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: cross-provider fallback and slingshot identity fallback settings

* cross-provider fallback feature to retry public reads on an alternate AppView when transient errors occur.

* slingshot identity fallback for degraded handle resolution using Slingshot's resolveMiniDoc

+1067 -59
+6
docs/specs/routing.md
··· 189 189 190 190 Do not fallback across write operations. 191 191 192 + Opt-in/trust boundary notes: 193 + 194 + - Cross-provider AppView fallback is OFF by default and only applies to public read endpoints. 195 + - Slingshot identity fallback is OFF by default and only used when primary handle resolution is degraded. 196 + - Slingshot fallback data is treated as a recovery hint (DID/PDS seed), not as authority for write routing. 197 + 192 198 ### Capability gating 193 199 194 200 Track endpoint support per provider to avoid blind retries:
+9 -9
docs/tasks/routing.md
··· 38 38 39 39 ## M5 - Fallback Engine 40 40 41 - - [ ] Add user setting for cross-provider fallback (default off) 42 - - [ ] Implement bounded fallback chain for read-only public endpoints 43 - - [ ] Add circuit-breaker window per provider/endpoint 44 - - [ ] Add structured logs for provider/fallback decisions 45 - - [ ] Add tests for timeout/429/5xx transitions with fallback enabled/disabled 41 + - [x] Add user setting for cross-provider fallback (default off) 42 + - [x] Implement bounded fallback chain for read-only public endpoints 43 + - [x] Add circuit-breaker window per provider/endpoint 44 + - [x] Add structured logs for provider/fallback decisions 45 + - [x] Add tests for timeout/429/5xx transitions with fallback enabled/disabled 46 46 47 47 ## M6 - microcosm Fallbacks 48 48 49 - - [ ] Keep Constellation fallback paths first-class for backlink enrichments 50 - - [ ] Add setting-gated Slingshot identity fallback for degraded handle resolution 51 - - [ ] Add tests for fallback parsing and failure handling 52 - - [ ] Document opt-in behavior and trust boundaries 49 + - [x] Keep Constellation fallback paths first-class for backlink enrichments 50 + - [x] Add setting-gated Slingshot identity fallback for degraded handle resolution 51 + - [x] Add tests for fallback parsing and failure handling 52 + - [x] Document opt-in behavior and trust boundaries 53 53 54 54 ## M7 - Settings and UX 55 55
+15 -1
lib/core/database/app_database.dart
··· 26 26 static const activeAccountDidSettingKey = 'active_account_did'; 27 27 28 28 @override 29 - int get schemaVersion => 18; 29 + int get schemaVersion => 19; 30 30 31 31 @override 32 32 MigrationStrategy get migration => MigrationStrategy( ··· 34 34 await migrator.createAll(); 35 35 await customStatement("INSERT OR IGNORE INTO settings (key, value) VALUES ('typeahead_provider', 'bluesky')"); 36 36 await customStatement("INSERT OR IGNORE INTO settings (key, value) VALUES ('appview_provider', 'bluesky')"); 37 + await customStatement( 38 + "INSERT OR IGNORE INTO settings (key, value) VALUES ('cross_provider_fallback_enabled', 'false')", 39 + ); 40 + await customStatement( 41 + "INSERT OR IGNORE INTO settings (key, value) VALUES ('slingshot_identity_fallback_enabled', 'false')", 42 + ); 37 43 }, 38 44 onUpgrade: (migrator, from, to) async { 39 45 if (from < 2) { ··· 122 128 } 123 129 if (from < 18) { 124 130 await customStatement("INSERT OR IGNORE INTO settings (key, value) VALUES ('appview_provider', 'bluesky')"); 131 + } 132 + if (from < 19) { 133 + await customStatement( 134 + "INSERT OR IGNORE INTO settings (key, value) VALUES ('cross_provider_fallback_enabled', 'false')", 135 + ); 136 + await customStatement( 137 + "INSERT OR IGNORE INTO settings (key, value) VALUES ('slingshot_identity_fallback_enabled', 'false')", 138 + ); 125 139 } 126 140 }, 127 141 );
+180
lib/core/network/app_view_fallback_service.dart
··· 1 + import 'dart:async'; 2 + import 'dart:io'; 3 + 4 + import 'package:atproto_core/atproto_core.dart' as atp_core show XRPCException; 5 + import 'package:lazurite/core/logging/app_logger.dart'; 6 + import 'package:lazurite/core/network/app_view_provider.dart'; 7 + import 'package:lazurite/core/network/app_view_request_context.dart'; 8 + 9 + class AppViewFallbackService { 10 + AppViewFallbackService({ 11 + DateTime Function()? nowProvider, 12 + Duration openWindow = const Duration(minutes: 2), 13 + int failureThreshold = 2, 14 + }) : _nowProvider = nowProvider ?? DateTime.now, 15 + _openWindow = openWindow, 16 + _failureThreshold = failureThreshold; 17 + 18 + final DateTime Function() _nowProvider; 19 + final Duration _openWindow; 20 + final int _failureThreshold; 21 + final Map<String, _CircuitState> _states = {}; 22 + 23 + Future<T> run<T>({ 24 + required String endpointId, 25 + required String primaryProviderKey, 26 + required bool fallbackEnabled, 27 + Map<String, String>? baseHeaders, 28 + required Future<T> Function( 29 + AppViewRequestContext context, 30 + Map<String, String> headers, { 31 + required bool fallbackUsed, 32 + }) 33 + request, 34 + }) async { 35 + final fallbackProvider = fallbackEnabled ? AppViewProviders.alternateBuiltIn(primaryProviderKey) : null; 36 + final candidates = [ 37 + primaryProviderKey, 38 + if (fallbackProvider != null && fallbackProvider != primaryProviderKey) fallbackProvider, 39 + ]; 40 + 41 + Object? lastError; 42 + StackTrace? lastStackTrace; 43 + 44 + for (var index = 0; index < candidates.length; index++) { 45 + final provider = candidates[index]; 46 + final fallbackUsed = index > 0; 47 + final now = _nowProvider(); 48 + if (_isOpen(endpointId, provider, now)) { 49 + log.w( 50 + 'appview.public_read endpoint=$endpointId provider=$provider ' 51 + 'fallbackUsed=$fallbackUsed action=skip reason=circuit_open', 52 + ); 53 + continue; 54 + } 55 + 56 + final context = AppViewRequestContext(appViewProvider: provider); 57 + final headers = context.appBskyHeaders(baseHeaders); 58 + log.i( 59 + 'appview.public_read endpoint=$endpointId provider=$provider ' 60 + 'fallbackUsed=$fallbackUsed fallbackEnabled=$fallbackEnabled action=attempt', 61 + ); 62 + 63 + try { 64 + final result = await request(context, headers, fallbackUsed: fallbackUsed); 65 + _recordSuccess(endpointId, provider); 66 + log.i('appview.public_read endpoint=$endpointId provider=$provider fallbackUsed=$fallbackUsed action=success'); 67 + return result; 68 + } catch (error, stackTrace) { 69 + final failure = _PublicReadFailure.classify(error); 70 + final circuitOpened = _recordFailure(endpointId, provider, now, failure.isTransient); 71 + log.w( 72 + 'appview.public_read endpoint=$endpointId provider=$provider ' 73 + 'fallbackUsed=$fallbackUsed action=error transient=${failure.isTransient} ' 74 + 'reason=${failure.reason} circuitOpened=$circuitOpened', 75 + error: error, 76 + stackTrace: stackTrace, 77 + ); 78 + lastError = error; 79 + lastStackTrace = stackTrace; 80 + if (!failure.isTransient) { 81 + Error.throwWithStackTrace(error, stackTrace); 82 + } 83 + } 84 + } 85 + 86 + if (lastError != null && lastStackTrace != null) { 87 + Error.throwWithStackTrace(lastError, lastStackTrace); 88 + } 89 + 90 + throw StateError('No providers available for $endpointId (all candidates blocked by circuit breaker).'); 91 + } 92 + 93 + bool _isOpen(String endpointId, String providerKey, DateTime now) { 94 + final state = _states[_key(endpointId, providerKey)]; 95 + if (state == null || state.openUntil == null) { 96 + return false; 97 + } 98 + final openUntil = state.openUntil!; 99 + if (now.isBefore(openUntil)) { 100 + return true; 101 + } 102 + state.openUntil = null; 103 + state.transientFailureCount = 0; 104 + return false; 105 + } 106 + 107 + void _recordSuccess(String endpointId, String providerKey) { 108 + final state = _states[_key(endpointId, providerKey)]; 109 + if (state == null) { 110 + return; 111 + } 112 + state.transientFailureCount = 0; 113 + state.openUntil = null; 114 + } 115 + 116 + bool _recordFailure(String endpointId, String providerKey, DateTime now, bool isTransient) { 117 + final state = _states.putIfAbsent(_key(endpointId, providerKey), _CircuitState.new); 118 + if (!isTransient) { 119 + state.transientFailureCount = 0; 120 + state.openUntil = null; 121 + return false; 122 + } 123 + 124 + state.transientFailureCount += 1; 125 + if (state.transientFailureCount >= _failureThreshold) { 126 + state.openUntil = now.add(_openWindow); 127 + state.transientFailureCount = 0; 128 + return true; 129 + } 130 + return false; 131 + } 132 + 133 + String _key(String endpointId, String providerKey) => '$endpointId::$providerKey'; 134 + } 135 + 136 + class _CircuitState { 137 + int transientFailureCount = 0; 138 + DateTime? openUntil; 139 + } 140 + 141 + class _PublicReadFailure { 142 + const _PublicReadFailure._({required this.reason, required this.isTransient}); 143 + 144 + final String reason; 145 + final bool isTransient; 146 + 147 + static _PublicReadFailure classify(Object error) { 148 + if (error is TimeoutException) { 149 + return const _PublicReadFailure._(reason: 'timeout', isTransient: true); 150 + } 151 + if (error is SocketException) { 152 + return const _PublicReadFailure._(reason: 'dns', isTransient: true); 153 + } 154 + if (error is atp_core.XRPCException) { 155 + final statusCode = error.response.status.code; 156 + if (statusCode == 429) { 157 + return const _PublicReadFailure._(reason: '429', isTransient: true); 158 + } 159 + if (statusCode >= 500 && statusCode < 600) { 160 + return const _PublicReadFailure._(reason: '5xx', isTransient: true); 161 + } 162 + return _PublicReadFailure._(reason: 'http_$statusCode', isTransient: false); 163 + } 164 + 165 + final message = error.toString().toLowerCase(); 166 + if (message.contains('timeout')) { 167 + return const _PublicReadFailure._(reason: 'timeout', isTransient: true); 168 + } 169 + if (RegExp(r'(^|[^0-9])429([^0-9]|$)').hasMatch(message)) { 170 + return const _PublicReadFailure._(reason: '429', isTransient: true); 171 + } 172 + if (RegExp(r'(^|[^0-9])5[0-9][0-9]([^0-9]|$)').hasMatch(message)) { 173 + return const _PublicReadFailure._(reason: '5xx', isTransient: true); 174 + } 175 + if (message.contains('failed host lookup') || message.contains('socketexception')) { 176 + return const _PublicReadFailure._(reason: 'dns', isTransient: true); 177 + } 178 + return const _PublicReadFailure._(reason: 'non_transient', isTransient: false); 179 + } 180 + }
+11
lib/core/network/app_view_provider.dart
··· 50 50 final normalizedKey = normalizeSettingKey(rawKey); 51 51 return _builtIns[normalizedKey] ?? bluesky; 52 52 } 53 + 54 + static String? alternateBuiltIn(String? rawKey) { 55 + final normalizedKey = normalizeSettingKey(rawKey); 56 + if (normalizedKey == blueskyKey) { 57 + return blackskyKey; 58 + } 59 + if (normalizedKey == blackskyKey) { 60 + return blueskyKey; 61 + } 62 + return null; 63 + } 53 64 }
+84
lib/core/network/slingshot_client.dart
··· 1 + import 'dart:async'; 2 + import 'dart:convert'; 3 + 4 + import 'package:http/http.dart' as http; 5 + 6 + const String _defaultBaseUrl = 'https://slingshot.microcosm.blue'; 7 + const Duration _kTimeout = Duration(seconds: 10); 8 + 9 + class SlingshotException implements Exception { 10 + const SlingshotException(this.message); 11 + 12 + final String message; 13 + 14 + @override 15 + String toString() => 'SlingshotException: $message'; 16 + } 17 + 18 + class SlingshotMiniDoc { 19 + const SlingshotMiniDoc({required this.did, required this.handle, required this.pds, this.signingKey}); 20 + 21 + final String did; 22 + final String handle; 23 + final String pds; 24 + final String? signingKey; 25 + } 26 + 27 + class SlingshotClient { 28 + SlingshotClient({String? baseUrl, http.Client? httpClient}) 29 + : _baseUrl = _normalizeBaseUrl(baseUrl), 30 + _httpClient = httpClient ?? http.Client(); 31 + 32 + final String _baseUrl; 33 + final http.Client _httpClient; 34 + 35 + String get baseUrl => _baseUrl; 36 + 37 + static String _normalizeBaseUrl(String? baseUrl) { 38 + final trimmed = baseUrl?.trim(); 39 + if (trimmed == null || trimmed.isEmpty) { 40 + return _defaultBaseUrl; 41 + } 42 + return trimmed.replaceFirst(RegExp(r'/+$'), ''); 43 + } 44 + 45 + Uri _resolveMiniDocUri(String identifier) { 46 + final base = Uri.parse('$_baseUrl/xrpc/com.bad-example.identity.resolveMiniDoc'); 47 + return base.replace(queryParameters: {'identifier': identifier}); 48 + } 49 + 50 + Future<SlingshotMiniDoc> resolveMiniDoc(String identifier) async { 51 + final normalizedIdentifier = identifier.trim(); 52 + if (normalizedIdentifier.isEmpty) { 53 + throw const SlingshotException('identifier must not be empty'); 54 + } 55 + 56 + final response = await _httpClient 57 + .get(_resolveMiniDocUri(normalizedIdentifier), headers: {'User-Agent': 'lazurite'}) 58 + .timeout(_kTimeout); 59 + if (response.statusCode != 200) { 60 + throw SlingshotException('HTTP ${response.statusCode}: ${response.body}'); 61 + } 62 + 63 + final decoded = jsonDecode(response.body); 64 + if (decoded is! Map<String, dynamic>) { 65 + throw const SlingshotException('invalid resolveMiniDoc response payload'); 66 + } 67 + 68 + final did = (decoded['did'] as String? ?? '').trim(); 69 + final handle = (decoded['handle'] as String? ?? '').trim(); 70 + final pds = (decoded['pds'] as String? ?? '').trim(); 71 + final signingKey = (decoded['signing_key'] as String?)?.trim(); 72 + 73 + if (did.isEmpty || handle.isEmpty || pds.isEmpty) { 74 + throw const SlingshotException('resolveMiniDoc payload missing required fields'); 75 + } 76 + 77 + return SlingshotMiniDoc( 78 + did: did, 79 + handle: handle, 80 + pds: pds, 81 + signingKey: signingKey == null || signingKey.isEmpty ? null : signingKey, 82 + ); 83 + } 84 + }
+85 -10
lib/features/auth/data/auth_repository.dart
··· 12 12 import 'package:lazurite/core/logging/app_logger.dart'; 13 13 import 'package:lazurite/core/network/atproto_host_resolver.dart'; 14 14 import 'package:lazurite/core/network/app_view_provider.dart'; 15 + import 'package:lazurite/core/network/slingshot_client.dart'; 15 16 import 'package:lazurite/core/network/xrpc_client_factory.dart'; 16 17 import 'package:lazurite/core/network/xrpc_network_interceptor.dart'; 17 18 import 'package:lazurite/features/auth/data/models/auth_models.dart'; ··· 36 37 OAuthRefreshSession oauthRefreshSession = _defaultOAuthRefreshSession, 37 38 Future<OAuthClientMetadata> Function(String clientId) loadClientMetadata = getClientMetadata, 38 39 String Function()? oauthServiceResolver, 40 + bool Function()? slingshotIdentityFallbackEnabledResolver, 41 + SlingshotClient? slingshotClient, 42 + Future<String> Function(String handle)? resolveHandleDid, 43 + Future<Map<String, dynamic>> Function(String did)? resolveDidDocument, 39 44 }) : _database = database, 40 45 _launchUrlWithMode = launchUrlWithMode, 41 46 _closeInAppBrowser = closeInAppBrowser, 42 47 _supportsCloseForMode = supportsCloseForMode, 43 48 _oauthRefreshSession = oauthRefreshSession, 44 49 _loadClientMetadata = loadClientMetadata, 45 - _oauthServiceResolver = oauthServiceResolver ?? _defaultOAuthServiceResolver; 50 + _oauthServiceResolver = oauthServiceResolver ?? _defaultOAuthServiceResolver, 51 + _slingshotIdentityFallbackEnabledResolver = slingshotIdentityFallbackEnabledResolver ?? _defaultFalse, 52 + _slingshotClient = slingshotClient ?? SlingshotClient(), 53 + _resolveHandleDid = resolveHandleDid, 54 + _resolveDidDocumentOverride = resolveDidDocument; 46 55 47 56 static const String kClientId = 'https://lazurite.stormlightlabs.org/client-metadata.json'; 48 57 static const String _oauthService = 'bsky.social'; ··· 56 65 final OAuthRefreshSession _oauthRefreshSession; 57 66 final Future<OAuthClientMetadata> Function(String clientId) _loadClientMetadata; 58 67 final String Function() _oauthServiceResolver; 68 + final bool Function() _slingshotIdentityFallbackEnabledResolver; 69 + final SlingshotClient _slingshotClient; 70 + final Future<String> Function(String handle)? _resolveHandleDid; 71 + final Future<Map<String, dynamic>> Function(String did)? _resolveDidDocumentOverride; 59 72 60 73 HttpServer? _callbackServer; 61 74 StreamSubscription<HttpRequest>? _callbackSubscription; ··· 581 594 582 595 Future<String> _resolveServiceForIdentifier(String identifier) async { 583 596 log.d('AuthRepository: Resolving AT Protocol service for $identifier'); 584 - final client = atp.ATProto.anonymous( 585 - service: _fallbackService, 586 - getClient: XrpcNetworkInterceptor.wrapGetClient(), 587 - postClient: XrpcNetworkInterceptor.wrapPostClient(), 588 - ); 597 + final resolvedIdentity = await _resolveIdentityForIdentifier(identifier); 598 + log.d('AuthRepository: Resolved identifier $identifier to DID ${resolvedIdentity.did}'); 589 599 590 - final did = identifier.startsWith('did:') 591 - ? identifier 592 - : (await client.identity.resolveHandle(handle: identifier)).data.did; 593 - log.d('AuthRepository: Resolved identifier $identifier to DID $did'); 600 + final serviceFromMiniDoc = normalizeAtprotoServiceHost(resolvedIdentity.pdsHost); 601 + if (serviceFromMiniDoc != null) { 602 + log.d('AuthRepository: Using Slingshot-provided PDS host for $identifier: $serviceFromMiniDoc'); 603 + return serviceFromMiniDoc; 604 + } 594 605 606 + final did = resolvedIdentity.did; 595 607 final didDoc = await _resolveDidDocument(did); 596 608 final serviceEndpoint = _extractServiceEndpoint(didDoc) ?? _fallbackService; 597 609 log.d('AuthRepository: Resolved DID $did to service endpoint $serviceEndpoint'); 598 610 return serviceEndpoint; 599 611 } 600 612 613 + Future<({String did, String? pdsHost})> _resolveIdentityForIdentifier(String identifier) async { 614 + final normalizedIdentifier = identifier.trim(); 615 + if (normalizedIdentifier.startsWith('did:')) { 616 + return (did: normalizedIdentifier, pdsHost: null); 617 + } 618 + 619 + try { 620 + final did = await _resolveHandleDidOrFetch(normalizedIdentifier); 621 + return (did: did, pdsHost: null); 622 + } catch (error, stackTrace) { 623 + final useSlingshotFallback = _slingshotIdentityFallbackEnabledResolver(); 624 + log.w( 625 + 'AuthRepository: resolveHandle failed for $normalizedIdentifier ' 626 + 'slingshotFallbackEnabled=$useSlingshotFallback', 627 + error: error, 628 + stackTrace: stackTrace, 629 + ); 630 + if (!useSlingshotFallback) { 631 + rethrow; 632 + } 633 + 634 + try { 635 + final miniDoc = await _slingshotClient.resolveMiniDoc(normalizedIdentifier); 636 + log.i( 637 + 'AuthRepository: slingshot resolveMiniDoc succeeded for $normalizedIdentifier ' 638 + 'did=${miniDoc.did} pds=${miniDoc.pds}', 639 + ); 640 + return (did: miniDoc.did, pdsHost: miniDoc.pds); 641 + } catch (fallbackError, fallbackStackTrace) { 642 + log.w( 643 + 'AuthRepository: slingshot resolveMiniDoc failed for $normalizedIdentifier', 644 + error: fallbackError, 645 + stackTrace: fallbackStackTrace, 646 + ); 647 + rethrow; 648 + } 649 + } 650 + } 651 + 652 + Future<String> _resolveHandleDidOrFetch(String handle) async { 653 + final override = _resolveHandleDid; 654 + if (override != null) { 655 + return override(handle); 656 + } 657 + 658 + final client = atp.ATProto.anonymous( 659 + service: _fallbackService, 660 + getClient: XrpcNetworkInterceptor.wrapGetClient(), 661 + postClient: XrpcNetworkInterceptor.wrapPostClient(), 662 + ); 663 + return (await client.identity.resolveHandle(handle: handle)).data.did; 664 + } 665 + 601 666 Future<String?> _resolveAuthorizationServiceForPdsHost(String pdsHost) async { 602 667 final normalizedPdsHost = normalizeAtprotoServiceHost(pdsHost); 603 668 if (normalizedPdsHost == null) { ··· 638 703 } 639 704 640 705 Future<Map<String, dynamic>> _resolveDidDocument(String did) async { 706 + final override = _resolveDidDocumentOverride; 707 + if (override != null) { 708 + return override(did); 709 + } 710 + 641 711 final uri = _didDocumentUri(did); 642 712 log.d('AuthRepository: Fetching DID document from ${_sanitizeUriForLog(uri)}'); 643 713 final response = await http.get(uri); ··· 822 892 return AppViewProviders.descriptorForSetting(AppViewProviders.defaultKey).entrywayUrl.host; 823 893 } 824 894 895 + static bool _defaultFalse() => false; 896 + 825 897 String _summarizeOAuthRefreshError(Object error) { 826 898 final message = error.toString().replaceAll('\n', ' ').trim(); 827 899 ··· 909 981 910 982 @visibleForTesting 911 983 Future<void> stopCallbackServerForTest() => _stopCallbackServer(); 984 + 985 + @visibleForTesting 986 + Future<String> resolveServiceForIdentifierForTest(String identifier) => _resolveServiceForIdentifier(identifier); 912 987 913 988 int get callbackPort => _callbackServerPort; 914 989 }
+65 -21
lib/features/feed/data/feed_repository.dart
··· 5 5 import 'package:bluesky/app_bsky_feed_defs.dart'; 6 6 import 'package:bluesky/app_bsky_feed_getauthorfeed.dart'; 7 7 import 'package:bluesky/app_bsky_unspecced_defs.dart'; 8 - import 'package:bluesky/bluesky.dart'; 8 + import 'package:flutter/foundation.dart'; 9 9 import 'package:lazurite/core/database/app_database.dart'; 10 10 import 'package:lazurite/core/logging/app_logger.dart'; 11 + import 'package:lazurite/core/network/app_view_fallback_service.dart'; 11 12 import 'package:lazurite/core/network/app_view_request_context.dart'; 12 13 import 'package:lazurite/features/feed/data/trending_join.dart'; 13 14 import 'package:lazurite/features/moderation/data/moderation_service.dart'; 14 15 15 16 class FeedRepository { 16 17 FeedRepository({ 17 - required Bluesky bluesky, 18 + required dynamic bluesky, 18 19 required AppDatabase database, 19 20 required String accountDid, 20 21 ModerationService? moderationService, 21 22 String? appViewProvider, 22 23 String Function()? appViewProviderResolver, 24 + bool crossProviderFallbackEnabled = false, 25 + bool Function()? crossProviderFallbackEnabledResolver, 26 + AppViewFallbackService? appViewFallbackService, 23 27 }) : _bluesky = bluesky, 24 28 _database = database, 25 29 _accountDid = accountDid, ··· 27 31 _appViewContext = AppViewRequestContext( 28 32 appViewProvider: appViewProvider, 29 33 appViewProviderResolver: appViewProviderResolver, 30 - ); 34 + ), 35 + _crossProviderFallbackEnabled = crossProviderFallbackEnabled, 36 + _crossProviderFallbackEnabledResolver = crossProviderFallbackEnabledResolver, 37 + _appViewFallbackService = appViewFallbackService ?? AppViewFallbackService(); 31 38 32 - final Bluesky _bluesky; 39 + final dynamic _bluesky; 33 40 final AppDatabase _database; 34 41 final String _accountDid; 35 42 final ModerationService? _moderationService; 36 43 final AppViewRequestContext _appViewContext; 44 + final bool _crossProviderFallbackEnabled; 45 + final bool Function()? _crossProviderFallbackEnabledResolver; 46 + final AppViewFallbackService _appViewFallbackService; 37 47 38 48 static const String timelineCacheKey = 'timeline'; 39 49 static const int _minTrendingLimit = 1; ··· 176 186 177 187 Future<TrendingTopicsResult> getTrendingTopics({int limit = 10}) async { 178 188 final clampedLimit = _clampTrendingLimit(limit); 179 - final provider = _appViewContext.resolveProviderKey(); 180 - log.i('trending.getTrendingTopics provider=$provider fallback=none limit=$clampedLimit'); 181 - 182 - final response = await _bluesky.unspecced.getTrendingTopics( 183 - limit: clampedLimit, 184 - $service: _appViewContext.publicServiceHost(), 185 - $headers: _appViewContext.appBskyHeaders(await _moderationService?.headersForRequest()), 189 + return _runPublicReadWithFallback( 190 + endpointId: 'app.bsky.unspecced.getTrendingTopics', 191 + request: (context, headers, {required fallbackUsed}) async { 192 + final response = await _bluesky.unspecced.getTrendingTopics( 193 + limit: clampedLimit, 194 + $service: context.publicServiceHost(), 195 + $headers: headers, 196 + ); 197 + return TrendingTopicsResult(topics: response.data.topics, suggested: response.data.suggested); 198 + }, 186 199 ); 187 - 188 - return TrendingTopicsResult(topics: response.data.topics, suggested: response.data.suggested); 189 200 } 190 201 191 202 Future<List<TrendView>> getTrends({int limit = 10}) async { 192 203 final clampedLimit = _clampTrendingLimit(limit); 193 - final provider = _appViewContext.resolveProviderKey(); 194 - log.i('trending.getTrends provider=$provider fallback=none limit=$clampedLimit'); 195 - 196 - final response = await _bluesky.unspecced.getTrends( 197 - limit: clampedLimit, 198 - $service: _appViewContext.publicServiceHost(), 199 - $headers: _appViewContext.appBskyHeaders(await _moderationService?.headersForRequest()), 204 + return _runPublicReadWithFallback( 205 + endpointId: 'app.bsky.unspecced.getTrends', 206 + request: (context, headers, {required fallbackUsed}) async { 207 + final response = await _bluesky.unspecced.getTrends( 208 + limit: clampedLimit, 209 + $service: context.publicServiceHost(), 210 + $headers: headers, 211 + ); 212 + return response.data.trends; 213 + }, 200 214 ); 201 - return response.data.trends; 202 215 } 203 216 204 217 int _clampTrendingLimit(int limit) { ··· 209 222 return _maxTrendingLimit; 210 223 } 211 224 return limit; 225 + } 226 + 227 + Future<T> _runPublicReadWithFallback<T>({ 228 + required String endpointId, 229 + required Future<T> Function( 230 + AppViewRequestContext context, 231 + Map<String, String> headers, { 232 + required bool fallbackUsed, 233 + }) 234 + request, 235 + }) async { 236 + final fallbackEnabled = _crossProviderFallbackEnabledResolver?.call() ?? _crossProviderFallbackEnabled; 237 + final baseHeaders = await _moderationService?.headersForRequest(); 238 + return _appViewFallbackService.run( 239 + endpointId: endpointId, 240 + primaryProviderKey: _appViewContext.resolveProviderKey(), 241 + fallbackEnabled: fallbackEnabled, 242 + baseHeaders: baseHeaders, 243 + request: request, 244 + ); 245 + } 246 + 247 + @visibleForTesting 248 + Future<T> runPublicReadWithFallbackForTest<T>({ 249 + required String endpointId, 250 + required Future<T> Function(String providerKey) request, 251 + }) { 252 + return _runPublicReadWithFallback( 253 + endpointId: endpointId, 254 + request: (context, _, {required fallbackUsed}) => request(context.resolveProviderKey()), 255 + ); 212 256 } 213 257 214 258 Future<GeneratorView> getFeedGenerator(AtUri feedUri) async {
+67 -16
lib/features/search/data/search_repository.dart
··· 5 5 import 'package:bluesky/app_bsky_feed_defs.dart'; 6 6 import 'package:bluesky/app_bsky_feed_searchposts.dart'; 7 7 import 'package:bluesky/app_bsky_graph_defs.dart'; 8 - import 'package:bluesky/bluesky.dart'; 8 + import 'package:flutter/foundation.dart'; 9 + import 'package:lazurite/core/network/app_view_fallback_service.dart'; 9 10 import 'package:lazurite/core/network/app_view_request_context.dart'; 10 11 import 'package:lazurite/core/network/xrpc_network_interceptor.dart'; 11 12 import 'package:lazurite/features/moderation/data/moderation_service.dart'; 12 13 13 14 class SearchRepository { 14 15 SearchRepository({ 15 - required Bluesky bluesky, 16 + required dynamic bluesky, 16 17 ModerationService? moderationService, 17 18 String? appViewProvider, 18 19 String Function()? appViewProviderResolver, 20 + bool crossProviderFallbackEnabled = false, 21 + bool Function()? crossProviderFallbackEnabledResolver, 22 + AppViewFallbackService? appViewFallbackService, 19 23 }) : _bluesky = bluesky, 20 24 _moderationService = moderationService, 21 25 _appViewContext = AppViewRequestContext( 22 26 appViewProvider: appViewProvider, 23 27 appViewProviderResolver: appViewProviderResolver, 24 - ); 28 + ), 29 + _crossProviderFallbackEnabled = crossProviderFallbackEnabled, 30 + _crossProviderFallbackEnabledResolver = crossProviderFallbackEnabledResolver, 31 + _appViewFallbackService = appViewFallbackService ?? AppViewFallbackService(); 25 32 26 - final Bluesky _bluesky; 33 + final dynamic _bluesky; 27 34 final ModerationService? _moderationService; 28 35 final AppViewRequestContext _appViewContext; 36 + final bool _crossProviderFallbackEnabled; 37 + final bool Function()? _crossProviderFallbackEnabledResolver; 38 + final AppViewFallbackService _appViewFallbackService; 29 39 static const int _maxBlackskyTopicFeedLimit = 25; 30 40 31 41 Future<SearchPostsResult> searchPosts({ ··· 65 75 } 66 76 67 77 Future<SearchStarterPacksResult> searchStarterPacks({required String query, String? cursor, int limit = 25}) async { 68 - final response = await _bluesky.graph.searchStarterPacks( 69 - q: query, 70 - cursor: cursor, 71 - limit: limit, 72 - $service: _appViewContext.publicServiceHost(), 73 - $headers: _appViewContext.appBskyHeaders(await _moderationService?.headersForRequest()), 78 + final response = await _runPublicReadWithFallback( 79 + endpointId: 'app.bsky.graph.searchStarterPacks', 80 + request: (context, headers, {required fallbackUsed}) { 81 + return _bluesky.graph.searchStarterPacks( 82 + q: query, 83 + cursor: cursor, 84 + limit: limit, 85 + $service: context.publicServiceHost(), 86 + $headers: headers, 87 + ); 88 + }, 74 89 ); 75 90 76 91 return SearchStarterPacksResult(starterPacks: response.data.starterPacks, cursor: response.data.cursor); 77 92 } 78 93 79 94 Future<SearchFeedsResult> searchFeedGenerators({required String query, String? cursor, int limit = 25}) async { 80 - final response = await _bluesky.unspecced.getPopularFeedGenerators( 81 - query: query, 82 - cursor: cursor, 83 - limit: limit, 84 - $service: _appViewContext.publicServiceHost(), 85 - $headers: _appViewContext.appBskyHeaders(await _moderationService?.headersForRequest()), 95 + final response = await _runPublicReadWithFallback( 96 + endpointId: 'app.bsky.unspecced.getPopularFeedGenerators', 97 + request: (context, headers, {required fallbackUsed}) { 98 + return _bluesky.unspecced.getPopularFeedGenerators( 99 + query: query, 100 + cursor: cursor, 101 + limit: limit, 102 + $service: context.publicServiceHost(), 103 + $headers: headers, 104 + ); 105 + }, 86 106 ); 87 107 88 108 return SearchFeedsResult(feeds: response.data.feeds, cursor: response.data.cursor); ··· 187 207 final copy = Map<String, String>.from(headers); 188 208 copy.removeWhere((key, _) => key.toLowerCase() == 'atproto-proxy'); 189 209 return copy; 210 + } 211 + 212 + Future<T> _runPublicReadWithFallback<T>({ 213 + required String endpointId, 214 + required Future<T> Function( 215 + AppViewRequestContext context, 216 + Map<String, String> headers, { 217 + required bool fallbackUsed, 218 + }) 219 + request, 220 + }) async { 221 + final fallbackEnabled = _crossProviderFallbackEnabledResolver?.call() ?? _crossProviderFallbackEnabled; 222 + final baseHeaders = await _moderationService?.headersForRequest(); 223 + return _appViewFallbackService.run( 224 + endpointId: endpointId, 225 + primaryProviderKey: _appViewContext.resolveProviderKey(), 226 + fallbackEnabled: fallbackEnabled, 227 + baseHeaders: baseHeaders, 228 + request: request, 229 + ); 230 + } 231 + 232 + @visibleForTesting 233 + Future<T> runPublicReadWithFallbackForTest<T>({ 234 + required String endpointId, 235 + required Future<T> Function(String providerKey) request, 236 + }) { 237 + return _runPublicReadWithFallback( 238 + endpointId: endpointId, 239 + request: (context, _, {required fallbackUsed}) => request(context.resolveProviderKey()), 240 + ); 190 241 } 191 242 192 243 List<PostView> _filterPosts(List<PostView> posts) {
+16
lib/features/settings/bloc/settings_cubit.dart
··· 49 49 static const String _defaultTypeaheadProvider = 'bluesky'; 50 50 static const Set<String> _supportedTypeaheadProviders = {'bluesky', 'community'}; 51 51 static const String _keyAppViewProvider = 'appview_provider'; 52 + static const String _keyCrossProviderFallbackEnabled = 'cross_provider_fallback_enabled'; 53 + static const String _keySlingshotIdentityFallbackEnabled = 'slingshot_identity_fallback_enabled'; 52 54 53 55 Future<void> loadSettings() async { 54 56 final paletteStr = await database.getSetting(_keyThemePalette); ··· 65 67 final semanticSearchMaxResultsStr = await database.getSetting(_keySemanticSearchMaxResults); 66 68 final typeaheadProviderStr = await database.getSetting(_keyTypeaheadProvider); 67 69 final appViewProviderStr = await database.getSetting(_keyAppViewProvider); 70 + final crossProviderFallbackEnabledStr = await database.getSetting(_keyCrossProviderFallbackEnabled); 71 + final slingshotIdentityFallbackEnabledStr = await database.getSetting(_keySlingshotIdentityFallbackEnabled); 68 72 final resolvedTypeaheadProvider = _supportedTypeaheadProviders.contains(typeaheadProviderStr) 69 73 ? typeaheadProviderStr! 70 74 : _defaultTypeaheadProvider; ··· 85 89 semanticSearchMaxResults: int.tryParse(semanticSearchMaxResultsStr ?? '') ?? 20, 86 90 typeaheadProvider: resolvedTypeaheadProvider, 87 91 appViewProvider: resolvedAppViewProvider, 92 + crossProviderFallbackEnabled: crossProviderFallbackEnabledStr == 'true', 93 + slingshotIdentityFallbackEnabled: slingshotIdentityFallbackEnabledStr == 'true', 88 94 ), 89 95 ); 90 96 } ··· 173 179 final normalizedProvider = AppViewProviders.normalizeSettingKey(provider); 174 180 await database.setSetting(_keyAppViewProvider, normalizedProvider); 175 181 emit(state.copyWith(appViewProvider: normalizedProvider)); 182 + } 183 + 184 + Future<void> setCrossProviderFallbackEnabled(bool enabled) async { 185 + await database.setSetting(_keyCrossProviderFallbackEnabled, enabled.toString()); 186 + emit(state.copyWith(crossProviderFallbackEnabled: enabled)); 187 + } 188 + 189 + Future<void> setSlingshotIdentityFallbackEnabled(bool enabled) async { 190 + await database.setSetting(_keySlingshotIdentityFallbackEnabled, enabled.toString()); 191 + emit(state.copyWith(slingshotIdentityFallbackEnabled: enabled)); 176 192 } 177 193 }
+14
lib/features/settings/bloc/settings_state.dart
··· 20 20 this.semanticSearchMaxResults = 20, 21 21 this.typeaheadProvider = 'bluesky', 22 22 this.appViewProvider = 'bluesky', 23 + this.crossProviderFallbackEnabled = false, 24 + this.slingshotIdentityFallbackEnabled = false, 23 25 }); 24 26 25 27 final AppThemePalette themePalette; ··· 46 48 /// Configured AppView provider (`bluesky` or `blacksky`). 47 49 final String appViewProvider; 48 50 51 + /// Enables public read fallback from selected AppView to alternate built-in AppView. 52 + final bool crossProviderFallbackEnabled; 53 + 54 + /// Enables Slingshot identity fallback for degraded handle resolution. 55 + final bool slingshotIdentityFallbackEnabled; 56 + 49 57 SettingsState copyWith({ 50 58 AppThemePalette? themePalette, 51 59 AppThemeVariant? themeVariant, ··· 60 68 int? semanticSearchMaxResults, 61 69 String? typeaheadProvider, 62 70 String? appViewProvider, 71 + bool? crossProviderFallbackEnabled, 72 + bool? slingshotIdentityFallbackEnabled, 63 73 }) { 64 74 return SettingsState( 65 75 themePalette: themePalette ?? this.themePalette, ··· 77 87 semanticSearchMaxResults: semanticSearchMaxResults ?? this.semanticSearchMaxResults, 78 88 typeaheadProvider: typeaheadProvider ?? this.typeaheadProvider, 79 89 appViewProvider: appViewProvider ?? this.appViewProvider, 90 + crossProviderFallbackEnabled: crossProviderFallbackEnabled ?? this.crossProviderFallbackEnabled, 91 + slingshotIdentityFallbackEnabled: slingshotIdentityFallbackEnabled ?? this.slingshotIdentityFallbackEnabled, 80 92 ); 81 93 } 82 94 ··· 95 107 semanticSearchMaxResults, 96 108 typeaheadProvider, 97 109 appViewProvider, 110 + crossProviderFallbackEnabled, 111 + slingshotIdentityFallbackEnabled, 98 112 ]; 99 113 }
+26 -1
lib/features/settings/presentation/settings_screen.dart
··· 419 419 } 420 420 421 421 Widget _buildAdvancedSettings(BuildContext context) { 422 + final settingsCubit = context.read<SettingsCubit>(); 422 423 return BlocBuilder<SettingsCubit, SettingsState>( 423 424 builder: (context, state) { 424 425 return Container( ··· 429 430 ), 430 431 color: Theme.of(context).cardColor, 431 432 ), 432 - child: _ConstellationUrlTile(currentUrl: state.constellationUrl), 433 + child: Column( 434 + children: [ 435 + _ConstellationUrlTile(currentUrl: state.constellationUrl), 436 + const Divider(height: 1), 437 + _SettingsTile( 438 + icon: Icons.compare_arrows_outlined, 439 + title: 'Cross-Provider Fallback', 440 + subtitle: 'Retry public reads on the alternate AppView when transient errors occur', 441 + trailing: Switch.adaptive( 442 + value: state.crossProviderFallbackEnabled, 443 + onChanged: settingsCubit.setCrossProviderFallbackEnabled, 444 + ), 445 + ), 446 + const Divider(height: 1), 447 + _SettingsTile( 448 + icon: Icons.alt_route_outlined, 449 + title: 'Slingshot Identity Fallback', 450 + subtitle: 'Use Slingshot resolveMiniDoc for degraded handle resolution', 451 + trailing: Switch.adaptive( 452 + value: state.slingshotIdentityFallbackEnabled, 453 + onChanged: settingsCubit.setSlingshotIdentityFallbackEnabled, 454 + ), 455 + ), 456 + ], 457 + ), 433 458 ); 434 459 }, 435 460 );
+13
lib/main.dart
··· 11 11 import 'package:lazurite/core/logging/app_logger.dart'; 12 12 import 'package:lazurite/core/logging/logging_bloc_observer.dart'; 13 13 import 'package:lazurite/core/logging/logging_navigator_observer.dart'; 14 + import 'package:lazurite/core/network/app_view_fallback_service.dart'; 14 15 import 'package:lazurite/core/network/app_view_provider.dart'; 15 16 import 'package:lazurite/core/network/app_view_router.dart'; 16 17 import 'package:lazurite/core/network/xrpc_client_factory.dart'; ··· 62 63 Bloc.observer = LoggingBlocObserver(); 63 64 64 65 final database = AppDatabase(); 66 + final appViewFallbackService = AppViewFallbackService(); 65 67 final objectBoxStore = await ObjectBoxStore.open(); 66 68 final embeddingService = EmbeddingService(); 67 69 unawaited(embeddingService.initialize()); ··· 75 77 final router = AppViewRouter(provider: provider); 76 78 return router.entrywayForAuth().host; 77 79 }, 80 + slingshotIdentityFallbackEnabledResolver: () => settingsCubit.state.slingshotIdentityFallbackEnabled, 78 81 ), 79 82 restoreSession: (authRepository) => authRepository.restoreSession(), 80 83 ); ··· 97 100 LazuriteApp.from( 98 101 authBloc, 99 102 database, 103 + appViewFallbackService, 100 104 objectBoxStore, 101 105 embeddingService, 102 106 settingsCubit, ··· 111 115 super.key, 112 116 required this.authBloc, 113 117 required this.database, 118 + required this.appViewFallbackService, 114 119 required this.objectBoxStore, 115 120 required this.embeddingService, 116 121 required this.settingsCubit, ··· 120 125 121 126 final AuthBloc authBloc; 122 127 final AppDatabase database; 128 + final AppViewFallbackService appViewFallbackService; 123 129 final ObjectBoxStore objectBoxStore; 124 130 final EmbeddingService embeddingService; 125 131 final SettingsCubit settingsCubit; ··· 130 136 static LazuriteApp from( 131 137 AuthBloc authBloc, 132 138 AppDatabase database, 139 + AppViewFallbackService appViewFallbackService, 133 140 ObjectBoxStore objectBoxStore, 134 141 EmbeddingService embeddingService, 135 142 SettingsCubit settingsCubit, ··· 138 145 ) => LazuriteApp( 139 146 authBloc: authBloc, 140 147 database: database, 148 + appViewFallbackService: appViewFallbackService, 141 149 objectBoxStore: objectBoxStore, 142 150 embeddingService: embeddingService, 143 151 settingsCubit: settingsCubit, ··· 268 276 accountDid: accountDid, 269 277 moderationService: context.read<ModerationService>(), 270 278 appViewProviderResolver: () => context.read<SettingsCubit>().state.appViewProvider, 279 + crossProviderFallbackEnabledResolver: () => 280 + context.read<SettingsCubit>().state.crossProviderFallbackEnabled, 281 + appViewFallbackService: widget.appViewFallbackService, 271 282 ), 272 283 ), 273 284 RepositoryProvider( ··· 277 288 bluesky: bluesky, 278 289 moderationService: context.read<ModerationService>(), 279 290 appViewProviderResolver: () => settingsCubit.state.appViewProvider, 291 + crossProviderFallbackEnabledResolver: () => settingsCubit.state.crossProviderFallbackEnabled, 292 + appViewFallbackService: widget.appViewFallbackService, 280 293 ); 281 294 }, 282 295 ),
+5
test/core/network/app_view_provider_test.dart
··· 27 27 expect(blacksky.entrywayUrl.host, equals('blacksky.community')); 28 28 expect(blacksky.webBaseUrl.host, equals('blacksky.community')); 29 29 }); 30 + 31 + test('returns alternate built-in provider key', () { 32 + expect(AppViewProviders.alternateBuiltIn('bluesky'), equals('blacksky')); 33 + expect(AppViewProviders.alternateBuiltIn('blacksky'), equals('bluesky')); 34 + }); 30 35 }); 31 36 }
+85
test/core/network/slingshot_client_test.dart
··· 1 + import 'dart:convert'; 2 + 3 + import 'package:flutter_test/flutter_test.dart'; 4 + import 'package:http/http.dart' as http; 5 + import 'package:http/testing.dart'; 6 + import 'package:lazurite/core/network/slingshot_client.dart'; 7 + 8 + void main() { 9 + group('SlingshotClient', () { 10 + test('uses default base URL when none provided', () async { 11 + Uri? capturedUri; 12 + final client = SlingshotClient( 13 + httpClient: MockClient((request) async { 14 + capturedUri = request.url; 15 + return http.Response( 16 + jsonEncode({'did': 'did:plc:test', 'handle': 'test.bsky.social', 'pds': 'bsky.social'}), 17 + 200, 18 + ); 19 + }), 20 + ); 21 + 22 + await client.resolveMiniDoc('test.bsky.social'); 23 + expect(capturedUri?.host, equals('slingshot.microcosm.blue')); 24 + }); 25 + 26 + test('sends identifier query parameter and user-agent header', () async { 27 + Uri? capturedUri; 28 + Map<String, String>? capturedHeaders; 29 + final client = SlingshotClient( 30 + baseUrl: 'https://example.com/', 31 + httpClient: MockClient((request) async { 32 + capturedUri = request.url; 33 + capturedHeaders = request.headers; 34 + return http.Response( 35 + jsonEncode({'did': 'did:plc:test', 'handle': 'test.bsky.social', 'pds': 'bsky.social'}), 36 + 200, 37 + ); 38 + }), 39 + ); 40 + 41 + await client.resolveMiniDoc('test.bsky.social'); 42 + expect(capturedUri?.path, equals('/xrpc/com.bad-example.identity.resolveMiniDoc')); 43 + expect(capturedUri?.queryParameters['identifier'], equals('test.bsky.social')); 44 + expect(capturedHeaders?['User-Agent'], equals('lazurite')); 45 + }); 46 + 47 + test('parses required fields', () async { 48 + final client = SlingshotClient( 49 + httpClient: MockClient( 50 + (_) async => http.Response( 51 + jsonEncode({ 52 + 'did': 'did:plc:test', 53 + 'handle': 'test.bsky.social', 54 + 'pds': 'https://pds.example.com', 55 + 'signing_key': 'did:key:z123', 56 + }), 57 + 200, 58 + ), 59 + ), 60 + ); 61 + 62 + final miniDoc = await client.resolveMiniDoc('test.bsky.social'); 63 + expect(miniDoc.did, equals('did:plc:test')); 64 + expect(miniDoc.handle, equals('test.bsky.social')); 65 + expect(miniDoc.pds, equals('https://pds.example.com')); 66 + expect(miniDoc.signingKey, equals('did:key:z123')); 67 + }); 68 + 69 + test('throws SlingshotException on non-200 response', () async { 70 + final client = SlingshotClient(httpClient: MockClient((_) async => http.Response('upstream error', 503))); 71 + 72 + expect(() => client.resolveMiniDoc('test.bsky.social'), throwsA(isA<SlingshotException>())); 73 + }); 74 + 75 + test('throws SlingshotException when payload is missing required fields', () async { 76 + final client = SlingshotClient( 77 + httpClient: MockClient( 78 + (_) async => http.Response(jsonEncode({'did': 'did:plc:test', 'handle': 'test.bsky.social'}), 200), 79 + ), 80 + ); 81 + 82 + expect(() => client.resolveMiniDoc('test.bsky.social'), throwsA(isA<SlingshotException>())); 83 + }); 84 + }); 85 + }
+44
test/features/auth/data/auth_repository_test.dart
··· 4 4 import 'package:flutter/foundation.dart'; 5 5 import 'package:flutter_test/flutter_test.dart'; 6 6 import 'package:lazurite/core/database/app_database.dart'; 7 + import 'package:lazurite/core/network/slingshot_client.dart'; 7 8 import 'package:lazurite/features/auth/data/auth_repository.dart'; 8 9 import 'package:lazurite/features/auth/data/models/auth_models.dart'; 9 10 import 'package:mocktail/mocktail.dart'; 10 11 import 'package:url_launcher/url_launcher.dart'; 11 12 12 13 class MockAppDatabase extends Mock implements AppDatabase {} 14 + 15 + class MockSlingshotClient extends Mock implements SlingshotClient {} 13 16 14 17 class FakeAccountsCompanion extends Fake implements AccountsCompanion {} 15 18 16 19 void main() { 17 20 late AuthRepository authRepository; 18 21 late MockAppDatabase mockDatabase; 22 + late MockSlingshotClient mockSlingshotClient; 19 23 20 24 setUpAll(() { 21 25 registerFallbackValue(FakeAccountsCompanion()); ··· 23 27 24 28 setUp(() { 25 29 mockDatabase = MockAppDatabase(); 30 + mockSlingshotClient = MockSlingshotClient(); 26 31 authRepository = AuthRepository(database: mockDatabase); 27 32 }); 28 33 ··· 268 273 ); 269 274 270 275 expect(candidates, equals(['bsky.social'])); 276 + }); 277 + }); 278 + 279 + group('slingshot identity fallback', () { 280 + test('does not use slingshot fallback when disabled', () async { 281 + authRepository = AuthRepository( 282 + database: mockDatabase, 283 + slingshotClient: mockSlingshotClient, 284 + slingshotIdentityFallbackEnabledResolver: () => false, 285 + resolveHandleDid: (_) async => throw Exception('resolveHandle down'), 286 + ); 287 + 288 + expect(() => authRepository.resolveServiceForIdentifierForTest('alice.bsky.social'), throwsA(isA<Exception>())); 289 + verifyNever(() => mockSlingshotClient.resolveMiniDoc(any())); 290 + }); 291 + 292 + test('uses slingshot mini doc when handle resolution is degraded and fallback is enabled', () async { 293 + when(() => mockSlingshotClient.resolveMiniDoc('alice.bsky.social')).thenAnswer( 294 + (_) async => const SlingshotMiniDoc( 295 + did: 'did:plc:alice', 296 + handle: 'alice.bsky.social', 297 + pds: 'https://pds.alice.example', 298 + ), 299 + ); 300 + 301 + authRepository = AuthRepository( 302 + database: mockDatabase, 303 + slingshotClient: mockSlingshotClient, 304 + slingshotIdentityFallbackEnabledResolver: () => true, 305 + resolveHandleDid: (_) async => throw Exception('resolveHandle down'), 306 + resolveDidDocument: (_) async { 307 + throw Exception('DID doc should not be fetched when mini doc includes pds'); 308 + }, 309 + ); 310 + 311 + final service = await authRepository.resolveServiceForIdentifierForTest('alice.bsky.social'); 312 + 313 + expect(service, equals('pds.alice.example')); 314 + verify(() => mockSlingshotClient.resolveMiniDoc('alice.bsky.social')).called(1); 271 315 }); 272 316 }); 273 317
+169
test/features/feed/data/feed_repository_fallback_test.dart
··· 1 + import 'dart:async'; 2 + 3 + import 'package:flutter_test/flutter_test.dart'; 4 + import 'package:lazurite/core/database/app_database.dart'; 5 + import 'package:lazurite/core/network/app_view_fallback_service.dart'; 6 + import 'package:lazurite/features/feed/data/feed_repository.dart'; 7 + import 'package:mocktail/mocktail.dart'; 8 + 9 + class _StubBluesky {} 10 + 11 + class MockAppDatabase extends Mock implements AppDatabase {} 12 + 13 + void main() { 14 + late _StubBluesky bluesky; 15 + late MockAppDatabase database; 16 + 17 + setUp(() { 18 + bluesky = _StubBluesky(); 19 + database = MockAppDatabase(); 20 + }); 21 + 22 + test('timeout does not fallback when cross-provider fallback is disabled', () async { 23 + final repo = FeedRepository( 24 + bluesky: bluesky, 25 + database: database, 26 + accountDid: 'did:plc:test', 27 + appViewProvider: 'bluesky', 28 + crossProviderFallbackEnabled: false, 29 + ); 30 + final attempts = <String>[]; 31 + 32 + await expectLater( 33 + () => repo.runPublicReadWithFallbackForTest<String>( 34 + endpointId: 'app.bsky.unspecced.getTrends', 35 + request: (provider) async { 36 + attempts.add(provider); 37 + throw TimeoutException('request timed out'); 38 + }, 39 + ), 40 + throwsA(isA<TimeoutException>()), 41 + ); 42 + 43 + expect(attempts, equals(['bluesky'])); 44 + }); 45 + 46 + test('429 triggers fallback when cross-provider fallback is enabled', () async { 47 + final repo = FeedRepository( 48 + bluesky: bluesky, 49 + database: database, 50 + accountDid: 'did:plc:test', 51 + appViewProvider: 'bluesky', 52 + crossProviderFallbackEnabled: true, 53 + ); 54 + final attempts = <String>[]; 55 + 56 + final result = await repo.runPublicReadWithFallbackForTest<String>( 57 + endpointId: 'app.bsky.unspecced.getTrendingTopics', 58 + request: (provider) async { 59 + attempts.add(provider); 60 + if (provider == 'bluesky') { 61 + throw Exception('HTTP 429: too many requests'); 62 + } 63 + return 'ok:$provider'; 64 + }, 65 + ); 66 + 67 + expect(result, equals('ok:blacksky')); 68 + expect(attempts, equals(['bluesky', 'blacksky'])); 69 + }); 70 + 71 + test('5xx triggers fallback when cross-provider fallback is enabled', () async { 72 + final repo = FeedRepository( 73 + bluesky: bluesky, 74 + database: database, 75 + accountDid: 'did:plc:test', 76 + appViewProvider: 'blacksky', 77 + crossProviderFallbackEnabled: true, 78 + ); 79 + final attempts = <String>[]; 80 + 81 + final result = await repo.runPublicReadWithFallbackForTest<String>( 82 + endpointId: 'app.bsky.unspecced.getTrends', 83 + request: (provider) async { 84 + attempts.add(provider); 85 + if (provider == 'blacksky') { 86 + throw Exception('HTTP 503 service unavailable'); 87 + } 88 + return 'ok:$provider'; 89 + }, 90 + ); 91 + 92 + expect(result, equals('ok:bluesky')); 93 + expect(attempts, equals(['blacksky', 'bluesky'])); 94 + }); 95 + 96 + test('429 does not fallback when cross-provider fallback is disabled', () async { 97 + final repo = FeedRepository( 98 + bluesky: bluesky, 99 + database: database, 100 + accountDid: 'did:plc:test', 101 + appViewProvider: 'blacksky', 102 + crossProviderFallbackEnabled: false, 103 + ); 104 + final attempts = <String>[]; 105 + 106 + await expectLater( 107 + () => repo.runPublicReadWithFallbackForTest<String>( 108 + endpointId: 'app.bsky.unspecced.getTrendingTopics', 109 + request: (provider) async { 110 + attempts.add(provider); 111 + throw Exception('429 RateLimitExceeded'); 112 + }, 113 + ), 114 + throwsA(isA<Exception>()), 115 + ); 116 + 117 + expect(attempts, equals(['blacksky'])); 118 + }); 119 + 120 + test('circuit breaker opens after transient failure threshold and closes after window', () async { 121 + var now = DateTime.utc(2026, 4, 30, 12, 0, 0); 122 + final fallbackService = AppViewFallbackService( 123 + nowProvider: () => now, 124 + failureThreshold: 1, 125 + openWindow: const Duration(minutes: 2), 126 + ); 127 + final repo = FeedRepository( 128 + bluesky: bluesky, 129 + database: database, 130 + accountDid: 'did:plc:test', 131 + appViewProvider: 'bluesky', 132 + crossProviderFallbackEnabled: true, 133 + appViewFallbackService: fallbackService, 134 + ); 135 + final attempts = <String>[]; 136 + 137 + final first = await repo.runPublicReadWithFallbackForTest<String>( 138 + endpointId: 'app.bsky.unspecced.getTrends', 139 + request: (provider) async { 140 + attempts.add(provider); 141 + if (provider == 'bluesky') { 142 + throw TimeoutException('timed out'); 143 + } 144 + return 'ok:$provider'; 145 + }, 146 + ); 147 + expect(first, equals('ok:blacksky')); 148 + 149 + final second = await repo.runPublicReadWithFallbackForTest<String>( 150 + endpointId: 'app.bsky.unspecced.getTrends', 151 + request: (provider) async { 152 + attempts.add(provider); 153 + return 'ok:$provider'; 154 + }, 155 + ); 156 + expect(second, equals('ok:blacksky')); 157 + 158 + now = now.add(const Duration(minutes: 3)); 159 + final third = await repo.runPublicReadWithFallbackForTest<String>( 160 + endpointId: 'app.bsky.unspecced.getTrends', 161 + request: (provider) async { 162 + attempts.add(provider); 163 + return 'ok:$provider'; 164 + }, 165 + ); 166 + expect(third, equals('ok:bluesky')); 167 + expect(attempts, equals(['bluesky', 'blacksky', 'blacksky', 'bluesky'])); 168 + }); 169 + }
+106
test/features/search/data/search_repository_fallback_test.dart
··· 1 + import 'dart:async'; 2 + 3 + import 'package:flutter_test/flutter_test.dart'; 4 + import 'package:lazurite/core/network/app_view_fallback_service.dart'; 5 + import 'package:lazurite/features/search/data/search_repository.dart'; 6 + 7 + class _StubBluesky {} 8 + 9 + void main() { 10 + late dynamic bluesky; 11 + 12 + setUp(() { 13 + bluesky = _StubBluesky(); 14 + }); 15 + 16 + test('search public reads do not fallback when disabled', () async { 17 + final repo = SearchRepository(bluesky: bluesky, appViewProvider: 'bluesky', crossProviderFallbackEnabled: false); 18 + final attempts = <String>[]; 19 + 20 + await expectLater( 21 + () => repo.runPublicReadWithFallbackForTest<String>( 22 + endpointId: 'app.bsky.unspecced.getPopularFeedGenerators', 23 + request: (provider) async { 24 + attempts.add(provider); 25 + throw TimeoutException('timed out'); 26 + }, 27 + ), 28 + throwsA(isA<TimeoutException>()), 29 + ); 30 + 31 + expect(attempts, equals(['bluesky'])); 32 + }); 33 + 34 + test('search public reads fallback when enabled and transient failure occurs', () async { 35 + final repo = SearchRepository(bluesky: bluesky, appViewProvider: 'bluesky', crossProviderFallbackEnabled: true); 36 + final attempts = <String>[]; 37 + 38 + final result = await repo.runPublicReadWithFallbackForTest<String>( 39 + endpointId: 'app.bsky.graph.searchStarterPacks', 40 + request: (provider) async { 41 + attempts.add(provider); 42 + if (provider == 'bluesky') { 43 + throw Exception('HTTP 503 service unavailable'); 44 + } 45 + return 'ok:$provider'; 46 + }, 47 + ); 48 + 49 + expect(result, equals('ok:blacksky')); 50 + expect(attempts, equals(['bluesky', 'blacksky'])); 51 + }); 52 + 53 + test('search repositories share circuit state through injected AppViewFallbackService', () async { 54 + var now = DateTime.utc(2026, 4, 30, 14, 0, 0); 55 + final fallbackService = AppViewFallbackService( 56 + nowProvider: () => now, 57 + failureThreshold: 1, 58 + openWindow: const Duration(minutes: 2), 59 + ); 60 + final repoA = SearchRepository( 61 + bluesky: bluesky, 62 + appViewProvider: 'bluesky', 63 + crossProviderFallbackEnabled: true, 64 + appViewFallbackService: fallbackService, 65 + ); 66 + final repoB = SearchRepository( 67 + bluesky: bluesky, 68 + appViewProvider: 'bluesky', 69 + crossProviderFallbackEnabled: true, 70 + appViewFallbackService: fallbackService, 71 + ); 72 + final attempts = <String>[]; 73 + 74 + final first = await repoA.runPublicReadWithFallbackForTest<String>( 75 + endpointId: 'app.bsky.unspecced.getPopularFeedGenerators', 76 + request: (provider) async { 77 + attempts.add('A:$provider'); 78 + if (provider == 'bluesky') { 79 + throw TimeoutException('timed out'); 80 + } 81 + return 'ok:$provider'; 82 + }, 83 + ); 84 + expect(first, equals('ok:blacksky')); 85 + 86 + final second = await repoB.runPublicReadWithFallbackForTest<String>( 87 + endpointId: 'app.bsky.unspecced.getPopularFeedGenerators', 88 + request: (provider) async { 89 + attempts.add('B:$provider'); 90 + return 'ok:$provider'; 91 + }, 92 + ); 93 + expect(second, equals('ok:blacksky')); 94 + 95 + now = now.add(const Duration(minutes: 3)); 96 + final third = await repoB.runPublicReadWithFallbackForTest<String>( 97 + endpointId: 'app.bsky.unspecced.getPopularFeedGenerators', 98 + request: (provider) async { 99 + attempts.add('B2:$provider'); 100 + return 'ok:$provider'; 101 + }, 102 + ); 103 + expect(third, equals('ok:bluesky')); 104 + expect(attempts, equals(['A:bluesky', 'A:blacksky', 'B:blacksky', 'B2:bluesky'])); 105 + }); 106 + }
+48 -1
test/features/settings/bloc/settings_cubit_test.dart
··· 29 29 expect(cubit.state.simulateOffline, false); 30 30 expect(cubit.state.threadAutoCollapseDepth, isNull); 31 31 expect(cubit.state.appViewProvider, 'bluesky'); 32 + expect(cubit.state.crossProviderFallbackEnabled, isFalse); 33 + expect(cubit.state.slingshotIdentityFallbackEnabled, isFalse); 32 34 }); 33 35 34 36 test('accepts initial values via constructor', () { ··· 90 92 .having((s) => s.simulateOffline, 'simulateOffline', false) 91 93 .having((s) => s.threadAutoCollapseDepth, 'threadAutoCollapseDepth', isNull) 92 94 .having((s) => s.typeaheadProvider, 'typeaheadProvider', 'bluesky') 93 - .having((s) => s.appViewProvider, 'appViewProvider', 'bluesky'), 95 + .having((s) => s.appViewProvider, 'appViewProvider', 'bluesky') 96 + .having((s) => s.crossProviderFallbackEnabled, 'crossProviderFallbackEnabled', false) 97 + .having((s) => s.slingshotIdentityFallbackEnabled, 'slingshotIdentityFallbackEnabled', false), 94 98 ], 95 99 ); 96 100 ··· 326 330 }, 327 331 act: (cubit) => cubit.loadSettings(), 328 332 expect: () => [isA<SettingsState>().having((s) => s.appViewProvider, 'appViewProvider', 'bluesky')], 333 + ); 334 + 335 + blocTest<SettingsCubit, SettingsState>( 336 + 'setCrossProviderFallbackEnabled updates state and persists to database', 337 + build: () => SettingsCubit(database: database), 338 + act: (cubit) => cubit.setCrossProviderFallbackEnabled(true), 339 + expect: () => [ 340 + isA<SettingsState>().having((s) => s.crossProviderFallbackEnabled, 'crossProviderFallbackEnabled', true), 341 + ], 342 + verify: (_) async { 343 + expect(await database.getSetting('cross_provider_fallback_enabled'), 'true'); 344 + }, 345 + ); 346 + 347 + blocTest<SettingsCubit, SettingsState>( 348 + 'setSlingshotIdentityFallbackEnabled updates state and persists to database', 349 + build: () => SettingsCubit(database: database), 350 + act: (cubit) => cubit.setSlingshotIdentityFallbackEnabled(true), 351 + expect: () => [ 352 + isA<SettingsState>().having( 353 + (s) => s.slingshotIdentityFallbackEnabled, 354 + 'slingshotIdentityFallbackEnabled', 355 + true, 356 + ), 357 + ], 358 + verify: (_) async { 359 + expect(await database.getSetting('slingshot_identity_fallback_enabled'), 'true'); 360 + }, 361 + ); 362 + 363 + blocTest<SettingsCubit, SettingsState>( 364 + 'loadSettings restores persisted fallback toggles', 365 + build: () => SettingsCubit(database: database), 366 + setUp: () async { 367 + await database.setSetting('cross_provider_fallback_enabled', 'true'); 368 + await database.setSetting('slingshot_identity_fallback_enabled', 'true'); 369 + }, 370 + act: (cubit) => cubit.loadSettings(), 371 + expect: () => [ 372 + isA<SettingsState>() 373 + .having((s) => s.crossProviderFallbackEnabled, 'crossProviderFallbackEnabled', true) 374 + .having((s) => s.slingshotIdentityFallbackEnabled, 'slingshotIdentityFallbackEnabled', true), 375 + ], 329 376 ); 330 377 }); 331 378 }
+17
test/features/settings/bloc/settings_state_test.dart
··· 149 149 simulateOffline: true, 150 150 threadAutoCollapseDepth: 3, 151 151 appViewProvider: 'blacksky', 152 + crossProviderFallbackEnabled: true, 153 + slingshotIdentityFallbackEnabled: true, 152 154 ); 153 155 154 156 expect(updated.themePalette, AppThemePalette.nord); ··· 159 161 expect(updated.simulateOffline, true); 160 162 expect(updated.threadAutoCollapseDepth, 3); 161 163 expect(updated.appViewProvider, 'blacksky'); 164 + expect(updated.crossProviderFallbackEnabled, isTrue); 165 + expect(updated.slingshotIdentityFallbackEnabled, isTrue); 162 166 expect(original.themePalette, AppThemePalette.oxocarbon); 163 167 }); 164 168 ··· 183 187 expect(updated.simulateOffline, true); 184 188 expect(updated.threadAutoCollapseDepth, 4); 185 189 expect(updated.appViewProvider, 'bluesky'); 190 + expect(updated.crossProviderFallbackEnabled, isFalse); 191 + expect(updated.slingshotIdentityFallbackEnabled, isFalse); 186 192 }); 187 193 188 194 test('copyWith can clear threadAutoCollapseDepth', () { ··· 217 223 expect(state.props, contains(true)); 218 224 expect(state.props, contains(6)); 219 225 expect(state.props, contains('bluesky')); 226 + expect(state.props, contains(false)); 220 227 }); 221 228 222 229 test('defaults feedLayout to card', () { ··· 262 269 useSystemTheme: false, 263 270 ); 264 271 expect(state.appViewProvider, 'bluesky'); 272 + }); 273 + 274 + test('defaults fallback toggles to disabled', () { 275 + const state = SettingsState( 276 + themePalette: AppThemePalette.oxocarbon, 277 + themeVariant: AppThemeVariant.dark, 278 + useSystemTheme: false, 279 + ); 280 + expect(state.crossProviderFallbackEnabled, isFalse); 281 + expect(state.slingshotIdentityFallbackEnabled, isFalse); 265 282 }); 266 283 }); 267 284 }
+2
test/features/settings/presentation/settings_screen_test.dart
··· 251 251 expect(find.text('ADVANCED'), findsOneWidget); 252 252 expect(find.text('Constellation URL'), findsOneWidget); 253 253 expect(find.text('https://constellation.microcosm.blue'), findsOneWidget); 254 + expect(find.text('Cross-Provider Fallback'), findsOneWidget); 255 + expect(find.text('Slingshot Identity Fallback'), findsOneWidget); 254 256 expect(find.byIcon(Icons.edit_outlined), findsNothing); 255 257 }); 256 258