From 47f033146f37eff9073896e6c9d2ce8d9ce1f79d Mon Sep 17 00:00:00 2001 From: Owais Jamil Date: Mon, 16 Mar 2026 19:22:45 -0500 Subject: [PATCH] feat: centralize xrpc client creation * use user pds for tokens --- lib/core/network/xrpc_client_factory.dart | 52 +++++++++ lib/features/auth/data/auth_repository.dart | 18 +-- .../profile/data/profile_repository.dart | 46 +++++++- .../profile/presentation/profile_screen.dart | 55 ++++----- lib/main.dart | 34 +----- .../network/xrpc_client_factory_test.dart | 93 +++++++++++++++ .../profile/data/profile_repository_test.dart | 107 ++++++++++++++++++ .../presentation/profile_screen_test.dart | 2 +- 8 files changed, 336 insertions(+), 71 deletions(-) create mode 100644 lib/core/network/xrpc_client_factory.dart create mode 100644 test/core/network/xrpc_client_factory_test.dart create mode 100644 test/features/profile/data/profile_repository_test.dart diff --git a/lib/core/network/xrpc_client_factory.dart b/lib/core/network/xrpc_client_factory.dart new file mode 100644 index 0000000..b6cbbfe --- /dev/null +++ b/lib/core/network/xrpc_client_factory.dart @@ -0,0 +1,52 @@ +import 'package:atproto/atproto.dart' as atp; +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:lazurite/features/auth/data/models/auth_models.dart'; + +/// Creates a Bluesky client from authentication tokens. +/// +/// OAuth tokens are scoped to the user's PDS. Let the SDK derive that +/// endpoint from the token instead of forcing the OAuth auth server host. +Bluesky? createBlueskyClient(AuthTokens? tokens) { + if (tokens == null) { + return null; + } + + if (tokens.usesOAuth) { + if (tokens.dpopPublicKey == null || tokens.dpopPrivateKey == null || tokens.refreshToken == null) { + return null; + } + + final oauthSession = atp_core.restoreOAuthSession( + accessToken: tokens.accessToken, + refreshToken: tokens.refreshToken!, + dPoPNonce: tokens.dpopNonce, + publicKey: tokens.dpopPublicKey!, + privateKey: tokens.dpopPrivateKey!, + ); + + return Bluesky.fromOAuthSession(oauthSession); + } + + if (tokens.refreshToken == null) { + return null; + } + + final session = atp_core.Session( + did: tokens.did, + handle: tokens.handle, + accessJwt: tokens.accessToken, + refreshJwt: tokens.refreshToken!, + ); + + return Bluesky.fromSession(session, service: tokens.service); +} + +atp.ATProto createAtProtoForOAuthSession(atp_oauth.OAuthSession session) { + return atp.ATProto.fromOAuthSession(session); +} + +Bluesky createBlueskyForOAuthSession(atp_oauth.OAuthSession session) { + return Bluesky.fromOAuthSession(session); +} diff --git a/lib/features/auth/data/auth_repository.dart b/lib/features/auth/data/auth_repository.dart index 35ac91e..5577e13 100644 --- a/lib/features/auth/data/auth_repository.dart +++ b/lib/features/auth/data/auth_repository.dart @@ -5,11 +5,11 @@ import 'dart:io'; import 'package:atproto/atproto.dart' as atp; import 'package:atproto_core/atproto_core.dart' as atcore; import 'package:atproto_oauth/atproto_oauth.dart'; -import 'package:bluesky/bluesky.dart'; import 'package:drift/drift.dart'; import 'package:http/http.dart' as http; import 'package:lazurite/core/database/app_database.dart'; import 'package:lazurite/core/logging/app_logger.dart'; +import 'package:lazurite/core/network/xrpc_client_factory.dart'; import 'package:lazurite/features/auth/data/models/auth_models.dart'; import 'package:url_launcher/url_launcher.dart'; @@ -189,7 +189,7 @@ class AuthRepository { final refreshedTokens = await _buildOAuthTokens( refreshedSession, fallbackHandle: currentSession.handle, - service: currentSession.service ?? _fallbackService, + oauthService: currentSession.service ?? _fallbackService, ); await saveSession(refreshedTokens); @@ -323,7 +323,7 @@ class AuthRepository { ); final oauthSession = await oauthClient.callback(callbackUrl, oauthContext); log.i('AuthRepository: OAuth token exchange succeeded for DID ${oauthSession.sub}'); - final tokens = await _buildOAuthTokens(oauthSession, fallbackHandle: fallbackHandle, service: service); + final tokens = await _buildOAuthTokens(oauthSession, fallbackHandle: fallbackHandle, oauthService: service); await saveSession(tokens); log.i('AuthRepository: OAuth login completed for ${tokens.handle}'); return tokens; @@ -332,14 +332,18 @@ class AuthRepository { Future _buildOAuthTokens( OAuthSession session, { required String fallbackHandle, - required String service, + required String oauthService, }) async { var resolvedHandle = fallbackHandle; String? displayName; log.d('AuthRepository: Building OAuth tokens for DID ${session.sub}'); + log.d( + 'AuthRepository: OAuth session will target PDS ' + '${session.atprotoPdsEndpoint ?? 'unknown'} via auth service $oauthService', + ); try { - final authSession = await atp.ATProto.fromOAuthSession(session, service: service).server.getSession(); + final authSession = await createAtProtoForOAuthSession(session).server.getSession(); resolvedHandle = authSession.data.handle; } catch (e, s) { log.w( @@ -350,7 +354,7 @@ class AuthRepository { } try { - final profile = await Bluesky.fromOAuthSession(session, service: service).actor.getProfile(actor: session.sub); + final profile = await createBlueskyForOAuthSession(session).actor.getProfile(actor: session.sub); displayName = profile.data.displayName; } catch (e, s) { log.w('AuthRepository: Failed to fetch display name, continuing without it', error: e, stackTrace: s); @@ -363,7 +367,7 @@ class AuthRepository { did: session.sub, handle: resolvedHandle, displayName: displayName, - service: service, + service: oauthService, dpopNonce: session.$dPoPNonce, dpopPublicKey: session.$publicKey, dpopPrivateKey: session.$privateKey, diff --git a/lib/features/profile/data/profile_repository.dart b/lib/features/profile/data/profile_repository.dart index 516fa92..de78883 100644 --- a/lib/features/profile/data/profile_repository.dart +++ b/lib/features/profile/data/profile_repository.dart @@ -1,7 +1,10 @@ import 'dart:convert'; +import 'package:atproto_core/atproto_core.dart' as atp_core; import 'package:bluesky/app_bsky_actor_defs.dart'; +import 'package:bluesky/bluesky.dart'; import 'package:lazurite/core/database/app_database.dart'; +import 'package:lazurite/core/logging/app_logger.dart'; import 'package:lazurite/features/auth/data/models/auth_models.dart'; class ProfileRepository { @@ -13,16 +16,22 @@ class ProfileRepository { final dynamic _bluesky; Future getProfile(String actor) async { + log.d('ProfileRepository: Loading profile for $actor via ${_describeClientContext()}'); + try { final response = await _bluesky.actor.getProfile(actor: actor); final profile = response.data; + log.i('ProfileRepository: Loaded profile ${profile.did} (${profile.handle})'); await _database.cacheProfile(did: profile.did, handle: profile.handle, payload: jsonEncode(profile.toJson())); + log.d('ProfileRepository: Cached profile ${profile.did} (${profile.handle})'); return profile; - } catch (error) { + } catch (error, stackTrace) { + log.e('ProfileRepository: Failed to load profile for $actor', error: error, stackTrace: stackTrace); final cachedProfile = await _getCachedProfile(actor); if (cachedProfile != null) { + log.w('ProfileRepository: Using cached profile for $actor after request failure'); return cachedProfile; } @@ -31,15 +40,25 @@ class ProfileRepository { } Future> getProfiles(List actors) async { + log.d('ProfileRepository: Loading ${actors.length} profiles via ${_describeClientContext()}'); final response = await _bluesky.actor.getProfiles(actors: actors); + log.i('ProfileRepository: Loaded ${response.data.profiles.length} profiles'); return response.data.profiles; } Future getCurrentUserProfile(AuthTokens tokens) async { + log.d('ProfileRepository: Loading current user profile for ${tokens.did} via ${_describeClientContext()}'); + try { final response = await _bluesky.actor.getProfile(actor: tokens.did); + log.i('ProfileRepository: Loaded current user profile ${response.data.did} (${response.data.handle})'); return response.data; - } catch (error) { + } catch (error, stackTrace) { + log.e( + 'ProfileRepository: Failed to load current user profile for ${tokens.did}', + error: error, + stackTrace: stackTrace, + ); return null; } } @@ -55,9 +74,32 @@ class ProfileRepository { )..where((profile) => profile.handle.equals(actor))).getSingleOrNull(); if (cachedProfile == null) { + log.d('ProfileRepository: No cached profile found for $actor'); return null; } + log.d('ProfileRepository: Found cached profile for $actor'); return ProfileViewDetailed.fromJson(jsonDecode(cachedProfile.payload) as Map); } + + String _describeClientContext() { + final bluesky = _bluesky; + if (bluesky is! Bluesky) { + return 'unknown client'; + } + + final oauthSession = bluesky.oAuthSession; + final session = bluesky.session; + final configuredService = bluesky.service; + + if (oauthSession != null) { + return 'oauth service=$configuredService pds=${oauthSession.atprotoPdsEndpoint ?? 'unknown'}'; + } + + if (session != null) { + return 'session service=$configuredService pds=${session.atprotoPdsEndpoint ?? 'unknown'}'; + } + + return 'anonymous service=$configuredService'; + } } diff --git a/lib/features/profile/presentation/profile_screen.dart b/lib/features/profile/presentation/profile_screen.dart index e747d31..df67cb8 100644 --- a/lib/features/profile/presentation/profile_screen.dart +++ b/lib/features/profile/presentation/profile_screen.dart @@ -19,6 +19,7 @@ class ProfileScreen extends StatefulWidget { } class _ProfileScreenState extends State with SingleTickerProviderStateMixin { + static const double _headerExpandedHeight = 120; static const _tabs = [ (label: 'Posts', filter: FeedFilter.postsNoReplies), (label: 'Replies', filter: FeedFilter.postsAndAuthorThreads), @@ -89,13 +90,13 @@ class _ProfileScreenState extends State with SingleTickerProvider headerSliverBuilder: (context, innerBoxIsScrolled) { return [ SliverAppBar( - expandedHeight: 220, + expandedHeight: _headerExpandedHeight, + floating: true, pinned: true, + snap: true, stretch: true, - flexibleSpace: FlexibleSpaceBar( - title: Text(profile?.displayName ?? profile?.handle ?? 'Profile'), - background: _buildBanner(profile), - ), + title: innerBoxIsScrolled ? Text(profile?.displayName ?? profile?.handle ?? 'Profile') : null, + flexibleSpace: FlexibleSpaceBar(background: _buildBanner(context, profile)), leading: IconButton( icon: const Icon(Icons.arrow_back), onPressed: () => context.canPop() ? context.pop() : context.go('/'), @@ -141,32 +142,25 @@ class _ProfileScreenState extends State with SingleTickerProvider ); } - Widget _buildBanner(ProfileViewDetailed? profile) { - if (profile?.banner == null) { - return DecoratedBox( - decoration: BoxDecoration( - gradient: LinearGradient( - colors: [Colors.blueGrey.shade700, Colors.blueGrey.shade400], - begin: Alignment.topLeft, - end: Alignment.bottomRight, - ), - ), - ); - } - - return Image.network( - profile!.banner!, - fit: BoxFit.cover, - errorBuilder: (_, _, _) => DecoratedBox( - decoration: BoxDecoration( - gradient: LinearGradient( - colors: [Colors.blueGrey.shade700, Colors.blueGrey.shade400], - begin: Alignment.topLeft, - end: Alignment.bottomRight, - ), + Widget _buildBanner(BuildContext context, ProfileViewDetailed? profile) { + final fallback = DecoratedBox( + decoration: BoxDecoration( + gradient: LinearGradient( + colors: [ + Theme.of(context).colorScheme.surfaceContainerHighest, + Theme.of(context).colorScheme.surfaceContainer, + ], + begin: Alignment.topLeft, + end: Alignment.bottomRight, ), ), ); + + if (profile?.banner == null) { + return fallback; + } + + return Image.network(profile!.banner!, fit: BoxFit.cover, errorBuilder: (_, _, _) => fallback); } Widget _buildProfileError(BuildContext context, String? errorMessage) { @@ -204,11 +198,12 @@ class _ProfileScreenState extends State with SingleTickerProvider ]; return Padding( - padding: const EdgeInsets.fromLTRB(16, 0, 16, 20), + padding: const EdgeInsets.fromLTRB(16, 16, 16, 20), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Transform.translate(offset: const Offset(0, -36), child: _buildAvatar(profile)), + _buildAvatar(profile), + const SizedBox(height: 16), Text( profile.displayName ?? profile.handle, style: Theme.of(context).textTheme.headlineSmall?.copyWith(fontWeight: FontWeight.w700), diff --git a/lib/main.dart b/lib/main.dart index fed9072..d1f6ef0 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -1,9 +1,9 @@ -import 'package:atproto_core/atproto_core.dart' as atp_core; import 'package:bluesky/bluesky.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:lazurite/core/database/app_database.dart'; import 'package:lazurite/core/logging/app_logger.dart'; +import 'package:lazurite/core/network/xrpc_client_factory.dart'; import 'package:lazurite/core/logging/logging_bloc_observer.dart'; import 'package:lazurite/core/logging/logging_navigator_observer.dart'; import 'package:lazurite/core/router/app_router.dart'; @@ -51,39 +51,11 @@ class LazuriteApp extends StatelessWidget { static final _navigatorObserver = LoggingNavigatorObserver(); Bluesky? _createBluesky(AuthState state) { - if (!state.isAuthenticated || state.tokens == null) { + if (!state.isAuthenticated) { return null; } - final tokens = state.tokens!; - final service = tokens.service ?? 'bsky.social'; - - if (tokens.usesOAuth) { - if (tokens.dpopPublicKey == null || tokens.dpopPrivateKey == null || tokens.refreshToken == null) { - return null; - } - - final oauthSession = atp_core.restoreOAuthSession( - accessToken: tokens.accessToken, - refreshToken: tokens.refreshToken!, - dPoPNonce: tokens.dpopNonce, - publicKey: tokens.dpopPublicKey!, - privateKey: tokens.dpopPrivateKey!, - ); - return Bluesky.fromOAuthSession(oauthSession, service: service); - } - - if (tokens.refreshToken == null) { - return null; - } - - final session = atp_core.Session( - did: tokens.did, - handle: tokens.handle, - accessJwt: tokens.accessToken, - refreshJwt: tokens.refreshToken!, - ); - return Bluesky.fromSession(session, service: service); + return createBlueskyClient(state.tokens); } @override diff --git a/test/core/network/xrpc_client_factory_test.dart b/test/core/network/xrpc_client_factory_test.dart new file mode 100644 index 0000000..ece6540 --- /dev/null +++ b/test/core/network/xrpc_client_factory_test.dart @@ -0,0 +1,93 @@ +import 'dart:convert'; + +import 'package:atproto_core/atproto_core.dart' as atp_core; +import 'package:flutter_test/flutter_test.dart'; +import 'package:lazurite/core/network/xrpc_client_factory.dart'; +import 'package:lazurite/features/auth/data/models/auth_models.dart'; + +void main() { + group('xrpc_client_factory', () { + test('creates an OAuth Bluesky client that targets the token PDS', () { + const pdsHost = 'porcini.us-east.host.bsky.network'; + final tokens = AuthTokens( + accessToken: _buildJwt( + aud: pdsHost, + sub: 'did:plc:alice', + clientId: 'https://client.example/metadata.json', + iss: 'https://bsky.social', + ), + refreshToken: 'refresh-token', + did: 'did:plc:alice', + handle: 'alice.bsky.social', + service: 'bsky.social', + dpopPublicKey: 'public-key', + dpopPrivateKey: 'private-key', + authMethod: AuthMethod.oauth, + ); + + final client = createBlueskyClient(tokens); + + expect(client, isNotNull); + expect(client!.service, pdsHost); + expect(client.oAuthSession, isNotNull); + expect(client.oAuthSession!.atprotoPdsEndpoint, pdsHost); + }); + + test('creates an app-password Bluesky client that targets the stored service', () { + const pdsHost = 'bsky.social'; + const tokens = AuthTokens( + accessToken: 'access-token', + refreshToken: 'refresh-token', + did: 'did:plc:alice', + handle: 'alice.bsky.social', + service: pdsHost, + ); + + final client = createBlueskyClient(tokens); + + expect(client, isNotNull); + expect(client!.service, pdsHost); + expect(client.session, isNotNull); + }); + + test('creates an OAuth ATProto client that targets the token PDS', () { + const pdsHost = 'porcini.us-east.host.bsky.network'; + final oauthSession = atp_core.restoreOAuthSession( + accessToken: _buildJwt( + aud: pdsHost, + sub: 'did:plc:alice', + clientId: 'https://client.example/metadata.json', + iss: 'https://bsky.social', + ), + refreshToken: 'refresh-token', + publicKey: 'public-key', + privateKey: 'private-key', + ); + + final client = createAtProtoForOAuthSession(oauthSession); + + expect(client.service, pdsHost); + expect(client.oAuthSession, isNotNull); + expect(client.oAuthSession!.atprotoPdsEndpoint, pdsHost); + }); + }); +} + +String _buildJwt({required String aud, required String sub, required String clientId, required String iss}) { + final header = _base64UrlEncode({'alg': 'none', 'typ': 'JWT'}); + final payload = _base64UrlEncode({ + 'aud': aud, + 'sub': sub, + 'client_id': clientId, + 'scope': 'atproto transition:generic', + 'iss': iss, + 'exp': DateTime.now().toUtc().add(const Duration(hours: 1)).millisecondsSinceEpoch ~/ 1000, + 'iat': DateTime.now().toUtc().millisecondsSinceEpoch ~/ 1000, + }); + + return '$header.$payload.signature'; +} + +String _base64UrlEncode(Map value) { + return base64Url.encode(utf8.encode(jsonEncode(value))).replaceAll('=', ''); +} diff --git a/test/features/profile/data/profile_repository_test.dart b/test/features/profile/data/profile_repository_test.dart new file mode 100644 index 0000000..50e5966 --- /dev/null +++ b/test/features/profile/data/profile_repository_test.dart @@ -0,0 +1,107 @@ +import 'dart:convert'; + +import 'package:drift/native.dart'; +import 'package:bluesky/app_bsky_actor_defs.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:lazurite/core/database/app_database.dart'; +import 'package:lazurite/features/profile/data/profile_repository.dart'; + +void main() { + late AppDatabase database; + + setUp(() async { + database = AppDatabase(executor: NativeDatabase.memory()); + }); + + tearDown(() async { + await database.close(); + }); + + group('ProfileRepository', () { + test('loads and caches a profile after a successful xrpc response', () async { + final profile = _buildProfile(); + final repository = ProfileRepository( + database: database, + bluesky: _FakeBlueskyClient(actor: _FakeActorService(onGetProfile: (_) async => _FakeResponse(profile))), + ); + + final result = await repository.getProfile(profile.did); + + expect(result.did, profile.did); + expect(result.handle, profile.handle); + + final cached = await database.select(database.cachedProfiles).getSingle(); + expect(cached.did, profile.did); + expect(cached.handle, profile.handle); + }); + + test('falls back to the cached profile when the xrpc request fails', () async { + final profile = _buildProfile(); + await database.cacheProfile(did: profile.did, handle: profile.handle, payload: jsonEncode(profile.toJson())); + + final repository = ProfileRepository( + database: database, + bluesky: _FakeBlueskyClient( + actor: _FakeActorService(onGetProfile: (_) async => throw Exception('request failed')), + ), + ); + + final result = await repository.getProfile(profile.handle); + + expect(result.did, profile.did); + expect(result.handle, profile.handle); + expect(result.displayName, profile.displayName); + }); + }); +} + +ProfileViewDetailed _buildProfile() { + return ProfileViewDetailed( + did: 'did:plc:alice', + handle: 'alice.bsky.social', + displayName: 'Alice Example', + description: 'Profile for repository tests', + followersCount: 10, + followsCount: 20, + postsCount: 30, + createdAt: DateTime.utc(2026, 3, 16), + ); +} + +class _FakeBlueskyClient { + _FakeBlueskyClient({required this.actor}); + + final _FakeActorService actor; +} + +class _FakeActorService { + _FakeActorService({required this.onGetProfile}); + + final Future<_FakeResponse> Function(String actor) onGetProfile; + + Future<_FakeResponse> getProfile({required String actor}) { + return onGetProfile(actor); + } + + Future<_FakeProfilesResponse> getProfiles({required List actors}) async { + return _FakeProfilesResponse(const _FakeProfilesData([])); + } +} + +class _FakeResponse { + _FakeResponse(this.data); + + final T data; +} + +class _FakeProfilesResponse { + _FakeProfilesResponse(this.data); + + final _FakeProfilesData data; +} + +class _FakeProfilesData { + const _FakeProfilesData(this.profiles); + + final List profiles; +} diff --git a/test/features/profile/presentation/profile_screen_test.dart b/test/features/profile/presentation/profile_screen_test.dart index d32e3ff..9bf7086 100644 --- a/test/features/profile/presentation/profile_screen_test.dart +++ b/test/features/profile/presentation/profile_screen_test.dart @@ -85,7 +85,7 @@ void main() { () => feedBloc.add(const FeedLoadRequested(actor: 'did:plc:me', filter: FeedFilter.postsNoReplies)), ).called(1); - expect(find.text('River Tam'), findsAtLeastNWidgets(1)); + expect(find.text('River Tam'), findsOneWidget); expect(find.text('@me.bsky.social'), findsOneWidget); expect(find.text('Signal and signal boost.'), findsOneWidget); expect(find.text('she/her'), findsOneWidget); -- 2.51.2