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: centralize xrpc client creation

* use user pds for tokens

+336 -71
+52
lib/core/network/xrpc_client_factory.dart
··· 1 + import 'package:atproto/atproto.dart' as atp; 2 + import 'package:atproto_core/atproto_core.dart' as atp_core; 3 + import 'package:atproto_oauth/atproto_oauth.dart' as atp_oauth; 4 + import 'package:bluesky/bluesky.dart'; 5 + import 'package:lazurite/features/auth/data/models/auth_models.dart'; 6 + 7 + /// Creates a Bluesky client from authentication tokens. 8 + /// 9 + /// OAuth tokens are scoped to the user's PDS. Let the SDK derive that 10 + /// endpoint from the token instead of forcing the OAuth auth server host. 11 + Bluesky? createBlueskyClient(AuthTokens? tokens) { 12 + if (tokens == null) { 13 + return null; 14 + } 15 + 16 + if (tokens.usesOAuth) { 17 + if (tokens.dpopPublicKey == null || tokens.dpopPrivateKey == null || tokens.refreshToken == null) { 18 + return null; 19 + } 20 + 21 + final oauthSession = atp_core.restoreOAuthSession( 22 + accessToken: tokens.accessToken, 23 + refreshToken: tokens.refreshToken!, 24 + dPoPNonce: tokens.dpopNonce, 25 + publicKey: tokens.dpopPublicKey!, 26 + privateKey: tokens.dpopPrivateKey!, 27 + ); 28 + 29 + return Bluesky.fromOAuthSession(oauthSession); 30 + } 31 + 32 + if (tokens.refreshToken == null) { 33 + return null; 34 + } 35 + 36 + final session = atp_core.Session( 37 + did: tokens.did, 38 + handle: tokens.handle, 39 + accessJwt: tokens.accessToken, 40 + refreshJwt: tokens.refreshToken!, 41 + ); 42 + 43 + return Bluesky.fromSession(session, service: tokens.service); 44 + } 45 + 46 + atp.ATProto createAtProtoForOAuthSession(atp_oauth.OAuthSession session) { 47 + return atp.ATProto.fromOAuthSession(session); 48 + } 49 + 50 + Bluesky createBlueskyForOAuthSession(atp_oauth.OAuthSession session) { 51 + return Bluesky.fromOAuthSession(session); 52 + }
+11 -7
lib/features/auth/data/auth_repository.dart
··· 5 5 import 'package:atproto/atproto.dart' as atp; 6 6 import 'package:atproto_core/atproto_core.dart' as atcore; 7 7 import 'package:atproto_oauth/atproto_oauth.dart'; 8 - import 'package:bluesky/bluesky.dart'; 9 8 import 'package:drift/drift.dart'; 10 9 import 'package:http/http.dart' as http; 11 10 import 'package:lazurite/core/database/app_database.dart'; 12 11 import 'package:lazurite/core/logging/app_logger.dart'; 12 + import 'package:lazurite/core/network/xrpc_client_factory.dart'; 13 13 import 'package:lazurite/features/auth/data/models/auth_models.dart'; 14 14 import 'package:url_launcher/url_launcher.dart'; 15 15 ··· 189 189 final refreshedTokens = await _buildOAuthTokens( 190 190 refreshedSession, 191 191 fallbackHandle: currentSession.handle, 192 - service: currentSession.service ?? _fallbackService, 192 + oauthService: currentSession.service ?? _fallbackService, 193 193 ); 194 194 195 195 await saveSession(refreshedTokens); ··· 323 323 ); 324 324 final oauthSession = await oauthClient.callback(callbackUrl, oauthContext); 325 325 log.i('AuthRepository: OAuth token exchange succeeded for DID ${oauthSession.sub}'); 326 - final tokens = await _buildOAuthTokens(oauthSession, fallbackHandle: fallbackHandle, service: service); 326 + final tokens = await _buildOAuthTokens(oauthSession, fallbackHandle: fallbackHandle, oauthService: service); 327 327 await saveSession(tokens); 328 328 log.i('AuthRepository: OAuth login completed for ${tokens.handle}'); 329 329 return tokens; ··· 332 332 Future<AuthTokens> _buildOAuthTokens( 333 333 OAuthSession session, { 334 334 required String fallbackHandle, 335 - required String service, 335 + required String oauthService, 336 336 }) async { 337 337 var resolvedHandle = fallbackHandle; 338 338 String? displayName; 339 339 log.d('AuthRepository: Building OAuth tokens for DID ${session.sub}'); 340 + log.d( 341 + 'AuthRepository: OAuth session will target PDS ' 342 + '${session.atprotoPdsEndpoint ?? 'unknown'} via auth service $oauthService', 343 + ); 340 344 341 345 try { 342 - final authSession = await atp.ATProto.fromOAuthSession(session, service: service).server.getSession(); 346 + final authSession = await createAtProtoForOAuthSession(session).server.getSession(); 343 347 resolvedHandle = authSession.data.handle; 344 348 } catch (e, s) { 345 349 log.w( ··· 350 354 } 351 355 352 356 try { 353 - final profile = await Bluesky.fromOAuthSession(session, service: service).actor.getProfile(actor: session.sub); 357 + final profile = await createBlueskyForOAuthSession(session).actor.getProfile(actor: session.sub); 354 358 displayName = profile.data.displayName; 355 359 } catch (e, s) { 356 360 log.w('AuthRepository: Failed to fetch display name, continuing without it', error: e, stackTrace: s); ··· 363 367 did: session.sub, 364 368 handle: resolvedHandle, 365 369 displayName: displayName, 366 - service: service, 370 + service: oauthService, 367 371 dpopNonce: session.$dPoPNonce, 368 372 dpopPublicKey: session.$publicKey, 369 373 dpopPrivateKey: session.$privateKey,
+44 -2
lib/features/profile/data/profile_repository.dart
··· 1 1 import 'dart:convert'; 2 2 3 + import 'package:atproto_core/atproto_core.dart' as atp_core; 3 4 import 'package:bluesky/app_bsky_actor_defs.dart'; 5 + import 'package:bluesky/bluesky.dart'; 4 6 import 'package:lazurite/core/database/app_database.dart'; 7 + import 'package:lazurite/core/logging/app_logger.dart'; 5 8 import 'package:lazurite/features/auth/data/models/auth_models.dart'; 6 9 7 10 class ProfileRepository { ··· 13 16 final dynamic _bluesky; 14 17 15 18 Future<ProfileViewDetailed> getProfile(String actor) async { 19 + log.d('ProfileRepository: Loading profile for $actor via ${_describeClientContext()}'); 20 + 16 21 try { 17 22 final response = await _bluesky.actor.getProfile(actor: actor); 18 23 final profile = response.data; 24 + log.i('ProfileRepository: Loaded profile ${profile.did} (${profile.handle})'); 19 25 20 26 await _database.cacheProfile(did: profile.did, handle: profile.handle, payload: jsonEncode(profile.toJson())); 27 + log.d('ProfileRepository: Cached profile ${profile.did} (${profile.handle})'); 21 28 22 29 return profile; 23 - } catch (error) { 30 + } catch (error, stackTrace) { 31 + log.e('ProfileRepository: Failed to load profile for $actor', error: error, stackTrace: stackTrace); 24 32 final cachedProfile = await _getCachedProfile(actor); 25 33 if (cachedProfile != null) { 34 + log.w('ProfileRepository: Using cached profile for $actor after request failure'); 26 35 return cachedProfile; 27 36 } 28 37 ··· 31 40 } 32 41 33 42 Future<List<ProfileView>> getProfiles(List<String> actors) async { 43 + log.d('ProfileRepository: Loading ${actors.length} profiles via ${_describeClientContext()}'); 34 44 final response = await _bluesky.actor.getProfiles(actors: actors); 45 + log.i('ProfileRepository: Loaded ${response.data.profiles.length} profiles'); 35 46 return response.data.profiles; 36 47 } 37 48 38 49 Future<ProfileViewDetailed?> getCurrentUserProfile(AuthTokens tokens) async { 50 + log.d('ProfileRepository: Loading current user profile for ${tokens.did} via ${_describeClientContext()}'); 51 + 39 52 try { 40 53 final response = await _bluesky.actor.getProfile(actor: tokens.did); 54 + log.i('ProfileRepository: Loaded current user profile ${response.data.did} (${response.data.handle})'); 41 55 return response.data; 42 - } catch (error) { 56 + } catch (error, stackTrace) { 57 + log.e( 58 + 'ProfileRepository: Failed to load current user profile for ${tokens.did}', 59 + error: error, 60 + stackTrace: stackTrace, 61 + ); 43 62 return null; 44 63 } 45 64 } ··· 55 74 )..where((profile) => profile.handle.equals(actor))).getSingleOrNull(); 56 75 57 76 if (cachedProfile == null) { 77 + log.d('ProfileRepository: No cached profile found for $actor'); 58 78 return null; 59 79 } 60 80 81 + log.d('ProfileRepository: Found cached profile for $actor'); 61 82 return ProfileViewDetailed.fromJson(jsonDecode(cachedProfile.payload) as Map<String, dynamic>); 83 + } 84 + 85 + String _describeClientContext() { 86 + final bluesky = _bluesky; 87 + if (bluesky is! Bluesky) { 88 + return 'unknown client'; 89 + } 90 + 91 + final oauthSession = bluesky.oAuthSession; 92 + final session = bluesky.session; 93 + final configuredService = bluesky.service; 94 + 95 + if (oauthSession != null) { 96 + return 'oauth service=$configuredService pds=${oauthSession.atprotoPdsEndpoint ?? 'unknown'}'; 97 + } 98 + 99 + if (session != null) { 100 + return 'session service=$configuredService pds=${session.atprotoPdsEndpoint ?? 'unknown'}'; 101 + } 102 + 103 + return 'anonymous service=$configuredService'; 62 104 } 63 105 }
+25 -30
lib/features/profile/presentation/profile_screen.dart
··· 19 19 } 20 20 21 21 class _ProfileScreenState extends State<ProfileScreen> with SingleTickerProviderStateMixin { 22 + static const double _headerExpandedHeight = 120; 22 23 static const _tabs = [ 23 24 (label: 'Posts', filter: FeedFilter.postsNoReplies), 24 25 (label: 'Replies', filter: FeedFilter.postsAndAuthorThreads), ··· 89 90 headerSliverBuilder: (context, innerBoxIsScrolled) { 90 91 return [ 91 92 SliverAppBar( 92 - expandedHeight: 220, 93 + expandedHeight: _headerExpandedHeight, 94 + floating: true, 93 95 pinned: true, 96 + snap: true, 94 97 stretch: true, 95 - flexibleSpace: FlexibleSpaceBar( 96 - title: Text(profile?.displayName ?? profile?.handle ?? 'Profile'), 97 - background: _buildBanner(profile), 98 - ), 98 + title: innerBoxIsScrolled ? Text(profile?.displayName ?? profile?.handle ?? 'Profile') : null, 99 + flexibleSpace: FlexibleSpaceBar(background: _buildBanner(context, profile)), 99 100 leading: IconButton( 100 101 icon: const Icon(Icons.arrow_back), 101 102 onPressed: () => context.canPop() ? context.pop() : context.go('/'), ··· 141 142 ); 142 143 } 143 144 144 - Widget _buildBanner(ProfileViewDetailed? profile) { 145 + Widget _buildBanner(BuildContext context, ProfileViewDetailed? profile) { 146 + final fallback = DecoratedBox( 147 + decoration: BoxDecoration( 148 + gradient: LinearGradient( 149 + colors: [ 150 + Theme.of(context).colorScheme.surfaceContainerHighest, 151 + Theme.of(context).colorScheme.surfaceContainer, 152 + ], 153 + begin: Alignment.topLeft, 154 + end: Alignment.bottomRight, 155 + ), 156 + ), 157 + ); 158 + 145 159 if (profile?.banner == null) { 146 - return DecoratedBox( 147 - decoration: BoxDecoration( 148 - gradient: LinearGradient( 149 - colors: [Colors.blueGrey.shade700, Colors.blueGrey.shade400], 150 - begin: Alignment.topLeft, 151 - end: Alignment.bottomRight, 152 - ), 153 - ), 154 - ); 160 + return fallback; 155 161 } 156 162 157 - return Image.network( 158 - profile!.banner!, 159 - fit: BoxFit.cover, 160 - errorBuilder: (_, _, _) => DecoratedBox( 161 - decoration: BoxDecoration( 162 - gradient: LinearGradient( 163 - colors: [Colors.blueGrey.shade700, Colors.blueGrey.shade400], 164 - begin: Alignment.topLeft, 165 - end: Alignment.bottomRight, 166 - ), 167 - ), 168 - ), 169 - ); 163 + return Image.network(profile!.banner!, fit: BoxFit.cover, errorBuilder: (_, _, _) => fallback); 170 164 } 171 165 172 166 Widget _buildProfileError(BuildContext context, String? errorMessage) { ··· 204 198 ]; 205 199 206 200 return Padding( 207 - padding: const EdgeInsets.fromLTRB(16, 0, 16, 20), 201 + padding: const EdgeInsets.fromLTRB(16, 16, 16, 20), 208 202 child: Column( 209 203 crossAxisAlignment: CrossAxisAlignment.start, 210 204 children: [ 211 - Transform.translate(offset: const Offset(0, -36), child: _buildAvatar(profile)), 205 + _buildAvatar(profile), 206 + const SizedBox(height: 16), 212 207 Text( 213 208 profile.displayName ?? profile.handle, 214 209 style: Theme.of(context).textTheme.headlineSmall?.copyWith(fontWeight: FontWeight.w700),
+3 -31
lib/main.dart
··· 1 - import 'package:atproto_core/atproto_core.dart' as atp_core; 2 1 import 'package:bluesky/bluesky.dart'; 3 2 import 'package:flutter/material.dart'; 4 3 import 'package:flutter_bloc/flutter_bloc.dart'; 5 4 import 'package:lazurite/core/database/app_database.dart'; 6 5 import 'package:lazurite/core/logging/app_logger.dart'; 6 + import 'package:lazurite/core/network/xrpc_client_factory.dart'; 7 7 import 'package:lazurite/core/logging/logging_bloc_observer.dart'; 8 8 import 'package:lazurite/core/logging/logging_navigator_observer.dart'; 9 9 import 'package:lazurite/core/router/app_router.dart'; ··· 51 51 static final _navigatorObserver = LoggingNavigatorObserver(); 52 52 53 53 Bluesky? _createBluesky(AuthState state) { 54 - if (!state.isAuthenticated || state.tokens == null) { 55 - return null; 56 - } 57 - 58 - final tokens = state.tokens!; 59 - final service = tokens.service ?? 'bsky.social'; 60 - 61 - if (tokens.usesOAuth) { 62 - if (tokens.dpopPublicKey == null || tokens.dpopPrivateKey == null || tokens.refreshToken == null) { 63 - return null; 64 - } 65 - 66 - final oauthSession = atp_core.restoreOAuthSession( 67 - accessToken: tokens.accessToken, 68 - refreshToken: tokens.refreshToken!, 69 - dPoPNonce: tokens.dpopNonce, 70 - publicKey: tokens.dpopPublicKey!, 71 - privateKey: tokens.dpopPrivateKey!, 72 - ); 73 - return Bluesky.fromOAuthSession(oauthSession, service: service); 74 - } 75 - 76 - if (tokens.refreshToken == null) { 54 + if (!state.isAuthenticated) { 77 55 return null; 78 56 } 79 57 80 - final session = atp_core.Session( 81 - did: tokens.did, 82 - handle: tokens.handle, 83 - accessJwt: tokens.accessToken, 84 - refreshJwt: tokens.refreshToken!, 85 - ); 86 - return Bluesky.fromSession(session, service: service); 58 + return createBlueskyClient(state.tokens); 87 59 } 88 60 89 61 @override
+93
test/core/network/xrpc_client_factory_test.dart
··· 1 + import 'dart:convert'; 2 + 3 + import 'package:atproto_core/atproto_core.dart' as atp_core; 4 + import 'package:flutter_test/flutter_test.dart'; 5 + import 'package:lazurite/core/network/xrpc_client_factory.dart'; 6 + import 'package:lazurite/features/auth/data/models/auth_models.dart'; 7 + 8 + void main() { 9 + group('xrpc_client_factory', () { 10 + test('creates an OAuth Bluesky client that targets the token PDS', () { 11 + const pdsHost = 'porcini.us-east.host.bsky.network'; 12 + final tokens = AuthTokens( 13 + accessToken: _buildJwt( 14 + aud: pdsHost, 15 + sub: 'did:plc:alice', 16 + clientId: 'https://client.example/metadata.json', 17 + iss: 'https://bsky.social', 18 + ), 19 + refreshToken: 'refresh-token', 20 + did: 'did:plc:alice', 21 + handle: 'alice.bsky.social', 22 + service: 'bsky.social', 23 + dpopPublicKey: 'public-key', 24 + dpopPrivateKey: 'private-key', 25 + authMethod: AuthMethod.oauth, 26 + ); 27 + 28 + final client = createBlueskyClient(tokens); 29 + 30 + expect(client, isNotNull); 31 + expect(client!.service, pdsHost); 32 + expect(client.oAuthSession, isNotNull); 33 + expect(client.oAuthSession!.atprotoPdsEndpoint, pdsHost); 34 + }); 35 + 36 + test('creates an app-password Bluesky client that targets the stored service', () { 37 + const pdsHost = 'bsky.social'; 38 + const tokens = AuthTokens( 39 + accessToken: 'access-token', 40 + refreshToken: 'refresh-token', 41 + did: 'did:plc:alice', 42 + handle: 'alice.bsky.social', 43 + service: pdsHost, 44 + ); 45 + 46 + final client = createBlueskyClient(tokens); 47 + 48 + expect(client, isNotNull); 49 + expect(client!.service, pdsHost); 50 + expect(client.session, isNotNull); 51 + }); 52 + 53 + test('creates an OAuth ATProto client that targets the token PDS', () { 54 + const pdsHost = 'porcini.us-east.host.bsky.network'; 55 + final oauthSession = atp_core.restoreOAuthSession( 56 + accessToken: _buildJwt( 57 + aud: pdsHost, 58 + sub: 'did:plc:alice', 59 + clientId: 'https://client.example/metadata.json', 60 + iss: 'https://bsky.social', 61 + ), 62 + refreshToken: 'refresh-token', 63 + publicKey: 'public-key', 64 + privateKey: 'private-key', 65 + ); 66 + 67 + final client = createAtProtoForOAuthSession(oauthSession); 68 + 69 + expect(client.service, pdsHost); 70 + expect(client.oAuthSession, isNotNull); 71 + expect(client.oAuthSession!.atprotoPdsEndpoint, pdsHost); 72 + }); 73 + }); 74 + } 75 + 76 + String _buildJwt({required String aud, required String sub, required String clientId, required String iss}) { 77 + final header = _base64UrlEncode({'alg': 'none', 'typ': 'JWT'}); 78 + final payload = _base64UrlEncode({ 79 + 'aud': aud, 80 + 'sub': sub, 81 + 'client_id': clientId, 82 + 'scope': 'atproto transition:generic', 83 + 'iss': iss, 84 + 'exp': DateTime.now().toUtc().add(const Duration(hours: 1)).millisecondsSinceEpoch ~/ 1000, 85 + 'iat': DateTime.now().toUtc().millisecondsSinceEpoch ~/ 1000, 86 + }); 87 + 88 + return '$header.$payload.signature'; 89 + } 90 + 91 + String _base64UrlEncode(Map<String, Object> value) { 92 + return base64Url.encode(utf8.encode(jsonEncode(value))).replaceAll('=', ''); 93 + }
+107
test/features/profile/data/profile_repository_test.dart
··· 1 + import 'dart:convert'; 2 + 3 + import 'package:drift/native.dart'; 4 + import 'package:bluesky/app_bsky_actor_defs.dart'; 5 + import 'package:flutter_test/flutter_test.dart'; 6 + import 'package:lazurite/core/database/app_database.dart'; 7 + import 'package:lazurite/features/profile/data/profile_repository.dart'; 8 + 9 + void main() { 10 + late AppDatabase database; 11 + 12 + setUp(() async { 13 + database = AppDatabase(executor: NativeDatabase.memory()); 14 + }); 15 + 16 + tearDown(() async { 17 + await database.close(); 18 + }); 19 + 20 + group('ProfileRepository', () { 21 + test('loads and caches a profile after a successful xrpc response', () async { 22 + final profile = _buildProfile(); 23 + final repository = ProfileRepository( 24 + database: database, 25 + bluesky: _FakeBlueskyClient(actor: _FakeActorService(onGetProfile: (_) async => _FakeResponse(profile))), 26 + ); 27 + 28 + final result = await repository.getProfile(profile.did); 29 + 30 + expect(result.did, profile.did); 31 + expect(result.handle, profile.handle); 32 + 33 + final cached = await database.select(database.cachedProfiles).getSingle(); 34 + expect(cached.did, profile.did); 35 + expect(cached.handle, profile.handle); 36 + }); 37 + 38 + test('falls back to the cached profile when the xrpc request fails', () async { 39 + final profile = _buildProfile(); 40 + await database.cacheProfile(did: profile.did, handle: profile.handle, payload: jsonEncode(profile.toJson())); 41 + 42 + final repository = ProfileRepository( 43 + database: database, 44 + bluesky: _FakeBlueskyClient( 45 + actor: _FakeActorService(onGetProfile: (_) async => throw Exception('request failed')), 46 + ), 47 + ); 48 + 49 + final result = await repository.getProfile(profile.handle); 50 + 51 + expect(result.did, profile.did); 52 + expect(result.handle, profile.handle); 53 + expect(result.displayName, profile.displayName); 54 + }); 55 + }); 56 + } 57 + 58 + ProfileViewDetailed _buildProfile() { 59 + return ProfileViewDetailed( 60 + did: 'did:plc:alice', 61 + handle: 'alice.bsky.social', 62 + displayName: 'Alice Example', 63 + description: 'Profile for repository tests', 64 + followersCount: 10, 65 + followsCount: 20, 66 + postsCount: 30, 67 + createdAt: DateTime.utc(2026, 3, 16), 68 + ); 69 + } 70 + 71 + class _FakeBlueskyClient { 72 + _FakeBlueskyClient({required this.actor}); 73 + 74 + final _FakeActorService actor; 75 + } 76 + 77 + class _FakeActorService { 78 + _FakeActorService({required this.onGetProfile}); 79 + 80 + final Future<_FakeResponse<ProfileViewDetailed>> Function(String actor) onGetProfile; 81 + 82 + Future<_FakeResponse<ProfileViewDetailed>> getProfile({required String actor}) { 83 + return onGetProfile(actor); 84 + } 85 + 86 + Future<_FakeProfilesResponse> getProfiles({required List<String> actors}) async { 87 + return _FakeProfilesResponse(const _FakeProfilesData([])); 88 + } 89 + } 90 + 91 + class _FakeResponse<T> { 92 + _FakeResponse(this.data); 93 + 94 + final T data; 95 + } 96 + 97 + class _FakeProfilesResponse { 98 + _FakeProfilesResponse(this.data); 99 + 100 + final _FakeProfilesData data; 101 + } 102 + 103 + class _FakeProfilesData { 104 + const _FakeProfilesData(this.profiles); 105 + 106 + final List<ProfileView> profiles; 107 + }
+1 -1
test/features/profile/presentation/profile_screen_test.dart
··· 85 85 () => feedBloc.add(const FeedLoadRequested(actor: 'did:plc:me', filter: FeedFilter.postsNoReplies)), 86 86 ).called(1); 87 87 88 - expect(find.text('River Tam'), findsAtLeastNWidgets(1)); 88 + expect(find.text('River Tam'), findsOneWidget); 89 89 expect(find.text('@me.bsky.social'), findsOneWidget); 90 90 expect(find.text('Signal and signal boost.'), findsOneWidget); 91 91 expect(find.text('she/her'), findsOneWidget);