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 AppView routing to use proxy fallback

* resolve OAuth entryway

* XRPC network interceptor for logging

+871 -178
+68 -56
docs/specs/routing.md
··· 1 1 --- 2 2 title: AppView Routing + Trending (Bluesky + Blacksky + microcosm Fallbacks) 3 - updated: 2026-04-29 3 + updated: 2026-04-30 4 4 --- 5 5 6 6 Introduce explicit AppView routing so Lazurite can target: ··· 23 23 24 24 ## Current State (Lazurite) 25 25 26 - - OAuth entryway is hardcoded to `bsky.social` in auth flow. 27 - - There is no shared AppView routing abstraction and no user-selectable AppView provider. 26 + - OAuth login now resolves account authority first (handle/DID -> PDS -> `authorization_servers`) and only falls back when resolution fails. 27 + - AppView provider routing is implemented for `app.bsky.*` headers/public reads, and `com.atproto.*` bypasses AppView routing. 28 28 - Home feed app bar has feed-management action only; no Trending action. 29 29 - Router has no `/trending` route. 30 30 ··· 96 96 Built-in defaults: 97 97 98 98 - Bluesky: `public.api.bsky.app`, `bsky.social`, `https://bsky.app` 99 - - Blacksky: `api.blacksky.community`, `blacksky.app`, `https://blacksky.app` 99 + - Blacksky: `api.blacksky.community`, `blacksky.community`, `https://blacksky.community` 100 100 101 101 ### Router abstraction 102 102 ··· 111 111 ### Request routing policy 112 112 113 113 1. Authenticated `app.bsky.*` 114 - - Route through PDS. 115 - - Set explicit `atproto-proxy` to selected provider DID. 116 - 114 + - Route through PDS. 115 + - Set explicit `atproto-proxy` to selected provider DID. 117 116 2. Signed-out/public `app.bsky.*` 118 - - Call selected provider `publicBaseUrl` directly. 119 - 117 + - Call selected provider `publicBaseUrl` directly. 120 118 3. `com.atproto.*` 121 - - Never AppView-routed; resolve PDS by DID/handle as normal. 119 + - Never AppView-routed; resolve PDS by DID/handle as normal. 122 120 123 121 ### Trending UX and routing 124 122 125 123 1. Home app bar adds `Trending` action button. 126 - - Route target: `/trending`. 127 - 124 + - Route target: `/trending`. 128 125 2. Add dedicated `TrendingScreen`. 129 - - Primary data source: `getTrendingTopics(limit=10)`. 130 - - Required enrichment (initial implementation): `getTrends(limit=10)` for richer metadata (actors, postCount, status/category). 131 - - UI sections: 132 - - `Topics` 133 - - `Suggested` (hidden when empty) 126 + - Primary data source: `getTrendingTopics(limit=10)`. 127 + - Required enrichment (initial implementation): `getTrends(limit=10)` for richer metadata (actors, postCount, status/category). 128 + - UI sections: 129 + - `Topics` 130 + - `Suggested` (hidden when empty) 134 131 135 132 Implementation note: 136 133 ··· 141 138 142 139 - Build a stable join key for both `topics[]` and `trends[]` before matching. 143 140 - Key precedence (in order): 141 + 144 142 1. Parsed link key (preferred): 145 - - `/topic/<id>` -> `topic:<id>` 146 - - `/profile/<actor>/feed/<rkey>` -> `feed:<actor>:<rkey>` 143 + - `/topic/<id>` -> `topic:<id>` 144 + - `/profile/<actor>/feed/<rkey>` -> `feed:<actor>:<rkey>` 147 145 2. Normalized topic string fallback: 148 - - lowercase 149 - - trim 150 - - collapse internal whitespace 151 - - drop leading `#` 152 - - Matching algorithm: 153 - 1. Try exact parsed-link-key match. 154 - 2. If absent, try normalized-topic-string match. 155 - 3. If multiple trend candidates match, pick the candidate with newest `startedAt`. 156 - 4. If still tied, pick lexicographically smallest `link` for deterministic output. 157 - 5. If no match, keep topic row and mark metadata as unavailable. 146 + - lowercase 147 + - trim 148 + - collapse internal whitespace 149 + - drop leading `#` 150 + - Matching algorithm: 151 + 1. Try exact parsed-link-key match. 152 + 2. If absent, try normalized-topic-string match. 153 + 3. If multiple trend candidates match, pick the candidate with newest `startedAt`. 154 + 4. If still tied, pick lexicographically smallest `link` for deterministic output. 155 + 5. If no match, keep topic row and mark metadata as unavailable. 158 156 159 - Trending UI state contract: 157 + Trending UI state contract: 160 158 161 - - `topics` load success + `trends` load success: 162 - - render fully enriched rows (actors/postCount/status/category when present). 163 - - `topics` load success + `trends` degraded/failure: 164 - - render topic rows without metadata fields. 165 - - show non-blocking banner/chip: `Metadata temporarily unavailable`. 166 - - keep row navigation actions enabled. 167 - - `topics` failure: 168 - - render blocking error state for Trending screen. 159 + - `topics` load success + `trends` load success: 160 + - render fully enriched rows (actors/postCount/status/category when present). 161 + - `topics` load success + `trends` degraded/failure: 162 + - render topic rows without metadata fields. 163 + - show non-blocking banner/chip: `Metadata temporarily unavailable`. 164 + - keep row navigation actions enabled. 165 + - `topics` failure: 166 + - render blocking error state for Trending screen. 169 167 170 168 3. Trend row actions: 171 - - Use provider-aware `resolveWebLink` for relative links. 172 - - If link maps to supported internal route, deep-link internally. 173 - - If unsupported, open external browser to provider `webBaseUrl + link`. 169 + - Use provider-aware `resolveWebLink` for relative links. 170 + - If link maps to supported internal route, deep-link internally. 171 + - If unsupported, open external browser to provider `webBaseUrl + link`. 174 172 175 173 4. Link parsing safety: 176 - - Never assume one provider link format. 177 - - Support at least: 178 - - `/profile/<actor>/feed/<rkey>` 179 - - `/topic/<id>` 180 - - Unknown path formats degrade to external open. 174 + - Never assume one provider link format. 175 + - Support at least: 176 + - `/profile/<actor>/feed/<rkey>` 177 + - `/topic/<id>` 178 + - Unknown path formats degrade to external open. 181 179 182 180 ### Fallback policy (defensive) 183 181 184 182 1. Try selected provider. 185 183 2. If cross-provider fallback setting is ON, then on transient read failures (`429`, `5xx`, timeout, DNS): 186 - - Try alternate built-in provider for read-only public endpoints. 184 + - Try alternate built-in provider for read-only public endpoints. 187 185 3. For non-AppView enrichments: 188 - - Backlink/index enrichments: Constellation. 189 - - Identity fallback: Slingshot `resolveMiniDoc` only when enabled. 186 + - Backlink/index enrichments: Constellation. 187 + - Identity fallback: Slingshot `resolveMiniDoc` only when enabled. 190 188 4. Log fallback reason/provider and apply endpoint-level circuit breaker. 191 189 192 190 Do not fallback across write operations. ··· 206 204 207 205 1. User selects provider in Settings. 208 206 2. Blocking confirmation sheet: 209 - - `Apply and restart now` 210 - - `Cancel` 211 - - Copy: user stays signed in; no local DB wipe. 207 + - `Apply and restart now` 208 + - `Cancel` 209 + - Copy: user stays signed in; no local DB wipe. 212 210 3. On confirm, perform soft restart: 213 - - Persist provider first. 214 - - Stop new requests and cancel in-flight work. 215 - - Tear down and rebuild app-level DI/blocs/services. 216 - - Return to bootstrap and rehydrate from persisted state. 211 + - Persist provider first. 212 + - Stop new requests and cancel in-flight work. 213 + - Tear down and rebuild app-level DI/blocs/services. 214 + - Return to bootstrap and rehydrate from persisted state. 217 215 218 216 This avoids mixed in-memory routing state while preserving session continuity. 219 217 ··· 229 227 1. Persist login-screen provider selection before any auth/network request. 230 228 2. Disable login submission while persistence is in-flight. 231 229 3. Construct auth/network clients only after provider setting loads at bootstrap. 230 + 231 + ### OAuth authority selection (account vs selected AppView) 232 + 233 + 1. Selected AppView controls content routing (`app.bsky.*`) and web link resolution. 234 + 2. OAuth authorization host must come from account authority metadata: 235 + - resolve handle/DID to PDS 236 + - fetch `/.well-known/oauth-protected-resource` 237 + - use `authorization_servers` issuer host first 238 + 3. Fallback chain when metadata lookup fails: 239 + - resolved PDS host 240 + - `bsky.social` 241 + - selected-provider entryway 242 + - final default fallback 243 + 4. Do not force OAuth host to selected AppView, because users can choose one AppView for reading while their account is hosted/authenticated elsewhere. 232 244 233 245 ### Health probes 234 246
+2 -2
docs/tasks/routing.md
··· 1 1 --- 2 2 title: AppView Routing + Trending Milestones 3 - updated: 2026-04-29 3 + updated: 2026-04-30 4 4 --- 5 5 6 6 ## M1 - Core Routing Model ··· 62 62 63 63 ## M8 - Auth + Reset Safety 64 64 65 - - [ ] Tie OAuth entryway default to selected provider (`bsky.social` / `blacksky.app`) 65 + - [x] Resolve OAuth entryway from account authority first (PDS `authorization_servers`), with provider/default fallbacks 66 66 - [ ] Ensure app-password and OAuth flows remain backward compatible 67 67 - [ ] Add migration behavior for existing saved sessions/accounts 68 68 - [ ] Ensure provider switch rebuilds DI/blocs/services before new requests
+2 -2
lib/core/network/app_view_provider.dart
··· 32 32 key: blackskyKey, 33 33 serviceDid: 'did:web:api.blacksky.community#bsky_appview', 34 34 publicBaseUrl: Uri.https('api.blacksky.community'), 35 - entrywayUrl: Uri.https('blacksky.app'), 36 - webBaseUrl: Uri.https('blacksky.app'), 35 + entrywayUrl: Uri.https('blacksky.community'), 36 + webBaseUrl: Uri.https('blacksky.community'), 37 37 ); 38 38 39 39 static final Map<String, AppViewProviderDescriptor> _builtIns = {blueskyKey: bluesky, blackskyKey: blacksky};
+45
lib/core/network/app_view_web_links.dart
··· 1 + import 'package:lazurite/core/logging/app_logger.dart'; 2 + import 'package:lazurite/core/network/app_view_provider.dart'; 3 + import 'package:lazurite/core/network/app_view_router.dart'; 4 + 5 + class AppViewWebLinks { 6 + const AppViewWebLinks._(); 7 + 8 + static String postFromAtUri(String atUri, {String? appViewProvider}) { 9 + final router = AppViewRouter(provider: AppViewProviders.descriptorForSetting(appViewProvider)); 10 + try { 11 + final match = RegExp(r'^at://([^/?#]+)/([^/?#]+)(?:/([^/?#]+))?').firstMatch(atUri.trim()); 12 + if (match == null) { 13 + return atUri; 14 + } 15 + 16 + final did = (match.group(1) ?? '').trim(); 17 + final collection = (match.group(2) ?? '').trim(); 18 + final rkey = (match.group(3) ?? '').trim(); 19 + if (did.isEmpty || collection.isEmpty || rkey.isEmpty) { 20 + return atUri; 21 + } 22 + 23 + if (collection != 'app.bsky.feed.post') { 24 + return atUri; 25 + } 26 + 27 + final relativePath = '/profile/$did/post/$rkey'; 28 + return router.resolveWebLink(relativePath).toString(); 29 + } catch (_) { 30 + log.d('failed to convert atUri to appView web URL'); 31 + return atUri; 32 + } 33 + } 34 + 35 + static String profile(String actor, {String? appViewProvider}) { 36 + final router = AppViewRouter(provider: AppViewProviders.descriptorForSetting(appViewProvider)); 37 + final normalizedActor = actor.trim(); 38 + if (normalizedActor.isEmpty) { 39 + return router.provider.webBaseUrl.toString(); 40 + } 41 + 42 + final relativePath = '/profile/$normalizedActor'; 43 + return router.resolveWebLink(relativePath).toString(); 44 + } 45 + }
+33 -6
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:lazurite/core/network/xrpc_network_interceptor.dart'; 6 7 import 'package:lazurite/features/auth/data/models/auth_models.dart'; 7 8 8 9 /// Creates a Bluesky client from authentication tokens. ··· 27 28 privateKey: tokens.dpopPrivateKey!, 28 29 ); 29 30 30 - return Bluesky.fromOAuthSession(oauthSession); 31 + return Bluesky.fromOAuthSession( 32 + oauthSession, 33 + getClient: XrpcNetworkInterceptor.wrapGetClient(), 34 + postClient: XrpcNetworkInterceptor.wrapPostClient(), 35 + ); 31 36 } 32 37 33 38 if (tokens.refreshToken == null) { ··· 41 46 refreshJwt: tokens.refreshToken!, 42 47 ); 43 48 44 - return Bluesky.fromSession(session, service: tokens.service); 49 + return Bluesky.fromSession( 50 + session, 51 + service: tokens.service, 52 + getClient: XrpcNetworkInterceptor.wrapGetClient(), 53 + postClient: XrpcNetworkInterceptor.wrapPostClient(), 54 + ); 45 55 } 46 56 47 57 BlueskyChat? createBlueSkyChatClient(AuthTokens? tokens) { ··· 60 70 privateKey: tokens.dpopPrivateKey!, 61 71 ); 62 72 63 - return BlueskyChat.fromOAuthSession(oauthSession); 73 + return BlueskyChat.fromOAuthSession( 74 + oauthSession, 75 + getClient: XrpcNetworkInterceptor.wrapGetClient(), 76 + postClient: XrpcNetworkInterceptor.wrapPostClient(), 77 + ); 64 78 } 65 79 66 80 if (tokens.refreshToken == null) return null; ··· 72 86 refreshJwt: tokens.refreshToken!, 73 87 ); 74 88 75 - return BlueskyChat.fromSession(session, service: tokens.service); 89 + return BlueskyChat.fromSession( 90 + session, 91 + service: tokens.service, 92 + getClient: XrpcNetworkInterceptor.wrapGetClient(), 93 + postClient: XrpcNetworkInterceptor.wrapPostClient(), 94 + ); 76 95 } 77 96 78 97 atp.ATProto createAtProtoForOAuthSession(atp_oauth.OAuthSession session) { 79 - return atp.ATProto.fromOAuthSession(session); 98 + return atp.ATProto.fromOAuthSession( 99 + session, 100 + getClient: XrpcNetworkInterceptor.wrapGetClient(), 101 + postClient: XrpcNetworkInterceptor.wrapPostClient(), 102 + ); 80 103 } 81 104 82 105 Bluesky createBlueskyForOAuthSession(atp_oauth.OAuthSession session) { 83 - return Bluesky.fromOAuthSession(session); 106 + return Bluesky.fromOAuthSession( 107 + session, 108 + getClient: XrpcNetworkInterceptor.wrapGetClient(), 109 + postClient: XrpcNetworkInterceptor.wrapPostClient(), 110 + ); 84 111 }
+141
lib/core/network/xrpc_network_interceptor.dart
··· 1 + import 'dart:convert'; 2 + 3 + import 'package:atproto_core/atproto_core.dart' as atp_core; 4 + import 'package:flutter/foundation.dart'; 5 + import 'package:http/http.dart' as http; 6 + import 'package:lazurite/core/logging/app_logger.dart'; 7 + 8 + class XrpcRequestMetadata { 9 + const XrpcRequestMetadata({required this.pdsHost, required this.appView, required this.xrpcMethod}); 10 + 11 + final String pdsHost; 12 + final String appView; 13 + final String xrpcMethod; 14 + } 15 + 16 + abstract final class XrpcNetworkInterceptor { 17 + static atp_core.GetClient wrapGetClient([atp_core.GetClient? baseClient]) { 18 + final delegate = baseClient ?? http.get; 19 + return (Uri url, {Map<String, String>? headers}) async { 20 + final metadata = metadataFor(url, headers: headers); 21 + final stopwatch = Stopwatch()..start(); 22 + log.t(_requestLogLine(httpMethod: 'GET', metadata: metadata)); 23 + try { 24 + final response = await delegate(url, headers: headers); 25 + _logResponse( 26 + httpMethod: 'GET', 27 + metadata: metadata, 28 + statusCode: response.statusCode, 29 + elapsed: stopwatch.elapsed, 30 + ); 31 + return response; 32 + } catch (error, stackTrace) { 33 + log.e( 34 + _failureLogLine(httpMethod: 'GET', metadata: metadata, elapsed: stopwatch.elapsed), 35 + error: error, 36 + stackTrace: stackTrace, 37 + ); 38 + rethrow; 39 + } 40 + }; 41 + } 42 + 43 + static atp_core.PostClient wrapPostClient([atp_core.PostClient? baseClient]) { 44 + final delegate = baseClient ?? http.post; 45 + return (Uri url, {Map<String, String>? headers, Object? body, Encoding? encoding}) async { 46 + final metadata = metadataFor(url, headers: headers); 47 + final stopwatch = Stopwatch()..start(); 48 + log.t(_requestLogLine(httpMethod: 'POST', metadata: metadata)); 49 + try { 50 + final response = await delegate(url, headers: headers, body: body, encoding: encoding); 51 + _logResponse( 52 + httpMethod: 'POST', 53 + metadata: metadata, 54 + statusCode: response.statusCode, 55 + elapsed: stopwatch.elapsed, 56 + ); 57 + return response; 58 + } catch (error, stackTrace) { 59 + log.e( 60 + _failureLogLine(httpMethod: 'POST', metadata: metadata, elapsed: stopwatch.elapsed), 61 + error: error, 62 + stackTrace: stackTrace, 63 + ); 64 + rethrow; 65 + } 66 + }; 67 + } 68 + 69 + @visibleForTesting 70 + static XrpcRequestMetadata metadataFor(Uri url, {Map<String, String>? headers}) { 71 + final pdsHost = url.host.isEmpty ? '<unknown>' : url.host; 72 + final xrpcMethod = _extractXrpcMethod(url); 73 + final appView = _headerValue(headers, 'atproto-proxy') ?? 'none'; 74 + return XrpcRequestMetadata(pdsHost: pdsHost, appView: appView, xrpcMethod: xrpcMethod); 75 + } 76 + 77 + static String _extractXrpcMethod(Uri url) { 78 + final segments = url.pathSegments.where((segment) => segment.isNotEmpty).toList(growable: false); 79 + final xrpcIndex = segments.indexOf('xrpc'); 80 + if (xrpcIndex >= 0 && xrpcIndex + 1 < segments.length) { 81 + return segments[xrpcIndex + 1]; 82 + } 83 + return '<unknown>'; 84 + } 85 + 86 + static String? _headerValue(Map<String, String>? headers, String key) { 87 + if (headers == null) { 88 + return null; 89 + } 90 + 91 + for (final entry in headers.entries) { 92 + if (entry.key.toLowerCase() == key.toLowerCase()) { 93 + return entry.value; 94 + } 95 + } 96 + return null; 97 + } 98 + 99 + static String _requestLogLine({required String httpMethod, required XrpcRequestMetadata metadata}) { 100 + return 'XRPC Request: method=$httpMethod, PDS=${metadata.pdsHost}, AppView=${metadata.appView}, ' 101 + 'XRPC method=${metadata.xrpcMethod}'; 102 + } 103 + 104 + static String _responseLogLine({ 105 + required String httpMethod, 106 + required XrpcRequestMetadata metadata, 107 + required int statusCode, 108 + required Duration elapsed, 109 + }) { 110 + return 'XRPC Response: method=$httpMethod, status=$statusCode, durationMs=${elapsed.inMilliseconds}, ' 111 + 'PDS=${metadata.pdsHost}, AppView=${metadata.appView}, XRPC method=${metadata.xrpcMethod}'; 112 + } 113 + 114 + static String _failureLogLine({ 115 + required String httpMethod, 116 + required XrpcRequestMetadata metadata, 117 + required Duration elapsed, 118 + }) { 119 + return 'XRPC Failure: method=$httpMethod, durationMs=${elapsed.inMilliseconds}, PDS=${metadata.pdsHost}, ' 120 + 'AppView=${metadata.appView}, XRPC method=${metadata.xrpcMethod}'; 121 + } 122 + 123 + static void _logResponse({ 124 + required String httpMethod, 125 + required XrpcRequestMetadata metadata, 126 + required int statusCode, 127 + required Duration elapsed, 128 + }) { 129 + final message = _responseLogLine( 130 + httpMethod: httpMethod, 131 + metadata: metadata, 132 + statusCode: statusCode, 133 + elapsed: elapsed, 134 + ); 135 + if (statusCode >= 400) { 136 + log.w(message); 137 + return; 138 + } 139 + log.t(message); 140 + } 141 + }
+6 -1
lib/core/router/app_router.dart
··· 9 9 import 'package:lazurite/core/logging/app_logger.dart'; 10 10 import 'package:lazurite/core/network/constellation_client.dart'; 11 11 import 'package:lazurite/core/network/app_view_provider.dart'; 12 + import 'package:lazurite/core/network/xrpc_network_interceptor.dart'; 12 13 import 'package:lazurite/core/router/app_shell.dart'; 13 14 import 'package:lazurite/core/router/fade_through_page.dart'; 14 15 import 'package:lazurite/features/alerts/presentation/alerts_screen.dart'; ··· 303 304 final appViewProvider = AppViewProviders.descriptorForSetting(settingsState.appViewProvider); 304 305 final repository = ProfileContextRepository( 305 306 bluesky: context.read<Bluesky>(), 306 - publicBluesky: Bluesky.anonymous(service: appViewProvider.publicBaseUrl.host), 307 + publicBluesky: Bluesky.anonymous( 308 + service: appViewProvider.publicBaseUrl.host, 309 + getClient: XrpcNetworkInterceptor.wrapGetClient(), 310 + postClient: XrpcNetworkInterceptor.wrapPostClient(), 311 + ), 307 312 constellationClient: ConstellationClient(baseUrl: constellationUrl), 308 313 ); 309 314 return _page(
+144 -12
lib/features/auth/data/auth_repository.dart
··· 13 13 import 'package:lazurite/core/network/atproto_host_resolver.dart'; 14 14 import 'package:lazurite/core/network/app_view_provider.dart'; 15 15 import 'package:lazurite/core/network/xrpc_client_factory.dart'; 16 + import 'package:lazurite/core/network/xrpc_network_interceptor.dart'; 16 17 import 'package:lazurite/features/auth/data/models/auth_models.dart'; 17 18 import 'package:url_launcher/url_launcher.dart'; 18 19 ··· 152 153 try { 153 154 _oauthCompleter = Completer<AuthTokens?>(); 154 155 _pendingHandle = handle.trim(); 155 - _pendingService = normalizeAtprotoServiceHost(_oauthServiceResolver()) ?? _oauthService; 156 + final preferredOauthService = normalizeAtprotoServiceHost(_oauthServiceResolver()) ?? _oauthService; 157 + String? resolvedPdsHost; 158 + String? resolvedAuthService; 159 + try { 160 + resolvedPdsHost = await _resolveServiceForIdentifier(_pendingHandle!); 161 + resolvedAuthService = await _resolveAuthorizationServiceForPdsHost(resolvedPdsHost); 162 + } catch (error, stackTrace) { 163 + log.w( 164 + 'AuthRepository: Failed to pre-resolve OAuth account authority for ${_pendingHandle!}; ' 165 + 'continuing with fallback auth service chain.', 166 + error: error, 167 + stackTrace: stackTrace, 168 + ); 169 + } 170 + final oauthServices = _oauthAuthorizeServiceCandidates( 171 + preferredAuthService: preferredOauthService, 172 + resolvedPdsHost: resolvedPdsHost, 173 + resolvedAuthService: resolvedAuthService, 174 + ); 156 175 log.i('AuthRepository: Starting OAuth login for ${_pendingHandle!}'); 176 + log.d('AuthRepository: OAuth auth service candidates: ${oauthServices.join(', ')}'); 157 177 158 178 final metadata = await _loadClientMetadata(kClientId); 159 179 log.d('AuthRepository: Loaded client metadata with redirect URIs: ${metadata.redirectUris.join(', ')}'); 160 180 final redirectUriTemplate = Uri.parse(metadata.redirectUris.first); 161 181 final redirectUri = await _startCallbackServer(redirectUriTemplate); 162 - final oauthClient = OAuthClient( 163 - metadata.copyWith(redirectUris: [redirectUri.toString()]), 164 - service: _pendingService!, 165 - ); 166 - final (authorizationUrl, context) = await oauthClient.authorize(_pendingHandle); 182 + 183 + Object? lastAttemptError; 184 + StackTrace? lastAttemptStackTrace; 185 + final failedAttemptSummaries = <String>[]; 186 + 187 + for (final oauthService in oauthServices) { 188 + try { 189 + final oauthClient = OAuthClient( 190 + metadata.copyWith(redirectUris: [redirectUri.toString()]), 191 + service: oauthService, 192 + ); 193 + final (authorizationUrl, context) = await oauthClient.authorize(_pendingHandle); 194 + 195 + _pendingService = oauthService; 196 + _pendingOAuthClient = oauthClient; 197 + _pendingOAuthContext = context; 198 + log.i('AuthRepository: OAuth PAR completed, launching browser to ${_sanitizeUriForLog(authorizationUrl)}'); 199 + await _launchUrl(authorizationUrl); 167 200 168 - _pendingOAuthClient = oauthClient; 169 - _pendingOAuthContext = context; 170 - log.i('AuthRepository: OAuth PAR completed, launching browser to ${_sanitizeUriForLog(authorizationUrl)}'); 171 - await _launchUrl(authorizationUrl); 201 + return await _oauthCompleter!.future; 202 + } catch (error, stackTrace) { 203 + lastAttemptError = error; 204 + lastAttemptStackTrace = stackTrace; 205 + final summary = _summarizeOAuthRefreshError(error); 206 + failedAttemptSummaries.add('$oauthService=$summary'); 207 + log.w( 208 + 'AuthRepository: OAuth authorize attempt failed using auth service $oauthService ($summary)', 209 + error: error, 210 + stackTrace: stackTrace, 211 + ); 212 + } 213 + } 172 214 173 - return await _oauthCompleter!.future; 215 + Error.throwWithStackTrace( 216 + Exception( 217 + 'OAuth authorize failed across ${oauthServices.length} auth service candidate(s). ' 218 + 'Attempts: ${failedAttemptSummaries.join(' | ')}. Last error: $lastAttemptError', 219 + ), 220 + lastAttemptStackTrace ?? StackTrace.current, 221 + ); 174 222 } catch (error, stackTrace) { 175 223 log.e('AuthRepository: OAuth login failed', error: error, stackTrace: stackTrace); 176 224 await _stopCallbackServer(); ··· 533 581 534 582 Future<String> _resolveServiceForIdentifier(String identifier) async { 535 583 log.d('AuthRepository: Resolving AT Protocol service for $identifier'); 536 - final client = atp.ATProto.anonymous(service: _fallbackService); 584 + final client = atp.ATProto.anonymous( 585 + service: _fallbackService, 586 + getClient: XrpcNetworkInterceptor.wrapGetClient(), 587 + postClient: XrpcNetworkInterceptor.wrapPostClient(), 588 + ); 537 589 538 590 final did = identifier.startsWith('did:') 539 591 ? identifier ··· 546 598 return serviceEndpoint; 547 599 } 548 600 601 + Future<String?> _resolveAuthorizationServiceForPdsHost(String pdsHost) async { 602 + final normalizedPdsHost = normalizeAtprotoServiceHost(pdsHost); 603 + if (normalizedPdsHost == null) { 604 + return null; 605 + } 606 + 607 + final uri = Uri.https(normalizedPdsHost, '/.well-known/oauth-protected-resource'); 608 + log.d('AuthRepository: Fetching protected resource metadata from ${_sanitizeUriForLog(uri)}'); 609 + final response = await http.get(uri); 610 + if (response.statusCode != HttpStatus.ok) { 611 + throw Exception( 612 + 'Failed to resolve authorization server for $normalizedPdsHost: ' 613 + 'HTTP ${response.statusCode}', 614 + ); 615 + } 616 + 617 + final decoded = jsonDecode(response.body); 618 + if (decoded is! Map<String, dynamic>) { 619 + throw Exception('Invalid protected resource metadata for $normalizedPdsHost'); 620 + } 621 + 622 + final rawAuthorizationServers = decoded['authorization_servers']; 623 + if (rawAuthorizationServers is! List) { 624 + return null; 625 + } 626 + 627 + for (final candidate in rawAuthorizationServers) { 628 + if (candidate is! String) { 629 + continue; 630 + } 631 + final host = normalizeAtprotoServiceHost(candidate); 632 + if (host != null && host.isNotEmpty) { 633 + return host; 634 + } 635 + } 636 + 637 + return null; 638 + } 639 + 549 640 Future<Map<String, dynamic>> _resolveDidDocument(String did) async { 550 641 final uri = _didDocumentUri(did); 551 642 log.d('AuthRepository: Fetching DID document from ${_sanitizeUriForLog(uri)}'); ··· 764 855 return candidates.toList(growable: false); 765 856 } 766 857 858 + static List<String> _oauthAuthorizeServiceCandidates({ 859 + required String? preferredAuthService, 860 + required String? resolvedPdsHost, 861 + required String? resolvedAuthService, 862 + }) { 863 + final candidates = <String>{}; 864 + 865 + final resolvedAuthHost = normalizeAtprotoServiceHost(resolvedAuthService); 866 + if (resolvedAuthHost != null) { 867 + candidates.add(resolvedAuthHost); 868 + } 869 + 870 + final resolvedHost = normalizeAtprotoServiceHost(resolvedPdsHost); 871 + if (resolvedHost != null) { 872 + candidates.add(resolvedHost); 873 + } 874 + 875 + candidates.add(_oauthService); 876 + 877 + final preferredHost = normalizeAtprotoServiceHost(preferredAuthService); 878 + if (preferredHost != null) { 879 + candidates.add(preferredHost); 880 + } 881 + 882 + candidates.add(_fallbackService); 883 + return candidates.toList(growable: false); 884 + } 885 + 767 886 @visibleForTesting 768 887 static List<String> oauthRefreshServiceCandidatesForTest({ 769 888 required String? storedAuthService, 770 889 required String? issuer, 771 890 }) { 772 891 return _oauthRefreshServiceCandidates(storedAuthService: storedAuthService, issuer: issuer); 892 + } 893 + 894 + @visibleForTesting 895 + static List<String> oauthAuthorizeServiceCandidatesForTest({ 896 + required String? preferredAuthService, 897 + required String? resolvedPdsHost, 898 + required String? resolvedAuthService, 899 + }) { 900 + return _oauthAuthorizeServiceCandidates( 901 + preferredAuthService: preferredAuthService, 902 + resolvedPdsHost: resolvedPdsHost, 903 + resolvedAuthService: resolvedAuthService, 904 + ); 773 905 } 774 906 775 907 @visibleForTesting
+50 -42
lib/features/auth/presentation/login_screen.dart
··· 76 76 ); 77 77 } 78 78 79 - bool _isHandleValid() { 80 - return _formKey.currentState?.validate() ?? false; 81 - } 79 + bool _isHandleValid() => _formKey.currentState?.validate() ?? false; 82 80 83 81 void _onTypeaheadSelected(TypeaheadResult result) { 84 82 _handleController.text = result.handle; ··· 163 161 builder: (context, settingsState) { 164 162 final selectedProvider = settingsState.appViewProvider; 165 163 return Column( 166 - crossAxisAlignment: CrossAxisAlignment.start, 164 + crossAxisAlignment: CrossAxisAlignment.center, 167 165 children: [ 168 166 Text( 169 - 'AppView Provider', 167 + 'Choose your portal', 168 + textAlign: TextAlign.center, 170 169 style: theme.textTheme.labelLarge?.copyWith(fontWeight: FontWeight.w600), 171 170 ), 172 171 const SizedBox(height: 8), 173 - SegmentedButton<String>( 174 - segments: const [ 175 - ButtonSegment<String>(value: AppViewProviders.blueskyKey, label: Text('Bluesky')), 176 - ButtonSegment<String>(value: AppViewProviders.blackskyKey, label: Text('Blacksky')), 177 - ], 178 - selected: {selectedProvider}, 179 - onSelectionChanged: (selection) { 180 - unawaited(context.read<SettingsCubit>().setAppViewProvider(selection.first)); 181 - }, 172 + Center( 173 + child: SegmentedButton<String>( 174 + segments: const [ 175 + ButtonSegment<String>( 176 + value: AppViewProviders.blueskyKey, 177 + label: _ProviderTabLabel(assetPath: 'assets/bluesky.svg', name: 'BlueSky'), 178 + ), 179 + ButtonSegment<String>( 180 + value: AppViewProviders.blackskyKey, 181 + label: _ProviderTabLabel(assetPath: 'assets/blacksky.svg', name: 'BlackSky'), 182 + ), 183 + ], 184 + selected: {selectedProvider}, 185 + onSelectionChanged: (selection) { 186 + unawaited(context.read<SettingsCubit>().setAppViewProvider(selection.first)); 187 + }, 188 + ), 182 189 ), 183 190 const SizedBox(height: 8), 184 - Text( 185 - selectedProvider == AppViewProviders.blackskyKey 186 - ? 'Sign-in will use Blacksky entryway defaults.' 187 - : 'Sign-in will use Bluesky entryway defaults.', 188 - style: theme.textTheme.bodySmall?.copyWith(color: colorScheme.onSurfaceVariant), 189 - ), 190 - const SizedBox(height: 16), 191 191 ], 192 192 ); 193 193 }, ··· 340 340 } 341 341 } 342 342 343 + class _ProviderTabLabel extends StatelessWidget { 344 + const _ProviderTabLabel({required this.assetPath, required this.name}); 345 + 346 + final String assetPath; 347 + final String name; 348 + 349 + @override 350 + Widget build(BuildContext context) => Row( 351 + mainAxisSize: MainAxisSize.min, 352 + children: [SvgPicture.asset(assetPath, height: 16), const SizedBox(width: 8), Text(name)], 353 + ); 354 + } 355 + 343 356 class _LogoCard extends StatelessWidget { 344 357 const _LogoCard({required this.colorScheme}); 345 358 346 359 final ColorScheme colorScheme; 347 360 348 361 @override 349 - Widget build(BuildContext context) { 350 - return Center( 351 - child: Container( 352 - width: 88, 353 - height: 88, 354 - decoration: BoxDecoration( 355 - borderRadius: BorderRadius.circular(24), 356 - gradient: LinearGradient(colors: [colorScheme.primary, colorScheme.secondary]), 357 - boxShadow: [ 358 - BoxShadow(color: colorScheme.primary.withValues(alpha: 0.24), blurRadius: 28, offset: const Offset(0, 12)), 359 - ], 360 - ), 361 - child: Padding( 362 - padding: const EdgeInsets.all(18), 363 - child: SvgPicture.asset( 364 - 'assets/logo.svg', 365 - colorFilter: const ColorFilter.mode(Colors.white, BlendMode.srcIn), 366 - ), 367 - ), 362 + Widget build(BuildContext context) => Center( 363 + child: Container( 364 + width: 88, 365 + height: 88, 366 + decoration: BoxDecoration( 367 + borderRadius: BorderRadius.circular(24), 368 + gradient: LinearGradient(colors: [colorScheme.primary, colorScheme.secondary]), 369 + boxShadow: [ 370 + BoxShadow(color: colorScheme.primary.withValues(alpha: 0.24), blurRadius: 28, offset: const Offset(0, 12)), 371 + ], 372 + ), 373 + child: Padding( 374 + padding: const EdgeInsets.all(18), 375 + child: SvgPicture.asset('assets/logo.svg', colorFilter: const ColorFilter.mode(Colors.white, BlendMode.srcIn)), 368 376 ), 369 - ); 370 - } 377 + ), 378 + ); 371 379 }
+35 -8
lib/features/feed/data/feed_repository.dart
··· 104 104 } 105 105 106 106 Future<PreferencesResult> getPreferences() async { 107 - final response = await _bluesky.actor.getPreferences( 108 - $headers: _appViewContext.appBskyHeaders(await _moderationService?.headersForRequest()), 109 - ); 110 - return PreferencesResult(preferences: response.data.preferences); 107 + final headers = _appViewContext.appBskyHeaders(await _moderationService?.headersForRequest()); 108 + try { 109 + final response = await _bluesky.actor.getPreferences($headers: headers); 110 + return PreferencesResult(preferences: response.data.preferences); 111 + } on XRPCException catch (error) { 112 + if (_shouldRetryPreferencesWithoutProxy(error: error, headers: headers)) { 113 + final response = await _bluesky.actor.getPreferences($headers: _withoutAppViewProxyHeader(headers)); 114 + return PreferencesResult(preferences: response.data.preferences); 115 + } 116 + rethrow; 117 + } 111 118 } 112 119 113 120 Future<void> putPreferences({required List<UPreferences> preferences}) async { 114 - await _bluesky.actor.putPreferences( 115 - preferences: preferences, 116 - $headers: _appViewContext.appBskyHeaders(await _moderationService?.headersForRequest()), 117 - ); 121 + final headers = _appViewContext.appBskyHeaders(await _moderationService?.headersForRequest()); 122 + try { 123 + await _bluesky.actor.putPreferences(preferences: preferences, $headers: headers); 124 + } on XRPCException catch (error) { 125 + if (_shouldRetryPreferencesWithoutProxy(error: error, headers: headers)) { 126 + await _bluesky.actor.putPreferences(preferences: preferences, $headers: _withoutAppViewProxyHeader(headers)); 127 + return; 128 + } 129 + rethrow; 130 + } 131 + } 132 + 133 + bool _shouldRetryPreferencesWithoutProxy({required XRPCException error, required Map<String, String> headers}) { 134 + return _hasAppViewProxyHeader(headers) && error.response.status.code == 404; 135 + } 136 + 137 + bool _hasAppViewProxyHeader(Map<String, String> headers) { 138 + return headers.keys.any((key) => key.toLowerCase() == 'atproto-proxy'); 139 + } 140 + 141 + Map<String, String> _withoutAppViewProxyHeader(Map<String, String> headers) { 142 + final copy = Map<String, String>.from(headers); 143 + copy.removeWhere((key, _) => key.toLowerCase() == 'atproto-proxy'); 144 + return copy; 118 145 } 119 146 120 147 Future<List<GeneratorView>> getSuggestedFeeds({String? cursor, int limit = 50}) async {
+2 -2
lib/features/feed/presentation/media/media_actions.dart
··· 4 4 import 'package:flutter/material.dart'; 5 5 import 'package:gal/gal.dart'; 6 6 import 'package:lazurite/core/logging/app_logger.dart'; 7 + import 'package:lazurite/shared/presentation/helpers/share_helper.dart'; 7 8 import 'package:path/path.dart' as p; 8 9 import 'package:path_provider/path_provider.dart'; 9 10 import 'package:permission_handler/permission_handler.dart'; 10 - import 'package:share_plus/share_plus.dart'; 11 11 12 12 enum MediaAssetType { image, video } 13 13 ··· 15 15 MediaActions._(); 16 16 17 17 static Future<void> shareImage(BuildContext context, String imageUrl) async { 18 - await Share.share(imageUrl); 18 + await ShareHelper.shareText(context, imageUrl); 19 19 } 20 20 21 21 static Future<void> downloadImage(
+6 -12
lib/features/feed/presentation/post_thread_screen.dart
··· 10 10 import 'package:flutter_bloc/flutter_bloc.dart'; 11 11 import 'package:go_router/go_router.dart'; 12 12 import 'package:intl/intl.dart'; 13 - import 'package:lazurite/core/logging/app_logger.dart'; 13 + import 'package:lazurite/core/network/app_view_provider.dart'; 14 + import 'package:lazurite/core/network/app_view_web_links.dart'; 14 15 import 'package:lazurite/features/compose/presentation/compose_route_args.dart'; 15 16 import 'package:lazurite/features/connectivity/cubit/connectivity_cubit.dart'; 16 17 import 'package:lazurite/features/feed/cubit/post_action_cache.dart'; ··· 926 927 HapticHelper.mediumImpact(); 927 928 final post = thread.post; 928 929 final postUri = post.uri.toString(); 929 - final bskyUrl = _convertAtUriToBskyUrl(postUri); 930 + final bskyUrl = AppViewWebLinks.postFromAtUri(postUri, appViewProvider: _resolveAppViewProvider(context)); 930 931 931 932 showOptionsSheet<void>( 932 933 context: context, ··· 1089 1090 return text is String ? text : ''; 1090 1091 } 1091 1092 1092 - String _convertAtUriToBskyUrl(String atUri) { 1093 + String _resolveAppViewProvider(BuildContext context) { 1093 1094 try { 1094 - final uri = Uri.parse(atUri); 1095 - final parts = uri.pathSegments; 1096 - if (parts.length >= 2) { 1097 - final did = uri.host; 1098 - final rkey = parts.last; 1099 - return 'https://bsky.app/profile/$did/post/$rkey'; 1100 - } 1095 + return context.read<SettingsCubit>().state.appViewProvider; 1101 1096 } catch (_) { 1102 - log.d('failed to convert atUri to bskyUrl'); 1097 + return AppViewProviders.defaultKey; 1103 1098 } 1104 - return atUri; 1105 1099 } 1106 1100 }
+11 -12
lib/features/feed/presentation/saved_posts_screen.dart
··· 7 7 import 'package:go_router/go_router.dart'; 8 8 import 'package:lazurite/core/database/app_database.dart'; 9 9 import 'package:lazurite/core/logging/app_logger.dart'; 10 + import 'package:lazurite/core/network/app_view_provider.dart'; 11 + import 'package:lazurite/core/network/app_view_web_links.dart'; 10 12 import 'package:lazurite/features/feed/cubit/saved_posts_cubit.dart'; 11 13 import 'package:lazurite/features/feed/data/post_action_repository.dart'; 12 14 import 'package:lazurite/features/feed/presentation/widgets/post_card_with_actions.dart'; 13 15 import 'package:lazurite/features/search/presentation/semantic_search_tab.dart'; 16 + import 'package:lazurite/features/settings/bloc/settings_cubit.dart'; 14 17 import 'package:lazurite/shared/presentation/widgets/animated_refresh_indicator.dart'; 15 18 import 'package:lazurite/shared/presentation/widgets/empty_state.dart'; 16 19 import 'package:lazurite/shared/presentation/widgets/error_state.dart'; 17 20 import 'package:lazurite/shared/presentation/widgets/loading_state.dart'; 21 + import 'package:lazurite/shared/presentation/helpers/share_helper.dart'; 18 22 import 'package:lazurite/shared/presentation/widgets/staggered_entrance.dart'; 19 - import 'package:share_plus/share_plus.dart'; 20 23 21 24 class SavedPostsScreen extends StatelessWidget { 22 25 const SavedPostsScreen({super.key, required this.accountDid}); ··· 223 226 ), 224 227 IconButton( 225 228 icon: const Icon(Icons.share_outlined), 226 - onPressed: () => Share.share(_convertAtUriToBskyUrl(savedPost.postUri)), 229 + onPressed: () => ShareHelper.shareText( 230 + context, 231 + AppViewWebLinks.postFromAtUri(savedPost.postUri, appViewProvider: _resolveAppViewProvider(context)), 232 + ), 227 233 tooltip: 'Share', 228 234 ), 229 235 IconButton(icon: const Icon(Icons.delete_outline), onPressed: onUnsave, tooltip: 'Remove'), ··· 244 250 return '${date.month}/${date.day}/${date.year}'; 245 251 } 246 252 247 - String _convertAtUriToBskyUrl(String atUri) { 253 + String _resolveAppViewProvider(BuildContext context) { 248 254 try { 249 - final uri = Uri.parse(atUri); 250 - final parts = uri.pathSegments; 251 - if (parts.length >= 2) { 252 - final did = uri.host; 253 - final rkey = parts.last; 254 - return 'https://bsky.app/profile/$did/post/$rkey'; 255 - } 255 + return context.read<SettingsCubit>().state.appViewProvider; 256 256 } catch (_) { 257 - log.d('failed to convert atUri to bskyUrl'); 257 + return AppViewProviders.defaultKey; 258 258 } 259 - return atUri; 260 259 } 261 260 }
+10 -14
lib/features/feed/presentation/widgets/post_action_bar.dart
··· 1 1 import 'package:flutter/material.dart'; 2 2 import 'package:flutter_animate/flutter_animate.dart'; 3 - import 'package:lazurite/core/logging/app_logger.dart'; 3 + import 'package:flutter_bloc/flutter_bloc.dart'; 4 + import 'package:lazurite/core/network/app_view_provider.dart'; 5 + import 'package:lazurite/core/network/app_view_web_links.dart'; 4 6 import 'package:lazurite/core/theme/animation_tokens.dart'; 5 7 import 'package:lazurite/features/connectivity/connectivity_helpers.dart'; 8 + import 'package:lazurite/features/settings/bloc/settings_cubit.dart'; 6 9 import 'package:lazurite/shared/presentation/helpers/haptic_helper.dart'; 10 + import 'package:lazurite/shared/presentation/helpers/share_helper.dart'; 7 11 import 'package:lazurite/shared/presentation/widgets/options_sheet.dart'; 8 12 import 'package:lazurite/shared/utils/format_utils.dart'; 9 - import 'package:share_plus/share_plus.dart'; 10 13 import 'package:lazurite/core/theme/theme_extensions.dart'; 11 14 12 15 class PostActionBar extends StatelessWidget { ··· 177 180 } 178 181 179 182 Future<void> _defaultShare(BuildContext context) async { 180 - final url = _convertAtUriToBskyUrl(postUri); 181 - await Share.share(url); 183 + final url = AppViewWebLinks.postFromAtUri(postUri, appViewProvider: _resolveAppViewProvider(context)); 184 + await ShareHelper.shareText(context, url); 182 185 } 183 186 184 - String _convertAtUriToBskyUrl(String atUri) { 187 + String _resolveAppViewProvider(BuildContext context) { 185 188 try { 186 - final uri = Uri.parse(atUri); 187 - final parts = uri.pathSegments; 188 - if (parts.length >= 2) { 189 - final did = uri.host; 190 - final rkey = parts.last; 191 - return 'https://bsky.app/profile/$did/post/$rkey'; 192 - } 189 + return context.read<SettingsCubit>().state.appViewProvider; 193 190 } catch (_) { 194 - log.d('failed to convert atUri to bskyUrl'); 191 + return AppViewProviders.defaultKey; 195 192 } 196 - return atUri; 197 193 } 198 194 } 199 195
+49 -2
lib/features/moderation/data/moderation_service.dart
··· 1 1 import 'dart:async'; 2 2 import 'dart:convert'; 3 3 4 + import 'package:atproto_core/atproto_core.dart' as atp_core; 4 5 import 'package:bluesky/app_bsky_actor_defs.dart'; 5 6 import 'package:bluesky/app_bsky_actor_getpreferences.dart'; 6 7 import 'package:bluesky/app_bsky_feed_defs.dart'; ··· 328 329 return _preferences; 329 330 } 330 331 332 + final headers = _appViewContext.appBskyHeaders(); 331 333 try { 332 - final prefsResponse = await _bluesky.actor.getPreferences(); 334 + final prefsResponse = await _bluesky.actor.getPreferences($headers: headers); 333 335 final preferences = prefsResponse.data.preferences; 334 336 await _cachePreferences(preferences); 335 337 return preferences; 338 + } on atp_core.XRPCException catch (error) { 339 + if (_shouldRetryPreferencesWithoutProxy(error: error, headers: headers)) { 340 + final fallbackHeaders = _withoutAppViewProxyHeader(headers); 341 + log.w( 342 + 'ModerationService: getPreferences returned ${error.response.status.code} with AppView proxy; ' 343 + 'retrying without atproto-proxy.', 344 + ); 345 + final prefsResponse = await _bluesky.actor.getPreferences($headers: fallbackHeaders); 346 + final preferences = prefsResponse.data.preferences; 347 + await _cachePreferences(preferences); 348 + return preferences; 349 + } 350 + rethrow; 336 351 } catch (error) { 337 352 final cached = await _loadCachedPreferences(); 338 353 if (cached != null) { ··· 345 360 } 346 361 347 362 Future<void> _putAndRefresh(List<UPreferences> preferences) async { 348 - await _bluesky.actor.putPreferences(preferences: preferences); 363 + final moderationPrefs = _toModerationPrefs(preferences); 364 + final headers = _buildHeadersForPrefs(moderationPrefs); 365 + try { 366 + await _bluesky.actor.putPreferences(preferences: preferences, $headers: headers); 367 + } on atp_core.XRPCException catch (error) { 368 + if (_shouldRetryPreferencesWithoutProxy(error: error, headers: headers)) { 369 + final fallbackHeaders = _withoutAppViewProxyHeader(headers); 370 + log.w( 371 + 'ModerationService: putPreferences returned ${error.response.status.code} with AppView proxy; ' 372 + 'retrying without atproto-proxy.', 373 + ); 374 + await _bluesky.actor.putPreferences(preferences: preferences, $headers: fallbackHeaders); 375 + } else { 376 + rethrow; 377 + } 378 + } 349 379 await updatePreferences(preferences: preferences); 350 380 } 351 381 ··· 545 575 546 576 Map<String, String> _buildHeadersForPrefs(bsky_moderation.ModerationPrefs prefs) { 547 577 return _appViewContext.appBskyHeaders(_buildLabelerHeaders(prefs.labelers.map((labeler) => labeler.did))); 578 + } 579 + 580 + bool _shouldRetryPreferencesWithoutProxy({ 581 + required atp_core.XRPCException error, 582 + required Map<String, String> headers, 583 + }) { 584 + return _hasAppViewProxyHeader(headers) && error.response.status.code == 404; 585 + } 586 + 587 + bool _hasAppViewProxyHeader(Map<String, String> headers) { 588 + return headers.keys.any((key) => key.toLowerCase() == 'atproto-proxy'); 589 + } 590 + 591 + Map<String, String> _withoutAppViewProxyHeader(Map<String, String> headers) { 592 + final copy = Map<String, String>.from(headers); 593 + copy.removeWhere((key, _) => key.toLowerCase() == 'atproto-proxy'); 594 + return copy; 548 595 } 549 596 550 597 String? get _preferencesCacheKey {
+15 -2
lib/features/profile/presentation/profile_screen.dart
··· 7 7 import 'package:flutter_bloc/flutter_bloc.dart'; 8 8 import 'package:go_router/go_router.dart'; 9 9 import 'package:intl/intl.dart'; 10 + import 'package:lazurite/core/network/app_view_provider.dart'; 11 + import 'package:lazurite/core/network/app_view_web_links.dart'; 10 12 import 'package:lazurite/core/router/app_shell.dart'; 11 13 import 'package:lazurite/core/theme/animation_tokens.dart'; 12 14 import 'package:lazurite/core/theme/animation_utils.dart'; ··· 42 44 import 'package:lazurite/features/starter_packs/data/starter_pack_repository.dart'; 43 45 import 'package:lazurite/features/starter_packs/presentation/widgets/starter_pack_card.dart'; 44 46 import 'package:lazurite/shared/presentation/helpers/navigation_helpers.dart'; 47 + import 'package:lazurite/shared/presentation/helpers/share_helper.dart'; 45 48 import 'package:lazurite/shared/presentation/helpers/snackbar_helper.dart'; 46 49 import 'package:lazurite/shared/presentation/widgets/options_sheet.dart'; 47 50 import 'package:lazurite/shared/utils/format_utils.dart'; 48 - import 'package:share_plus/share_plus.dart'; 49 51 import 'package:url_launcher/url_launcher.dart'; 50 52 51 53 class ProfileScreen extends StatefulWidget { ··· 561 563 OptionsSheetItem( 562 564 leading: const Icon(Icons.share_outlined), 563 565 title: 'Share Profile', 564 - onTap: () => Share.share('https://bsky.app/profile/${profile.handle}'), 566 + onTap: () => ShareHelper.shareText( 567 + context, 568 + AppViewWebLinks.profile(profile.handle, appViewProvider: _resolveAppViewProvider(context)), 569 + ), 565 570 ), 566 571 OptionsSheetItem( 567 572 leading: const Icon(Icons.playlist_add_outlined), ··· 656 661 ), 657 662 ), 658 663 ).whenComplete(cubit.close); 664 + } 665 + 666 + String _resolveAppViewProvider(BuildContext context) { 667 + try { 668 + return context.read<SettingsCubit>().state.appViewProvider; 669 + } catch (_) { 670 + return AppViewProviders.defaultKey; 671 + } 659 672 } 660 673 661 674 void _showSuggestedFollows(BuildContext context, ProfileViewDetailed profile) {
+27
lib/shared/presentation/helpers/share_helper.dart
··· 1 + import 'package:flutter/material.dart'; 2 + import 'package:share_plus/share_plus.dart'; 3 + 4 + class ShareHelper { 5 + const ShareHelper._(); 6 + 7 + static Future<void> shareText(BuildContext context, String text) { 8 + return Share.share(text, sharePositionOrigin: _sharePositionOrigin(context)); 9 + } 10 + 11 + static Rect _sharePositionOrigin(BuildContext context) { 12 + final renderObject = context.findRenderObject(); 13 + if (renderObject is RenderBox && renderObject.hasSize && !renderObject.size.isEmpty) { 14 + final rect = renderObject.localToGlobal(Offset.zero) & renderObject.size; 15 + if (!rect.isEmpty) { 16 + return rect; 17 + } 18 + } 19 + 20 + final mediaQuerySize = MediaQuery.maybeSizeOf(context); 21 + if (mediaQuerySize != null && !mediaQuerySize.isEmpty) { 22 + return Rect.fromLTWH(0, 0, mediaQuerySize.width, mediaQuerySize.height); 23 + } 24 + 25 + return const Rect.fromLTWH(0, 0, 1, 1); 26 + } 27 + }
+2 -2
test/core/network/app_view_provider_test.dart
··· 24 24 25 25 expect(blacksky.serviceDid, equals('did:web:api.blacksky.community#bsky_appview')); 26 26 expect(blacksky.publicBaseUrl.host, equals('api.blacksky.community')); 27 - expect(blacksky.entrywayUrl.host, equals('blacksky.app')); 28 - expect(blacksky.webBaseUrl.host, equals('blacksky.app')); 27 + expect(blacksky.entrywayUrl.host, equals('blacksky.community')); 28 + expect(blacksky.webBaseUrl.host, equals('blacksky.community')); 29 29 }); 30 30 }); 31 31 }
+68
test/core/network/xrpc_network_interceptor_test.dart
··· 1 + import 'package:flutter_test/flutter_test.dart'; 2 + import 'package:http/http.dart' as http; 3 + import 'package:lazurite/core/network/xrpc_network_interceptor.dart'; 4 + 5 + void main() { 6 + group('XrpcNetworkInterceptor', () { 7 + group('metadataFor', () { 8 + test('extracts pds, appview, and xrpc method', () { 9 + final metadata = XrpcNetworkInterceptor.metadataFor( 10 + Uri.parse('https://shaggymane.us-west.host.bsky.network/xrpc/app.bsky.actor.getPreferences?limit=100'), 11 + headers: {'atproto-proxy': 'did:web:api.blacksky.community#bsky_appview'}, 12 + ); 13 + 14 + expect(metadata.pdsHost, 'shaggymane.us-west.host.bsky.network'); 15 + expect(metadata.appView, 'did:web:api.blacksky.community#bsky_appview'); 16 + expect(metadata.xrpcMethod, 'app.bsky.actor.getPreferences'); 17 + }); 18 + 19 + test('handles case-insensitive appview header keys', () { 20 + final metadata = XrpcNetworkInterceptor.metadataFor( 21 + Uri.parse('https://example.com/xrpc/app.bsky.feed.getFeed'), 22 + headers: {'AtProto-Proxy': 'did:web:api.bsky.app#bsky_appview'}, 23 + ); 24 + 25 + expect(metadata.appView, 'did:web:api.bsky.app#bsky_appview'); 26 + }); 27 + 28 + test('returns defaults for non-xrpc paths and missing appview header', () { 29 + final metadata = XrpcNetworkInterceptor.metadataFor(Uri.parse('https://example.com/.well-known/did.json')); 30 + 31 + expect(metadata.pdsHost, 'example.com'); 32 + expect(metadata.appView, 'none'); 33 + expect(metadata.xrpcMethod, '<unknown>'); 34 + }); 35 + }); 36 + 37 + group('wrap clients', () { 38 + test('wrapGetClient delegates request and returns response', () async { 39 + final wrapped = XrpcNetworkInterceptor.wrapGetClient((url, {headers}) async { 40 + return http.Response('ok', 200, request: http.Request('GET', url)); 41 + }); 42 + 43 + final response = await wrapped( 44 + Uri.parse('https://example.com/xrpc/app.bsky.actor.getProfile'), 45 + headers: const {'atproto-proxy': 'did:web:api.bsky.app#bsky_appview'}, 46 + ); 47 + 48 + expect(response.statusCode, 200); 49 + expect(response.body, 'ok'); 50 + }); 51 + 52 + test('wrapPostClient delegates request and returns response', () async { 53 + final wrapped = XrpcNetworkInterceptor.wrapPostClient((url, {headers, body, encoding}) async { 54 + return http.Response('created', 201, request: http.Request('POST', url)); 55 + }); 56 + 57 + final response = await wrapped( 58 + Uri.parse('https://example.com/xrpc/app.bsky.actor.putPreferences'), 59 + headers: const {'atproto-proxy': 'did:web:api.blacksky.community#bsky_appview'}, 60 + body: const {'k': 'v'}, 61 + ); 62 + 63 + expect(response.statusCode, 201); 64 + expect(response.body, 'created'); 65 + }); 66 + }); 67 + }); 68 + }
+22
test/features/auth/data/auth_repository_test.dart
··· 249 249 }); 250 250 }); 251 251 252 + group('oauth authorize candidates', () { 253 + test('prioritizes resolved auth service before provider preference', () { 254 + final candidates = AuthRepository.oauthAuthorizeServiceCandidatesForTest( 255 + preferredAuthService: 'blacksky.community', 256 + resolvedPdsHost: 'https://porcini.us-east.host.bsky.network', 257 + resolvedAuthService: 'https://bsky.social', 258 + ); 259 + 260 + expect(candidates, equals(['bsky.social', 'porcini.us-east.host.bsky.network', 'blacksky.community'])); 261 + }); 262 + 263 + test('deduplicates when preferred and resolved hosts match defaults', () { 264 + final candidates = AuthRepository.oauthAuthorizeServiceCandidatesForTest( 265 + preferredAuthService: 'https://bsky.social', 266 + resolvedPdsHost: 'bsky.social', 267 + resolvedAuthService: 'bsky.social', 268 + ); 269 + 270 + expect(candidates, equals(['bsky.social'])); 271 + }); 272 + }); 273 + 252 274 group('clearSession', () { 253 275 test('should delete all accounts', () async { 254 276 when(() => mockDatabase.deleteAllAccounts()).thenAnswer((_) async => 1);
+94 -3
test/features/moderation/data/moderation_service_test.dart
··· 157 157 service.dispose(); 158 158 }); 159 159 160 + test('uses selected AppView proxy headers for preference reads and writes', () async { 161 + final actor = _FakeActorService(preferences: const []); 162 + final service = ModerationService( 163 + bluesky: _FakeBlueskyClient(actor: actor, labeler: const _FakeLabelerService()), 164 + database: database, 165 + accountDid: _accountDid, 166 + userDid: _accountDid, 167 + appViewProvider: 'blacksky', 168 + ); 169 + 170 + await service.ensureInitialized(); 171 + expect(actor.lastGetPreferencesHeaders?['atproto-proxy'], 'did:web:api.blacksky.community#bsky_appview'); 172 + 173 + await service.subscribeToLabeler(_customLabelerDid); 174 + expect(actor.lastPutPreferencesHeaders?['atproto-proxy'], 'did:web:api.blacksky.community#bsky_appview'); 175 + expect(actor.lastPutPreferencesHeaders?['atproto-accept-labelers'], contains(_customLabelerDid)); 176 + 177 + service.dispose(); 178 + }); 179 + 180 + test('retries preferences without AppView proxy when proxied request returns 404', () async { 181 + final actor = _FakeActorService( 182 + preferences: const [], 183 + errorOnProxyGetPreferences: _proxyNotSupported( 184 + method: HttpMethod.get, 185 + xrpcMethod: 'app.bsky.actor.getPreferences', 186 + ), 187 + errorOnProxyPutPreferences: _proxyNotSupported( 188 + method: HttpMethod.post, 189 + xrpcMethod: 'app.bsky.actor.putPreferences', 190 + ), 191 + ); 192 + final service = ModerationService( 193 + bluesky: _FakeBlueskyClient(actor: actor, labeler: const _FakeLabelerService()), 194 + database: database, 195 + accountDid: _accountDid, 196 + userDid: _accountDid, 197 + appViewProvider: 'blacksky', 198 + ); 199 + 200 + await service.ensureInitialized(); 201 + expect(actor.getPreferencesCallCount, 2); 202 + expect(actor.lastGetPreferencesHeaders?['atproto-proxy'], isNull); 203 + 204 + await service.subscribeToLabeler(_customLabelerDid); 205 + expect(actor.putPreferencesCallCount, 2); 206 + expect(actor.lastPutPreferencesHeaders?['atproto-proxy'], isNull); 207 + 208 + service.dispose(); 209 + }); 210 + 160 211 test('setLabelPreference stores contentLabelPref entries', () async { 161 212 final actor = _FakeActorService(preferences: const []); 162 213 final service = ModerationService( ··· 231 282 } 232 283 233 284 class _FakeActorService { 234 - _FakeActorService({this.preferences = const [], this.error}); 285 + _FakeActorService({ 286 + this.preferences = const [], 287 + this.error, 288 + this.errorOnProxyGetPreferences, 289 + this.errorOnProxyPutPreferences, 290 + }); 235 291 236 292 final List<UPreferences> preferences; 237 293 final Object? error; 294 + final Object? errorOnProxyGetPreferences; 295 + final Object? errorOnProxyPutPreferences; 238 296 List<UPreferences>? lastPutPreferences; 297 + Map<String, String>? lastGetPreferencesHeaders; 298 + Map<String, String>? lastPutPreferencesHeaders; 299 + int getPreferencesCallCount = 0; 300 + int putPreferencesCallCount = 0; 239 301 240 - Future<_FakePreferencesResponse> getPreferences() async { 302 + Future<_FakePreferencesResponse> getPreferences({Map<String, String>? $headers}) async { 303 + getPreferencesCallCount++; 241 304 if (error != null) { 242 305 throw error!; 243 306 } 307 + if (_hasAppViewProxyHeader($headers) && errorOnProxyGetPreferences != null) { 308 + throw errorOnProxyGetPreferences!; 309 + } 310 + lastGetPreferencesHeaders = $headers; 244 311 return _FakePreferencesResponse(_FakePreferencesData(preferences)); 245 312 } 246 313 247 - Future<void> putPreferences({required List<UPreferences> preferences}) async { 314 + Future<void> putPreferences({required List<UPreferences> preferences, Map<String, String>? $headers}) async { 315 + putPreferencesCallCount++; 316 + if (_hasAppViewProxyHeader($headers) && errorOnProxyPutPreferences != null) { 317 + throw errorOnProxyPutPreferences!; 318 + } 248 319 lastPutPreferences = preferences; 320 + lastPutPreferencesHeaders = $headers; 321 + } 322 + 323 + bool _hasAppViewProxyHeader(Map<String, String>? headers) { 324 + if (headers == null) { 325 + return false; 326 + } 327 + return headers.keys.any((key) => key.toLowerCase() == 'atproto-proxy'); 249 328 } 250 329 } 251 330 ··· 289 368 290 369 final List<ULabelerGetServicesViews> views; 291 370 } 371 + 372 + InvalidRequestException _proxyNotSupported({required HttpMethod method, required String xrpcMethod}) { 373 + return InvalidRequestException( 374 + XRPCResponse<XRPCError>( 375 + headers: const {}, 376 + status: HttpStatus.notFound, 377 + request: XRPCRequest(method: method, url: Uri.parse('https://example.test/xrpc/$xrpcMethod')), 378 + rateLimit: RateLimit.unlimited(), 379 + data: const XRPCError(error: 'XRPCNotSupported', message: 'XRPC Not Supported'), 380 + ), 381 + ); 382 + }