diff --git a/docs/specs/routing.md b/docs/specs/routing.md index 896d192..2283564 100644 --- a/docs/specs/routing.md +++ b/docs/specs/routing.md @@ -1,6 +1,6 @@ --- title: AppView Routing + Trending (Bluesky + Blacksky + microcosm Fallbacks) -updated: 2026-04-29 +updated: 2026-04-30 --- Introduce explicit AppView routing so Lazurite can target: @@ -23,8 +23,8 @@ Design goal: route intentionally, fail predictably, and avoid hidden dependence ## Current State (Lazurite) -- OAuth entryway is hardcoded to `bsky.social` in auth flow. -- There is no shared AppView routing abstraction and no user-selectable AppView provider. +- OAuth login now resolves account authority first (handle/DID -> PDS -> `authorization_servers`) and only falls back when resolution fails. +- AppView provider routing is implemented for `app.bsky.*` headers/public reads, and `com.atproto.*` bypasses AppView routing. - Home feed app bar has feed-management action only; no Trending action. - Router has no `/trending` route. @@ -96,7 +96,7 @@ class AppViewProvider { Built-in defaults: - Bluesky: `public.api.bsky.app`, `bsky.social`, `https://bsky.app` -- Blacksky: `api.blacksky.community`, `blacksky.app`, `https://blacksky.app` +- Blacksky: `api.blacksky.community`, `blacksky.community`, `https://blacksky.community` ### Router abstraction @@ -111,26 +111,23 @@ Introduce `AppViewRouter` as single source of truth: ### Request routing policy 1. Authenticated `app.bsky.*` -- Route through PDS. -- Set explicit `atproto-proxy` to selected provider DID. - + - Route through PDS. + - Set explicit `atproto-proxy` to selected provider DID. 2. Signed-out/public `app.bsky.*` -- Call selected provider `publicBaseUrl` directly. - + - Call selected provider `publicBaseUrl` directly. 3. `com.atproto.*` -- Never AppView-routed; resolve PDS by DID/handle as normal. + - Never AppView-routed; resolve PDS by DID/handle as normal. ### Trending UX and routing 1. Home app bar adds `Trending` action button. -- Route target: `/trending`. - + - Route target: `/trending`. 2. Add dedicated `TrendingScreen`. -- Primary data source: `getTrendingTopics(limit=10)`. -- Required enrichment (initial implementation): `getTrends(limit=10)` for richer metadata (actors, postCount, status/category). -- UI sections: - - `Topics` - - `Suggested` (hidden when empty) + - Primary data source: `getTrendingTopics(limit=10)`. + - Required enrichment (initial implementation): `getTrends(limit=10)` for richer metadata (actors, postCount, status/category). + - UI sections: + - `Topics` + - `Suggested` (hidden when empty) Implementation note: @@ -141,52 +138,53 @@ Deterministic join contract: - Build a stable join key for both `topics[]` and `trends[]` before matching. - Key precedence (in order): + 1. Parsed link key (preferred): - - `/topic/` -> `topic:` - - `/profile//feed/` -> `feed::` + - `/topic/` -> `topic:` + - `/profile//feed/` -> `feed::` 2. Normalized topic string fallback: - - lowercase - - trim - - collapse internal whitespace - - drop leading `#` -- Matching algorithm: -1. Try exact parsed-link-key match. -2. If absent, try normalized-topic-string match. -3. If multiple trend candidates match, pick the candidate with newest `startedAt`. -4. If still tied, pick lexicographically smallest `link` for deterministic output. -5. If no match, keep topic row and mark metadata as unavailable. - -Trending UI state contract: - -- `topics` load success + `trends` load success: - - render fully enriched rows (actors/postCount/status/category when present). -- `topics` load success + `trends` degraded/failure: - - render topic rows without metadata fields. - - show non-blocking banner/chip: `Metadata temporarily unavailable`. - - keep row navigation actions enabled. -- `topics` failure: - - render blocking error state for Trending screen. + - lowercase + - trim + - collapse internal whitespace + - drop leading `#` + - Matching algorithm: + 1. Try exact parsed-link-key match. + 2. If absent, try normalized-topic-string match. + 3. If multiple trend candidates match, pick the candidate with newest `startedAt`. + 4. If still tied, pick lexicographically smallest `link` for deterministic output. + 5. If no match, keep topic row and mark metadata as unavailable. + + Trending UI state contract: + + - `topics` load success + `trends` load success: + - render fully enriched rows (actors/postCount/status/category when present). + - `topics` load success + `trends` degraded/failure: + - render topic rows without metadata fields. + - show non-blocking banner/chip: `Metadata temporarily unavailable`. + - keep row navigation actions enabled. + - `topics` failure: + - render blocking error state for Trending screen. 3. Trend row actions: -- Use provider-aware `resolveWebLink` for relative links. -- If link maps to supported internal route, deep-link internally. -- If unsupported, open external browser to provider `webBaseUrl + link`. + - Use provider-aware `resolveWebLink` for relative links. + - If link maps to supported internal route, deep-link internally. + - If unsupported, open external browser to provider `webBaseUrl + link`. 4. Link parsing safety: -- Never assume one provider link format. -- Support at least: - - `/profile//feed/` - - `/topic/` -- Unknown path formats degrade to external open. + - Never assume one provider link format. + - Support at least: + - `/profile//feed/` + - `/topic/` + - Unknown path formats degrade to external open. ### Fallback policy (defensive) 1. Try selected provider. 2. If cross-provider fallback setting is ON, then on transient read failures (`429`, `5xx`, timeout, DNS): -- Try alternate built-in provider for read-only public endpoints. + - Try alternate built-in provider for read-only public endpoints. 3. For non-AppView enrichments: -- Backlink/index enrichments: Constellation. -- Identity fallback: Slingshot `resolveMiniDoc` only when enabled. + - Backlink/index enrichments: Constellation. + - Identity fallback: Slingshot `resolveMiniDoc` only when enabled. 4. Log fallback reason/provider and apply endpoint-level circuit breaker. Do not fallback across write operations. @@ -206,14 +204,14 @@ Best contract for provider switching: 1. User selects provider in Settings. 2. Blocking confirmation sheet: -- `Apply and restart now` -- `Cancel` -- Copy: user stays signed in; no local DB wipe. + - `Apply and restart now` + - `Cancel` + - Copy: user stays signed in; no local DB wipe. 3. On confirm, perform soft restart: -- Persist provider first. -- Stop new requests and cancel in-flight work. -- Tear down and rebuild app-level DI/blocs/services. -- Return to bootstrap and rehydrate from persisted state. + - Persist provider first. + - Stop new requests and cancel in-flight work. + - Tear down and rebuild app-level DI/blocs/services. + - Return to bootstrap and rehydrate from persisted state. This avoids mixed in-memory routing state while preserving session continuity. @@ -230,6 +228,20 @@ This avoids mixed in-memory routing state while preserving session continuity. 2. Disable login submission while persistence is in-flight. 3. Construct auth/network clients only after provider setting loads at bootstrap. +### OAuth authority selection (account vs selected AppView) + +1. Selected AppView controls content routing (`app.bsky.*`) and web link resolution. +2. OAuth authorization host must come from account authority metadata: + - resolve handle/DID to PDS + - fetch `/.well-known/oauth-protected-resource` + - use `authorization_servers` issuer host first +3. Fallback chain when metadata lookup fails: + - resolved PDS host + - `bsky.social` + - selected-provider entryway + - final default fallback +4. Do not force OAuth host to selected AppView, because users can choose one AppView for reading while their account is hosted/authenticated elsewhere. + ### Health probes - Run once at startup. diff --git a/docs/tasks/routing.md b/docs/tasks/routing.md index 816b382..9ea988c 100644 --- a/docs/tasks/routing.md +++ b/docs/tasks/routing.md @@ -1,6 +1,6 @@ --- title: AppView Routing + Trending Milestones -updated: 2026-04-29 +updated: 2026-04-30 --- ## M1 - Core Routing Model @@ -62,7 +62,7 @@ updated: 2026-04-29 ## M8 - Auth + Reset Safety -- [ ] Tie OAuth entryway default to selected provider (`bsky.social` / `blacksky.app`) +- [x] Resolve OAuth entryway from account authority first (PDS `authorization_servers`), with provider/default fallbacks - [ ] Ensure app-password and OAuth flows remain backward compatible - [ ] Add migration behavior for existing saved sessions/accounts - [ ] Ensure provider switch rebuilds DI/blocs/services before new requests diff --git a/lib/core/network/app_view_provider.dart b/lib/core/network/app_view_provider.dart index 5d3c04f..80bf53c 100644 --- a/lib/core/network/app_view_provider.dart +++ b/lib/core/network/app_view_provider.dart @@ -32,8 +32,8 @@ abstract final class AppViewProviders { key: blackskyKey, serviceDid: 'did:web:api.blacksky.community#bsky_appview', publicBaseUrl: Uri.https('api.blacksky.community'), - entrywayUrl: Uri.https('blacksky.app'), - webBaseUrl: Uri.https('blacksky.app'), + entrywayUrl: Uri.https('blacksky.community'), + webBaseUrl: Uri.https('blacksky.community'), ); static final Map _builtIns = {blueskyKey: bluesky, blackskyKey: blacksky}; diff --git a/lib/core/network/app_view_web_links.dart b/lib/core/network/app_view_web_links.dart new file mode 100644 index 0000000..e9e7a74 --- /dev/null +++ b/lib/core/network/app_view_web_links.dart @@ -0,0 +1,45 @@ +import 'package:lazurite/core/logging/app_logger.dart'; +import 'package:lazurite/core/network/app_view_provider.dart'; +import 'package:lazurite/core/network/app_view_router.dart'; + +class AppViewWebLinks { + const AppViewWebLinks._(); + + static String postFromAtUri(String atUri, {String? appViewProvider}) { + final router = AppViewRouter(provider: AppViewProviders.descriptorForSetting(appViewProvider)); + try { + final match = RegExp(r'^at://([^/?#]+)/([^/?#]+)(?:/([^/?#]+))?').firstMatch(atUri.trim()); + if (match == null) { + return atUri; + } + + final did = (match.group(1) ?? '').trim(); + final collection = (match.group(2) ?? '').trim(); + final rkey = (match.group(3) ?? '').trim(); + if (did.isEmpty || collection.isEmpty || rkey.isEmpty) { + return atUri; + } + + if (collection != 'app.bsky.feed.post') { + return atUri; + } + + final relativePath = '/profile/$did/post/$rkey'; + return router.resolveWebLink(relativePath).toString(); + } catch (_) { + log.d('failed to convert atUri to appView web URL'); + return atUri; + } + } + + static String profile(String actor, {String? appViewProvider}) { + final router = AppViewRouter(provider: AppViewProviders.descriptorForSetting(appViewProvider)); + final normalizedActor = actor.trim(); + if (normalizedActor.isEmpty) { + return router.provider.webBaseUrl.toString(); + } + + final relativePath = '/profile/$normalizedActor'; + return router.resolveWebLink(relativePath).toString(); + } +} diff --git a/lib/core/network/xrpc_client_factory.dart b/lib/core/network/xrpc_client_factory.dart index 302d79c..f1975ac 100644 --- a/lib/core/network/xrpc_client_factory.dart +++ b/lib/core/network/xrpc_client_factory.dart @@ -3,6 +3,7 @@ import 'package:atproto_core/atproto_core.dart' as atp_core; import 'package:atproto_oauth/atproto_oauth.dart' as atp_oauth; import 'package:bluesky/bluesky.dart'; import 'package:bluesky/bluesky_chat.dart'; +import 'package:lazurite/core/network/xrpc_network_interceptor.dart'; import 'package:lazurite/features/auth/data/models/auth_models.dart'; /// Creates a Bluesky client from authentication tokens. @@ -27,7 +28,11 @@ Bluesky? createBlueskyClient(AuthTokens? tokens) { privateKey: tokens.dpopPrivateKey!, ); - return Bluesky.fromOAuthSession(oauthSession); + return Bluesky.fromOAuthSession( + oauthSession, + getClient: XrpcNetworkInterceptor.wrapGetClient(), + postClient: XrpcNetworkInterceptor.wrapPostClient(), + ); } if (tokens.refreshToken == null) { @@ -41,7 +46,12 @@ Bluesky? createBlueskyClient(AuthTokens? tokens) { refreshJwt: tokens.refreshToken!, ); - return Bluesky.fromSession(session, service: tokens.service); + return Bluesky.fromSession( + session, + service: tokens.service, + getClient: XrpcNetworkInterceptor.wrapGetClient(), + postClient: XrpcNetworkInterceptor.wrapPostClient(), + ); } BlueskyChat? createBlueSkyChatClient(AuthTokens? tokens) { @@ -60,7 +70,11 @@ BlueskyChat? createBlueSkyChatClient(AuthTokens? tokens) { privateKey: tokens.dpopPrivateKey!, ); - return BlueskyChat.fromOAuthSession(oauthSession); + return BlueskyChat.fromOAuthSession( + oauthSession, + getClient: XrpcNetworkInterceptor.wrapGetClient(), + postClient: XrpcNetworkInterceptor.wrapPostClient(), + ); } if (tokens.refreshToken == null) return null; @@ -72,13 +86,26 @@ BlueskyChat? createBlueSkyChatClient(AuthTokens? tokens) { refreshJwt: tokens.refreshToken!, ); - return BlueskyChat.fromSession(session, service: tokens.service); + return BlueskyChat.fromSession( + session, + service: tokens.service, + getClient: XrpcNetworkInterceptor.wrapGetClient(), + postClient: XrpcNetworkInterceptor.wrapPostClient(), + ); } atp.ATProto createAtProtoForOAuthSession(atp_oauth.OAuthSession session) { - return atp.ATProto.fromOAuthSession(session); + return atp.ATProto.fromOAuthSession( + session, + getClient: XrpcNetworkInterceptor.wrapGetClient(), + postClient: XrpcNetworkInterceptor.wrapPostClient(), + ); } Bluesky createBlueskyForOAuthSession(atp_oauth.OAuthSession session) { - return Bluesky.fromOAuthSession(session); + return Bluesky.fromOAuthSession( + session, + getClient: XrpcNetworkInterceptor.wrapGetClient(), + postClient: XrpcNetworkInterceptor.wrapPostClient(), + ); } diff --git a/lib/core/network/xrpc_network_interceptor.dart b/lib/core/network/xrpc_network_interceptor.dart new file mode 100644 index 0000000..5892373 --- /dev/null +++ b/lib/core/network/xrpc_network_interceptor.dart @@ -0,0 +1,141 @@ +import 'dart:convert'; + +import 'package:atproto_core/atproto_core.dart' as atp_core; +import 'package:flutter/foundation.dart'; +import 'package:http/http.dart' as http; +import 'package:lazurite/core/logging/app_logger.dart'; + +class XrpcRequestMetadata { + const XrpcRequestMetadata({required this.pdsHost, required this.appView, required this.xrpcMethod}); + + final String pdsHost; + final String appView; + final String xrpcMethod; +} + +abstract final class XrpcNetworkInterceptor { + static atp_core.GetClient wrapGetClient([atp_core.GetClient? baseClient]) { + final delegate = baseClient ?? http.get; + return (Uri url, {Map? headers}) async { + final metadata = metadataFor(url, headers: headers); + final stopwatch = Stopwatch()..start(); + log.t(_requestLogLine(httpMethod: 'GET', metadata: metadata)); + try { + final response = await delegate(url, headers: headers); + _logResponse( + httpMethod: 'GET', + metadata: metadata, + statusCode: response.statusCode, + elapsed: stopwatch.elapsed, + ); + return response; + } catch (error, stackTrace) { + log.e( + _failureLogLine(httpMethod: 'GET', metadata: metadata, elapsed: stopwatch.elapsed), + error: error, + stackTrace: stackTrace, + ); + rethrow; + } + }; + } + + static atp_core.PostClient wrapPostClient([atp_core.PostClient? baseClient]) { + final delegate = baseClient ?? http.post; + return (Uri url, {Map? headers, Object? body, Encoding? encoding}) async { + final metadata = metadataFor(url, headers: headers); + final stopwatch = Stopwatch()..start(); + log.t(_requestLogLine(httpMethod: 'POST', metadata: metadata)); + try { + final response = await delegate(url, headers: headers, body: body, encoding: encoding); + _logResponse( + httpMethod: 'POST', + metadata: metadata, + statusCode: response.statusCode, + elapsed: stopwatch.elapsed, + ); + return response; + } catch (error, stackTrace) { + log.e( + _failureLogLine(httpMethod: 'POST', metadata: metadata, elapsed: stopwatch.elapsed), + error: error, + stackTrace: stackTrace, + ); + rethrow; + } + }; + } + + @visibleForTesting + static XrpcRequestMetadata metadataFor(Uri url, {Map? headers}) { + final pdsHost = url.host.isEmpty ? '' : url.host; + final xrpcMethod = _extractXrpcMethod(url); + final appView = _headerValue(headers, 'atproto-proxy') ?? 'none'; + return XrpcRequestMetadata(pdsHost: pdsHost, appView: appView, xrpcMethod: xrpcMethod); + } + + static String _extractXrpcMethod(Uri url) { + final segments = url.pathSegments.where((segment) => segment.isNotEmpty).toList(growable: false); + final xrpcIndex = segments.indexOf('xrpc'); + if (xrpcIndex >= 0 && xrpcIndex + 1 < segments.length) { + return segments[xrpcIndex + 1]; + } + return ''; + } + + static String? _headerValue(Map? headers, String key) { + if (headers == null) { + return null; + } + + for (final entry in headers.entries) { + if (entry.key.toLowerCase() == key.toLowerCase()) { + return entry.value; + } + } + return null; + } + + static String _requestLogLine({required String httpMethod, required XrpcRequestMetadata metadata}) { + return 'XRPC Request: method=$httpMethod, PDS=${metadata.pdsHost}, AppView=${metadata.appView}, ' + 'XRPC method=${metadata.xrpcMethod}'; + } + + static String _responseLogLine({ + required String httpMethod, + required XrpcRequestMetadata metadata, + required int statusCode, + required Duration elapsed, + }) { + return 'XRPC Response: method=$httpMethod, status=$statusCode, durationMs=${elapsed.inMilliseconds}, ' + 'PDS=${metadata.pdsHost}, AppView=${metadata.appView}, XRPC method=${metadata.xrpcMethod}'; + } + + static String _failureLogLine({ + required String httpMethod, + required XrpcRequestMetadata metadata, + required Duration elapsed, + }) { + return 'XRPC Failure: method=$httpMethod, durationMs=${elapsed.inMilliseconds}, PDS=${metadata.pdsHost}, ' + 'AppView=${metadata.appView}, XRPC method=${metadata.xrpcMethod}'; + } + + static void _logResponse({ + required String httpMethod, + required XrpcRequestMetadata metadata, + required int statusCode, + required Duration elapsed, + }) { + final message = _responseLogLine( + httpMethod: httpMethod, + metadata: metadata, + statusCode: statusCode, + elapsed: elapsed, + ); + if (statusCode >= 400) { + log.w(message); + return; + } + log.t(message); + } +} diff --git a/lib/core/router/app_router.dart b/lib/core/router/app_router.dart index b543ab0..ed12eed 100644 --- a/lib/core/router/app_router.dart +++ b/lib/core/router/app_router.dart @@ -9,6 +9,7 @@ import 'package:lazurite/core/database/app_database.dart'; import 'package:lazurite/core/logging/app_logger.dart'; import 'package:lazurite/core/network/constellation_client.dart'; import 'package:lazurite/core/network/app_view_provider.dart'; +import 'package:lazurite/core/network/xrpc_network_interceptor.dart'; import 'package:lazurite/core/router/app_shell.dart'; import 'package:lazurite/core/router/fade_through_page.dart'; import 'package:lazurite/features/alerts/presentation/alerts_screen.dart'; @@ -303,7 +304,11 @@ class AppRouter { final appViewProvider = AppViewProviders.descriptorForSetting(settingsState.appViewProvider); final repository = ProfileContextRepository( bluesky: context.read(), - publicBluesky: Bluesky.anonymous(service: appViewProvider.publicBaseUrl.host), + publicBluesky: Bluesky.anonymous( + service: appViewProvider.publicBaseUrl.host, + getClient: XrpcNetworkInterceptor.wrapGetClient(), + postClient: XrpcNetworkInterceptor.wrapPostClient(), + ), constellationClient: ConstellationClient(baseUrl: constellationUrl), ); return _page( diff --git a/lib/features/auth/data/auth_repository.dart b/lib/features/auth/data/auth_repository.dart index a574aa5..b3e8964 100644 --- a/lib/features/auth/data/auth_repository.dart +++ b/lib/features/auth/data/auth_repository.dart @@ -13,6 +13,7 @@ import 'package:lazurite/core/logging/app_logger.dart'; import 'package:lazurite/core/network/atproto_host_resolver.dart'; import 'package:lazurite/core/network/app_view_provider.dart'; import 'package:lazurite/core/network/xrpc_client_factory.dart'; +import 'package:lazurite/core/network/xrpc_network_interceptor.dart'; import 'package:lazurite/features/auth/data/models/auth_models.dart'; import 'package:url_launcher/url_launcher.dart'; @@ -152,25 +153,72 @@ class AuthRepository { try { _oauthCompleter = Completer(); _pendingHandle = handle.trim(); - _pendingService = normalizeAtprotoServiceHost(_oauthServiceResolver()) ?? _oauthService; + final preferredOauthService = normalizeAtprotoServiceHost(_oauthServiceResolver()) ?? _oauthService; + String? resolvedPdsHost; + String? resolvedAuthService; + try { + resolvedPdsHost = await _resolveServiceForIdentifier(_pendingHandle!); + resolvedAuthService = await _resolveAuthorizationServiceForPdsHost(resolvedPdsHost); + } catch (error, stackTrace) { + log.w( + 'AuthRepository: Failed to pre-resolve OAuth account authority for ${_pendingHandle!}; ' + 'continuing with fallback auth service chain.', + error: error, + stackTrace: stackTrace, + ); + } + final oauthServices = _oauthAuthorizeServiceCandidates( + preferredAuthService: preferredOauthService, + resolvedPdsHost: resolvedPdsHost, + resolvedAuthService: resolvedAuthService, + ); log.i('AuthRepository: Starting OAuth login for ${_pendingHandle!}'); + log.d('AuthRepository: OAuth auth service candidates: ${oauthServices.join(', ')}'); final metadata = await _loadClientMetadata(kClientId); log.d('AuthRepository: Loaded client metadata with redirect URIs: ${metadata.redirectUris.join(', ')}'); final redirectUriTemplate = Uri.parse(metadata.redirectUris.first); final redirectUri = await _startCallbackServer(redirectUriTemplate); - final oauthClient = OAuthClient( - metadata.copyWith(redirectUris: [redirectUri.toString()]), - service: _pendingService!, - ); - final (authorizationUrl, context) = await oauthClient.authorize(_pendingHandle); - _pendingOAuthClient = oauthClient; - _pendingOAuthContext = context; - log.i('AuthRepository: OAuth PAR completed, launching browser to ${_sanitizeUriForLog(authorizationUrl)}'); - await _launchUrl(authorizationUrl); + Object? lastAttemptError; + StackTrace? lastAttemptStackTrace; + final failedAttemptSummaries = []; + + for (final oauthService in oauthServices) { + try { + final oauthClient = OAuthClient( + metadata.copyWith(redirectUris: [redirectUri.toString()]), + service: oauthService, + ); + final (authorizationUrl, context) = await oauthClient.authorize(_pendingHandle); + + _pendingService = oauthService; + _pendingOAuthClient = oauthClient; + _pendingOAuthContext = context; + log.i('AuthRepository: OAuth PAR completed, launching browser to ${_sanitizeUriForLog(authorizationUrl)}'); + await _launchUrl(authorizationUrl); + + return await _oauthCompleter!.future; + } catch (error, stackTrace) { + lastAttemptError = error; + lastAttemptStackTrace = stackTrace; + final summary = _summarizeOAuthRefreshError(error); + failedAttemptSummaries.add('$oauthService=$summary'); + log.w( + 'AuthRepository: OAuth authorize attempt failed using auth service $oauthService ($summary)', + error: error, + stackTrace: stackTrace, + ); + } + } - return await _oauthCompleter!.future; + Error.throwWithStackTrace( + Exception( + 'OAuth authorize failed across ${oauthServices.length} auth service candidate(s). ' + 'Attempts: ${failedAttemptSummaries.join(' | ')}. Last error: $lastAttemptError', + ), + lastAttemptStackTrace ?? StackTrace.current, + ); } catch (error, stackTrace) { log.e('AuthRepository: OAuth login failed', error: error, stackTrace: stackTrace); await _stopCallbackServer(); @@ -533,7 +581,11 @@ class AuthRepository { Future _resolveServiceForIdentifier(String identifier) async { log.d('AuthRepository: Resolving AT Protocol service for $identifier'); - final client = atp.ATProto.anonymous(service: _fallbackService); + final client = atp.ATProto.anonymous( + service: _fallbackService, + getClient: XrpcNetworkInterceptor.wrapGetClient(), + postClient: XrpcNetworkInterceptor.wrapPostClient(), + ); final did = identifier.startsWith('did:') ? identifier @@ -546,6 +598,45 @@ class AuthRepository { return serviceEndpoint; } + Future _resolveAuthorizationServiceForPdsHost(String pdsHost) async { + final normalizedPdsHost = normalizeAtprotoServiceHost(pdsHost); + if (normalizedPdsHost == null) { + return null; + } + + final uri = Uri.https(normalizedPdsHost, '/.well-known/oauth-protected-resource'); + log.d('AuthRepository: Fetching protected resource metadata from ${_sanitizeUriForLog(uri)}'); + final response = await http.get(uri); + if (response.statusCode != HttpStatus.ok) { + throw Exception( + 'Failed to resolve authorization server for $normalizedPdsHost: ' + 'HTTP ${response.statusCode}', + ); + } + + final decoded = jsonDecode(response.body); + if (decoded is! Map) { + throw Exception('Invalid protected resource metadata for $normalizedPdsHost'); + } + + final rawAuthorizationServers = decoded['authorization_servers']; + if (rawAuthorizationServers is! List) { + return null; + } + + for (final candidate in rawAuthorizationServers) { + if (candidate is! String) { + continue; + } + final host = normalizeAtprotoServiceHost(candidate); + if (host != null && host.isNotEmpty) { + return host; + } + } + + return null; + } + Future> _resolveDidDocument(String did) async { final uri = _didDocumentUri(did); log.d('AuthRepository: Fetching DID document from ${_sanitizeUriForLog(uri)}'); @@ -764,6 +855,34 @@ class AuthRepository { return candidates.toList(growable: false); } + static List _oauthAuthorizeServiceCandidates({ + required String? preferredAuthService, + required String? resolvedPdsHost, + required String? resolvedAuthService, + }) { + final candidates = {}; + + final resolvedAuthHost = normalizeAtprotoServiceHost(resolvedAuthService); + if (resolvedAuthHost != null) { + candidates.add(resolvedAuthHost); + } + + final resolvedHost = normalizeAtprotoServiceHost(resolvedPdsHost); + if (resolvedHost != null) { + candidates.add(resolvedHost); + } + + candidates.add(_oauthService); + + final preferredHost = normalizeAtprotoServiceHost(preferredAuthService); + if (preferredHost != null) { + candidates.add(preferredHost); + } + + candidates.add(_fallbackService); + return candidates.toList(growable: false); + } + @visibleForTesting static List oauthRefreshServiceCandidatesForTest({ required String? storedAuthService, @@ -772,6 +891,19 @@ class AuthRepository { return _oauthRefreshServiceCandidates(storedAuthService: storedAuthService, issuer: issuer); } + @visibleForTesting + static List oauthAuthorizeServiceCandidatesForTest({ + required String? preferredAuthService, + required String? resolvedPdsHost, + required String? resolvedAuthService, + }) { + return _oauthAuthorizeServiceCandidates( + preferredAuthService: preferredAuthService, + resolvedPdsHost: resolvedPdsHost, + resolvedAuthService: resolvedAuthService, + ); + } + @visibleForTesting Future startCallbackServerForTest(Uri redirectUriTemplate) => _startCallbackServer(redirectUriTemplate); diff --git a/lib/features/auth/presentation/login_screen.dart b/lib/features/auth/presentation/login_screen.dart index fc29f35..892b830 100644 --- a/lib/features/auth/presentation/login_screen.dart +++ b/lib/features/auth/presentation/login_screen.dart @@ -76,9 +76,7 @@ class _LoginScreenState extends State { ); } - bool _isHandleValid() { - return _formKey.currentState?.validate() ?? false; - } + bool _isHandleValid() => _formKey.currentState?.validate() ?? false; void _onTypeaheadSelected(TypeaheadResult result) { _handleController.text = result.handle; @@ -163,31 +161,33 @@ class _LoginScreenState extends State { builder: (context, settingsState) { final selectedProvider = settingsState.appViewProvider; return Column( - crossAxisAlignment: CrossAxisAlignment.start, + crossAxisAlignment: CrossAxisAlignment.center, children: [ Text( - 'AppView Provider', + 'Choose your portal', + textAlign: TextAlign.center, style: theme.textTheme.labelLarge?.copyWith(fontWeight: FontWeight.w600), ), const SizedBox(height: 8), - SegmentedButton( - segments: const [ - ButtonSegment(value: AppViewProviders.blueskyKey, label: Text('Bluesky')), - ButtonSegment(value: AppViewProviders.blackskyKey, label: Text('Blacksky')), - ], - selected: {selectedProvider}, - onSelectionChanged: (selection) { - unawaited(context.read().setAppViewProvider(selection.first)); - }, + Center( + child: SegmentedButton( + segments: const [ + ButtonSegment( + value: AppViewProviders.blueskyKey, + label: _ProviderTabLabel(assetPath: 'assets/bluesky.svg', name: 'BlueSky'), + ), + ButtonSegment( + value: AppViewProviders.blackskyKey, + label: _ProviderTabLabel(assetPath: 'assets/blacksky.svg', name: 'BlackSky'), + ), + ], + selected: {selectedProvider}, + onSelectionChanged: (selection) { + unawaited(context.read().setAppViewProvider(selection.first)); + }, + ), ), const SizedBox(height: 8), - Text( - selectedProvider == AppViewProviders.blackskyKey - ? 'Sign-in will use Blacksky entryway defaults.' - : 'Sign-in will use Bluesky entryway defaults.', - style: theme.textTheme.bodySmall?.copyWith(color: colorScheme.onSurfaceVariant), - ), - const SizedBox(height: 16), ], ); }, @@ -340,32 +340,40 @@ class _LoginScreenState extends State { } } +class _ProviderTabLabel extends StatelessWidget { + const _ProviderTabLabel({required this.assetPath, required this.name}); + + final String assetPath; + final String name; + + @override + Widget build(BuildContext context) => Row( + mainAxisSize: MainAxisSize.min, + children: [SvgPicture.asset(assetPath, height: 16), const SizedBox(width: 8), Text(name)], + ); +} + class _LogoCard extends StatelessWidget { const _LogoCard({required this.colorScheme}); final ColorScheme colorScheme; @override - Widget build(BuildContext context) { - return Center( - child: Container( - width: 88, - height: 88, - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(24), - gradient: LinearGradient(colors: [colorScheme.primary, colorScheme.secondary]), - boxShadow: [ - BoxShadow(color: colorScheme.primary.withValues(alpha: 0.24), blurRadius: 28, offset: const Offset(0, 12)), - ], - ), - child: Padding( - padding: const EdgeInsets.all(18), - child: SvgPicture.asset( - 'assets/logo.svg', - colorFilter: const ColorFilter.mode(Colors.white, BlendMode.srcIn), - ), - ), + Widget build(BuildContext context) => Center( + child: Container( + width: 88, + height: 88, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(24), + gradient: LinearGradient(colors: [colorScheme.primary, colorScheme.secondary]), + boxShadow: [ + BoxShadow(color: colorScheme.primary.withValues(alpha: 0.24), blurRadius: 28, offset: const Offset(0, 12)), + ], ), - ); - } + child: Padding( + padding: const EdgeInsets.all(18), + child: SvgPicture.asset('assets/logo.svg', colorFilter: const ColorFilter.mode(Colors.white, BlendMode.srcIn)), + ), + ), + ); } diff --git a/lib/features/feed/data/feed_repository.dart b/lib/features/feed/data/feed_repository.dart index 334c453..0f44b44 100644 --- a/lib/features/feed/data/feed_repository.dart +++ b/lib/features/feed/data/feed_repository.dart @@ -104,17 +104,44 @@ class FeedRepository { } Future getPreferences() async { - final response = await _bluesky.actor.getPreferences( - $headers: _appViewContext.appBskyHeaders(await _moderationService?.headersForRequest()), - ); - return PreferencesResult(preferences: response.data.preferences); + final headers = _appViewContext.appBskyHeaders(await _moderationService?.headersForRequest()); + try { + final response = await _bluesky.actor.getPreferences($headers: headers); + return PreferencesResult(preferences: response.data.preferences); + } on XRPCException catch (error) { + if (_shouldRetryPreferencesWithoutProxy(error: error, headers: headers)) { + final response = await _bluesky.actor.getPreferences($headers: _withoutAppViewProxyHeader(headers)); + return PreferencesResult(preferences: response.data.preferences); + } + rethrow; + } } Future putPreferences({required List preferences}) async { - await _bluesky.actor.putPreferences( - preferences: preferences, - $headers: _appViewContext.appBskyHeaders(await _moderationService?.headersForRequest()), - ); + final headers = _appViewContext.appBskyHeaders(await _moderationService?.headersForRequest()); + try { + await _bluesky.actor.putPreferences(preferences: preferences, $headers: headers); + } on XRPCException catch (error) { + if (_shouldRetryPreferencesWithoutProxy(error: error, headers: headers)) { + await _bluesky.actor.putPreferences(preferences: preferences, $headers: _withoutAppViewProxyHeader(headers)); + return; + } + rethrow; + } + } + + bool _shouldRetryPreferencesWithoutProxy({required XRPCException error, required Map headers}) { + return _hasAppViewProxyHeader(headers) && error.response.status.code == 404; + } + + bool _hasAppViewProxyHeader(Map headers) { + return headers.keys.any((key) => key.toLowerCase() == 'atproto-proxy'); + } + + Map _withoutAppViewProxyHeader(Map headers) { + final copy = Map.from(headers); + copy.removeWhere((key, _) => key.toLowerCase() == 'atproto-proxy'); + return copy; } Future> getSuggestedFeeds({String? cursor, int limit = 50}) async { diff --git a/lib/features/feed/presentation/media/media_actions.dart b/lib/features/feed/presentation/media/media_actions.dart index 8d8acc0..ace491e 100644 --- a/lib/features/feed/presentation/media/media_actions.dart +++ b/lib/features/feed/presentation/media/media_actions.dart @@ -4,10 +4,10 @@ import 'package:dio/dio.dart'; import 'package:flutter/material.dart'; import 'package:gal/gal.dart'; import 'package:lazurite/core/logging/app_logger.dart'; +import 'package:lazurite/shared/presentation/helpers/share_helper.dart'; import 'package:path/path.dart' as p; import 'package:path_provider/path_provider.dart'; import 'package:permission_handler/permission_handler.dart'; -import 'package:share_plus/share_plus.dart'; enum MediaAssetType { image, video } @@ -15,7 +15,7 @@ class MediaActions { MediaActions._(); static Future shareImage(BuildContext context, String imageUrl) async { - await Share.share(imageUrl); + await ShareHelper.shareText(context, imageUrl); } static Future downloadImage( diff --git a/lib/features/feed/presentation/post_thread_screen.dart b/lib/features/feed/presentation/post_thread_screen.dart index 6c0cd9e..175c670 100644 --- a/lib/features/feed/presentation/post_thread_screen.dart +++ b/lib/features/feed/presentation/post_thread_screen.dart @@ -10,7 +10,8 @@ import 'package:flutter/services.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:go_router/go_router.dart'; import 'package:intl/intl.dart'; -import 'package:lazurite/core/logging/app_logger.dart'; +import 'package:lazurite/core/network/app_view_provider.dart'; +import 'package:lazurite/core/network/app_view_web_links.dart'; import 'package:lazurite/features/compose/presentation/compose_route_args.dart'; import 'package:lazurite/features/connectivity/cubit/connectivity_cubit.dart'; import 'package:lazurite/features/feed/cubit/post_action_cache.dart'; @@ -926,7 +927,7 @@ class _FocusedPostContent extends StatelessWidget { HapticHelper.mediumImpact(); final post = thread.post; final postUri = post.uri.toString(); - final bskyUrl = _convertAtUriToBskyUrl(postUri); + final bskyUrl = AppViewWebLinks.postFromAtUri(postUri, appViewProvider: _resolveAppViewProvider(context)); showOptionsSheet( context: context, @@ -1089,18 +1090,11 @@ class _FocusedPostContent extends StatelessWidget { return text is String ? text : ''; } - String _convertAtUriToBskyUrl(String atUri) { + String _resolveAppViewProvider(BuildContext context) { try { - final uri = Uri.parse(atUri); - final parts = uri.pathSegments; - if (parts.length >= 2) { - final did = uri.host; - final rkey = parts.last; - return 'https://bsky.app/profile/$did/post/$rkey'; - } + return context.read().state.appViewProvider; } catch (_) { - log.d('failed to convert atUri to bskyUrl'); + return AppViewProviders.defaultKey; } - return atUri; } } diff --git a/lib/features/feed/presentation/saved_posts_screen.dart b/lib/features/feed/presentation/saved_posts_screen.dart index a35ddea..90dfb48 100644 --- a/lib/features/feed/presentation/saved_posts_screen.dart +++ b/lib/features/feed/presentation/saved_posts_screen.dart @@ -7,16 +7,19 @@ import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:go_router/go_router.dart'; import 'package:lazurite/core/database/app_database.dart'; import 'package:lazurite/core/logging/app_logger.dart'; +import 'package:lazurite/core/network/app_view_provider.dart'; +import 'package:lazurite/core/network/app_view_web_links.dart'; import 'package:lazurite/features/feed/cubit/saved_posts_cubit.dart'; import 'package:lazurite/features/feed/data/post_action_repository.dart'; import 'package:lazurite/features/feed/presentation/widgets/post_card_with_actions.dart'; import 'package:lazurite/features/search/presentation/semantic_search_tab.dart'; +import 'package:lazurite/features/settings/bloc/settings_cubit.dart'; import 'package:lazurite/shared/presentation/widgets/animated_refresh_indicator.dart'; import 'package:lazurite/shared/presentation/widgets/empty_state.dart'; import 'package:lazurite/shared/presentation/widgets/error_state.dart'; import 'package:lazurite/shared/presentation/widgets/loading_state.dart'; +import 'package:lazurite/shared/presentation/helpers/share_helper.dart'; import 'package:lazurite/shared/presentation/widgets/staggered_entrance.dart'; -import 'package:share_plus/share_plus.dart'; class SavedPostsScreen extends StatelessWidget { const SavedPostsScreen({super.key, required this.accountDid}); @@ -223,7 +226,10 @@ class _SavedPostCard extends StatelessWidget { ), IconButton( icon: const Icon(Icons.share_outlined), - onPressed: () => Share.share(_convertAtUriToBskyUrl(savedPost.postUri)), + onPressed: () => ShareHelper.shareText( + context, + AppViewWebLinks.postFromAtUri(savedPost.postUri, appViewProvider: _resolveAppViewProvider(context)), + ), tooltip: 'Share', ), IconButton(icon: const Icon(Icons.delete_outline), onPressed: onUnsave, tooltip: 'Remove'), @@ -244,18 +250,11 @@ class _SavedPostCard extends StatelessWidget { return '${date.month}/${date.day}/${date.year}'; } - String _convertAtUriToBskyUrl(String atUri) { + String _resolveAppViewProvider(BuildContext context) { try { - final uri = Uri.parse(atUri); - final parts = uri.pathSegments; - if (parts.length >= 2) { - final did = uri.host; - final rkey = parts.last; - return 'https://bsky.app/profile/$did/post/$rkey'; - } + return context.read().state.appViewProvider; } catch (_) { - log.d('failed to convert atUri to bskyUrl'); + return AppViewProviders.defaultKey; } - return atUri; } } diff --git a/lib/features/feed/presentation/widgets/post_action_bar.dart b/lib/features/feed/presentation/widgets/post_action_bar.dart index 293821f..074f6ea 100644 --- a/lib/features/feed/presentation/widgets/post_action_bar.dart +++ b/lib/features/feed/presentation/widgets/post_action_bar.dart @@ -1,12 +1,15 @@ import 'package:flutter/material.dart'; import 'package:flutter_animate/flutter_animate.dart'; -import 'package:lazurite/core/logging/app_logger.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:lazurite/core/network/app_view_provider.dart'; +import 'package:lazurite/core/network/app_view_web_links.dart'; import 'package:lazurite/core/theme/animation_tokens.dart'; import 'package:lazurite/features/connectivity/connectivity_helpers.dart'; +import 'package:lazurite/features/settings/bloc/settings_cubit.dart'; import 'package:lazurite/shared/presentation/helpers/haptic_helper.dart'; +import 'package:lazurite/shared/presentation/helpers/share_helper.dart'; import 'package:lazurite/shared/presentation/widgets/options_sheet.dart'; import 'package:lazurite/shared/utils/format_utils.dart'; -import 'package:share_plus/share_plus.dart'; import 'package:lazurite/core/theme/theme_extensions.dart'; class PostActionBar extends StatelessWidget { @@ -177,23 +180,16 @@ class PostActionBar extends StatelessWidget { } Future _defaultShare(BuildContext context) async { - final url = _convertAtUriToBskyUrl(postUri); - await Share.share(url); + final url = AppViewWebLinks.postFromAtUri(postUri, appViewProvider: _resolveAppViewProvider(context)); + await ShareHelper.shareText(context, url); } - String _convertAtUriToBskyUrl(String atUri) { + String _resolveAppViewProvider(BuildContext context) { try { - final uri = Uri.parse(atUri); - final parts = uri.pathSegments; - if (parts.length >= 2) { - final did = uri.host; - final rkey = parts.last; - return 'https://bsky.app/profile/$did/post/$rkey'; - } + return context.read().state.appViewProvider; } catch (_) { - log.d('failed to convert atUri to bskyUrl'); + return AppViewProviders.defaultKey; } - return atUri; } } diff --git a/lib/features/moderation/data/moderation_service.dart b/lib/features/moderation/data/moderation_service.dart index ff5e98a..07e07dc 100644 --- a/lib/features/moderation/data/moderation_service.dart +++ b/lib/features/moderation/data/moderation_service.dart @@ -1,6 +1,7 @@ import 'dart:async'; import 'dart:convert'; +import 'package:atproto_core/atproto_core.dart' as atp_core; import 'package:bluesky/app_bsky_actor_defs.dart'; import 'package:bluesky/app_bsky_actor_getpreferences.dart'; import 'package:bluesky/app_bsky_feed_defs.dart'; @@ -328,11 +329,25 @@ class ModerationService { return _preferences; } + final headers = _appViewContext.appBskyHeaders(); try { - final prefsResponse = await _bluesky.actor.getPreferences(); + final prefsResponse = await _bluesky.actor.getPreferences($headers: headers); final preferences = prefsResponse.data.preferences; await _cachePreferences(preferences); return preferences; + } on atp_core.XRPCException catch (error) { + if (_shouldRetryPreferencesWithoutProxy(error: error, headers: headers)) { + final fallbackHeaders = _withoutAppViewProxyHeader(headers); + log.w( + 'ModerationService: getPreferences returned ${error.response.status.code} with AppView proxy; ' + 'retrying without atproto-proxy.', + ); + final prefsResponse = await _bluesky.actor.getPreferences($headers: fallbackHeaders); + final preferences = prefsResponse.data.preferences; + await _cachePreferences(preferences); + return preferences; + } + rethrow; } catch (error) { final cached = await _loadCachedPreferences(); if (cached != null) { @@ -345,7 +360,22 @@ class ModerationService { } Future _putAndRefresh(List preferences) async { - await _bluesky.actor.putPreferences(preferences: preferences); + final moderationPrefs = _toModerationPrefs(preferences); + final headers = _buildHeadersForPrefs(moderationPrefs); + try { + await _bluesky.actor.putPreferences(preferences: preferences, $headers: headers); + } on atp_core.XRPCException catch (error) { + if (_shouldRetryPreferencesWithoutProxy(error: error, headers: headers)) { + final fallbackHeaders = _withoutAppViewProxyHeader(headers); + log.w( + 'ModerationService: putPreferences returned ${error.response.status.code} with AppView proxy; ' + 'retrying without atproto-proxy.', + ); + await _bluesky.actor.putPreferences(preferences: preferences, $headers: fallbackHeaders); + } else { + rethrow; + } + } await updatePreferences(preferences: preferences); } @@ -547,6 +577,23 @@ class ModerationService { return _appViewContext.appBskyHeaders(_buildLabelerHeaders(prefs.labelers.map((labeler) => labeler.did))); } + bool _shouldRetryPreferencesWithoutProxy({ + required atp_core.XRPCException error, + required Map headers, + }) { + return _hasAppViewProxyHeader(headers) && error.response.status.code == 404; + } + + bool _hasAppViewProxyHeader(Map headers) { + return headers.keys.any((key) => key.toLowerCase() == 'atproto-proxy'); + } + + Map _withoutAppViewProxyHeader(Map headers) { + final copy = Map.from(headers); + copy.removeWhere((key, _) => key.toLowerCase() == 'atproto-proxy'); + return copy; + } + String? get _preferencesCacheKey { final accountDid = _accountDid; if (accountDid == null || accountDid.isEmpty) { diff --git a/lib/features/profile/presentation/profile_screen.dart b/lib/features/profile/presentation/profile_screen.dart index bb9bbd0..0d37883 100644 --- a/lib/features/profile/presentation/profile_screen.dart +++ b/lib/features/profile/presentation/profile_screen.dart @@ -7,6 +7,8 @@ import 'package:flutter_animate/flutter_animate.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:go_router/go_router.dart'; import 'package:intl/intl.dart'; +import 'package:lazurite/core/network/app_view_provider.dart'; +import 'package:lazurite/core/network/app_view_web_links.dart'; import 'package:lazurite/core/router/app_shell.dart'; import 'package:lazurite/core/theme/animation_tokens.dart'; import 'package:lazurite/core/theme/animation_utils.dart'; @@ -42,10 +44,10 @@ import 'package:lazurite/features/starter_packs/cubit/actor_starter_packs_cubit. import 'package:lazurite/features/starter_packs/data/starter_pack_repository.dart'; import 'package:lazurite/features/starter_packs/presentation/widgets/starter_pack_card.dart'; import 'package:lazurite/shared/presentation/helpers/navigation_helpers.dart'; +import 'package:lazurite/shared/presentation/helpers/share_helper.dart'; import 'package:lazurite/shared/presentation/helpers/snackbar_helper.dart'; import 'package:lazurite/shared/presentation/widgets/options_sheet.dart'; import 'package:lazurite/shared/utils/format_utils.dart'; -import 'package:share_plus/share_plus.dart'; import 'package:url_launcher/url_launcher.dart'; class ProfileScreen extends StatefulWidget { @@ -561,7 +563,10 @@ class _ProfileScreenState extends State with TickerProviderStateM OptionsSheetItem( leading: const Icon(Icons.share_outlined), title: 'Share Profile', - onTap: () => Share.share('https://bsky.app/profile/${profile.handle}'), + onTap: () => ShareHelper.shareText( + context, + AppViewWebLinks.profile(profile.handle, appViewProvider: _resolveAppViewProvider(context)), + ), ), OptionsSheetItem( leading: const Icon(Icons.playlist_add_outlined), @@ -658,6 +663,14 @@ class _ProfileScreenState extends State with TickerProviderStateM ).whenComplete(cubit.close); } + String _resolveAppViewProvider(BuildContext context) { + try { + return context.read().state.appViewProvider; + } catch (_) { + return AppViewProviders.defaultKey; + } + } + void _showSuggestedFollows(BuildContext context, ProfileViewDetailed profile) { ProfileRepository? profileRepository; try { diff --git a/lib/shared/presentation/helpers/share_helper.dart b/lib/shared/presentation/helpers/share_helper.dart new file mode 100644 index 0000000..dd83ac5 --- /dev/null +++ b/lib/shared/presentation/helpers/share_helper.dart @@ -0,0 +1,27 @@ +import 'package:flutter/material.dart'; +import 'package:share_plus/share_plus.dart'; + +class ShareHelper { + const ShareHelper._(); + + static Future shareText(BuildContext context, String text) { + return Share.share(text, sharePositionOrigin: _sharePositionOrigin(context)); + } + + static Rect _sharePositionOrigin(BuildContext context) { + final renderObject = context.findRenderObject(); + if (renderObject is RenderBox && renderObject.hasSize && !renderObject.size.isEmpty) { + final rect = renderObject.localToGlobal(Offset.zero) & renderObject.size; + if (!rect.isEmpty) { + return rect; + } + } + + final mediaQuerySize = MediaQuery.maybeSizeOf(context); + if (mediaQuerySize != null && !mediaQuerySize.isEmpty) { + return Rect.fromLTWH(0, 0, mediaQuerySize.width, mediaQuerySize.height); + } + + return const Rect.fromLTWH(0, 0, 1, 1); + } +} diff --git a/test/core/network/app_view_provider_test.dart b/test/core/network/app_view_provider_test.dart index dccab04..8aa2fcf 100644 --- a/test/core/network/app_view_provider_test.dart +++ b/test/core/network/app_view_provider_test.dart @@ -24,8 +24,8 @@ void main() { expect(blacksky.serviceDid, equals('did:web:api.blacksky.community#bsky_appview')); expect(blacksky.publicBaseUrl.host, equals('api.blacksky.community')); - expect(blacksky.entrywayUrl.host, equals('blacksky.app')); - expect(blacksky.webBaseUrl.host, equals('blacksky.app')); + expect(blacksky.entrywayUrl.host, equals('blacksky.community')); + expect(blacksky.webBaseUrl.host, equals('blacksky.community')); }); }); } diff --git a/test/core/network/app_view_web_links_test.dart b/test/core/network/app_view_web_links_test.dart new file mode 100644 index 0000000..d65c100 --- /dev/null +++ b/test/core/network/app_view_web_links_test.dart @@ -0,0 +1,39 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:lazurite/core/network/app_view_provider.dart'; +import 'package:lazurite/core/network/app_view_web_links.dart'; + +void main() { + group('AppViewWebLinks.postFromAtUri', () { + test('uses Bluesky web base by default', () { + final url = AppViewWebLinks.postFromAtUri('at://did:plc:alice/app.bsky.feed.post/abc123'); + expect(url, equals('https://bsky.app/profile/did:plc:alice/post/abc123')); + }); + + test('uses selected BlackSky web base', () { + final url = AppViewWebLinks.postFromAtUri( + 'at://did:plc:alice/app.bsky.feed.post/abc123', + appViewProvider: AppViewProviders.blackskyKey, + ); + expect(url, equals('https://blacksky.community/profile/did:plc:alice/post/abc123')); + }); + + test('returns original input for non-post at URI', () { + const input = 'at://did:plc:alice/app.bsky.actor.profile/self'; + final url = AppViewWebLinks.postFromAtUri(input); + expect(url, equals(input)); + }); + + test('returns original input for malformed URI', () { + const input = ':::not-a-uri:::'; + final url = AppViewWebLinks.postFromAtUri(input); + expect(url, equals(input)); + }); + }); + + group('AppViewWebLinks.profile', () { + test('builds profile link against selected provider', () { + final url = AppViewWebLinks.profile('alice.bsky.social', appViewProvider: AppViewProviders.blackskyKey); + expect(url, equals('https://blacksky.community/profile/alice.bsky.social')); + }); + }); +} diff --git a/test/core/network/xrpc_network_interceptor_test.dart b/test/core/network/xrpc_network_interceptor_test.dart new file mode 100644 index 0000000..6b329af --- /dev/null +++ b/test/core/network/xrpc_network_interceptor_test.dart @@ -0,0 +1,68 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:http/http.dart' as http; +import 'package:lazurite/core/network/xrpc_network_interceptor.dart'; + +void main() { + group('XrpcNetworkInterceptor', () { + group('metadataFor', () { + test('extracts pds, appview, and xrpc method', () { + final metadata = XrpcNetworkInterceptor.metadataFor( + Uri.parse('https://shaggymane.us-west.host.bsky.network/xrpc/app.bsky.actor.getPreferences?limit=100'), + headers: {'atproto-proxy': 'did:web:api.blacksky.community#bsky_appview'}, + ); + + expect(metadata.pdsHost, 'shaggymane.us-west.host.bsky.network'); + expect(metadata.appView, 'did:web:api.blacksky.community#bsky_appview'); + expect(metadata.xrpcMethod, 'app.bsky.actor.getPreferences'); + }); + + test('handles case-insensitive appview header keys', () { + final metadata = XrpcNetworkInterceptor.metadataFor( + Uri.parse('https://example.com/xrpc/app.bsky.feed.getFeed'), + headers: {'AtProto-Proxy': 'did:web:api.bsky.app#bsky_appview'}, + ); + + expect(metadata.appView, 'did:web:api.bsky.app#bsky_appview'); + }); + + test('returns defaults for non-xrpc paths and missing appview header', () { + final metadata = XrpcNetworkInterceptor.metadataFor(Uri.parse('https://example.com/.well-known/did.json')); + + expect(metadata.pdsHost, 'example.com'); + expect(metadata.appView, 'none'); + expect(metadata.xrpcMethod, ''); + }); + }); + + group('wrap clients', () { + test('wrapGetClient delegates request and returns response', () async { + final wrapped = XrpcNetworkInterceptor.wrapGetClient((url, {headers}) async { + return http.Response('ok', 200, request: http.Request('GET', url)); + }); + + final response = await wrapped( + Uri.parse('https://example.com/xrpc/app.bsky.actor.getProfile'), + headers: const {'atproto-proxy': 'did:web:api.bsky.app#bsky_appview'}, + ); + + expect(response.statusCode, 200); + expect(response.body, 'ok'); + }); + + test('wrapPostClient delegates request and returns response', () async { + final wrapped = XrpcNetworkInterceptor.wrapPostClient((url, {headers, body, encoding}) async { + return http.Response('created', 201, request: http.Request('POST', url)); + }); + + final response = await wrapped( + Uri.parse('https://example.com/xrpc/app.bsky.actor.putPreferences'), + headers: const {'atproto-proxy': 'did:web:api.blacksky.community#bsky_appview'}, + body: const {'k': 'v'}, + ); + + expect(response.statusCode, 201); + expect(response.body, 'created'); + }); + }); + }); +} diff --git a/test/features/auth/data/auth_repository_test.dart b/test/features/auth/data/auth_repository_test.dart index dbd6197..3ad6516 100644 --- a/test/features/auth/data/auth_repository_test.dart +++ b/test/features/auth/data/auth_repository_test.dart @@ -249,6 +249,28 @@ void main() { }); }); + group('oauth authorize candidates', () { + test('prioritizes resolved auth service before provider preference', () { + final candidates = AuthRepository.oauthAuthorizeServiceCandidatesForTest( + preferredAuthService: 'blacksky.community', + resolvedPdsHost: 'https://porcini.us-east.host.bsky.network', + resolvedAuthService: 'https://bsky.social', + ); + + expect(candidates, equals(['bsky.social', 'porcini.us-east.host.bsky.network', 'blacksky.community'])); + }); + + test('deduplicates when preferred and resolved hosts match defaults', () { + final candidates = AuthRepository.oauthAuthorizeServiceCandidatesForTest( + preferredAuthService: 'https://bsky.social', + resolvedPdsHost: 'bsky.social', + resolvedAuthService: 'bsky.social', + ); + + expect(candidates, equals(['bsky.social'])); + }); + }); + group('clearSession', () { test('should delete all accounts', () async { when(() => mockDatabase.deleteAllAccounts()).thenAnswer((_) async => 1); diff --git a/test/features/moderation/data/moderation_service_test.dart b/test/features/moderation/data/moderation_service_test.dart index 597ece9..c9ca3e9 100644 --- a/test/features/moderation/data/moderation_service_test.dart +++ b/test/features/moderation/data/moderation_service_test.dart @@ -157,6 +157,57 @@ void main() { service.dispose(); }); + test('uses selected AppView proxy headers for preference reads and writes', () async { + final actor = _FakeActorService(preferences: const []); + final service = ModerationService( + bluesky: _FakeBlueskyClient(actor: actor, labeler: const _FakeLabelerService()), + database: database, + accountDid: _accountDid, + userDid: _accountDid, + appViewProvider: 'blacksky', + ); + + await service.ensureInitialized(); + expect(actor.lastGetPreferencesHeaders?['atproto-proxy'], 'did:web:api.blacksky.community#bsky_appview'); + + await service.subscribeToLabeler(_customLabelerDid); + expect(actor.lastPutPreferencesHeaders?['atproto-proxy'], 'did:web:api.blacksky.community#bsky_appview'); + expect(actor.lastPutPreferencesHeaders?['atproto-accept-labelers'], contains(_customLabelerDid)); + + service.dispose(); + }); + + test('retries preferences without AppView proxy when proxied request returns 404', () async { + final actor = _FakeActorService( + preferences: const [], + errorOnProxyGetPreferences: _proxyNotSupported( + method: HttpMethod.get, + xrpcMethod: 'app.bsky.actor.getPreferences', + ), + errorOnProxyPutPreferences: _proxyNotSupported( + method: HttpMethod.post, + xrpcMethod: 'app.bsky.actor.putPreferences', + ), + ); + final service = ModerationService( + bluesky: _FakeBlueskyClient(actor: actor, labeler: const _FakeLabelerService()), + database: database, + accountDid: _accountDid, + userDid: _accountDid, + appViewProvider: 'blacksky', + ); + + await service.ensureInitialized(); + expect(actor.getPreferencesCallCount, 2); + expect(actor.lastGetPreferencesHeaders?['atproto-proxy'], isNull); + + await service.subscribeToLabeler(_customLabelerDid); + expect(actor.putPreferencesCallCount, 2); + expect(actor.lastPutPreferencesHeaders?['atproto-proxy'], isNull); + + service.dispose(); + }); + test('setLabelPreference stores contentLabelPref entries', () async { final actor = _FakeActorService(preferences: const []); final service = ModerationService( @@ -231,21 +282,49 @@ class _FakeBlueskyClient { } class _FakeActorService { - _FakeActorService({this.preferences = const [], this.error}); + _FakeActorService({ + this.preferences = const [], + this.error, + this.errorOnProxyGetPreferences, + this.errorOnProxyPutPreferences, + }); final List preferences; final Object? error; + final Object? errorOnProxyGetPreferences; + final Object? errorOnProxyPutPreferences; List? lastPutPreferences; + Map? lastGetPreferencesHeaders; + Map? lastPutPreferencesHeaders; + int getPreferencesCallCount = 0; + int putPreferencesCallCount = 0; - Future<_FakePreferencesResponse> getPreferences() async { + Future<_FakePreferencesResponse> getPreferences({Map? $headers}) async { + getPreferencesCallCount++; if (error != null) { throw error!; } + if (_hasAppViewProxyHeader($headers) && errorOnProxyGetPreferences != null) { + throw errorOnProxyGetPreferences!; + } + lastGetPreferencesHeaders = $headers; return _FakePreferencesResponse(_FakePreferencesData(preferences)); } - Future putPreferences({required List preferences}) async { + Future putPreferences({required List preferences, Map? $headers}) async { + putPreferencesCallCount++; + if (_hasAppViewProxyHeader($headers) && errorOnProxyPutPreferences != null) { + throw errorOnProxyPutPreferences!; + } lastPutPreferences = preferences; + lastPutPreferencesHeaders = $headers; + } + + bool _hasAppViewProxyHeader(Map? headers) { + if (headers == null) { + return false; + } + return headers.keys.any((key) => key.toLowerCase() == 'atproto-proxy'); } } @@ -289,3 +368,15 @@ class _FakeGetServicesData { final List views; } + +InvalidRequestException _proxyNotSupported({required HttpMethod method, required String xrpcMethod}) { + return InvalidRequestException( + XRPCResponse( + headers: const {}, + status: HttpStatus.notFound, + request: XRPCRequest(method: method, url: Uri.parse('https://example.test/xrpc/$xrpcMethod')), + rateLimit: RateLimit.unlimited(), + data: const XRPCError(error: 'XRPCNotSupported', message: 'XRPC Not Supported'), + ), + ); +}