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.

fix: oauth redirect

+154 -7
+73 -3
lib/features/auth/data/auth_repository.dart
··· 15 15 import 'package:lazurite/features/auth/data/models/auth_models.dart'; 16 16 import 'package:url_launcher/url_launcher.dart'; 17 17 18 + typedef _LaunchUrlWithMode = Future<bool> Function(Uri url, LaunchMode mode); 19 + typedef _CloseInAppBrowser = Future<void> Function(); 20 + typedef _SupportsCloseForMode = Future<bool> Function(LaunchMode mode); 21 + 18 22 class AuthRepository { 19 - AuthRepository({required AppDatabase database}) : _database = database; 23 + AuthRepository({ 24 + required AppDatabase database, 25 + _LaunchUrlWithMode launchUrlWithMode = _defaultLaunchUrlWithMode, 26 + _CloseInAppBrowser closeInAppBrowser = closeInAppWebView, 27 + _SupportsCloseForMode supportsCloseForMode = supportsCloseForLaunchMode, 28 + }) : _database = database, 29 + _launchUrlWithMode = launchUrlWithMode, 30 + _closeInAppBrowser = closeInAppBrowser, 31 + _supportsCloseForMode = supportsCloseForMode; 20 32 21 33 static const String kClientId = 'https://lazurite.stormlightlabs.org/client-metadata.json'; 22 34 static const String _oauthService = 'bsky.social'; ··· 24 36 static final Uri _appReopenUri = Uri.parse('lazurite://auth-complete'); 25 37 26 38 final AppDatabase _database; 39 + final _LaunchUrlWithMode _launchUrlWithMode; 40 + final _CloseInAppBrowser _closeInAppBrowser; 41 + final _SupportsCloseForMode _supportsCloseForMode; 27 42 28 43 HttpServer? _callbackServer; 29 44 StreamSubscription<HttpRequest>? _callbackSubscription; ··· 33 48 OAuthContext? _pendingOAuthContext; 34 49 String? _pendingHandle; 35 50 String? _pendingService; 51 + LaunchMode? _oauthLaunchMode; 36 52 37 53 Future<AuthTokens?> getStoredSession() async { 38 54 final account = await _database.getActiveAccount(); ··· 140 156 await _stopCallbackServer(); 141 157 _resetPendingOAuthState(); 142 158 throw Exception('Failed to login with OAuth: $error'); 159 + } finally { 160 + await _dismissOAuthBrowserIfNeeded(); 143 161 } 144 162 } 145 163 ··· 511 529 return null; 512 530 } 513 531 532 + static Future<bool> _defaultLaunchUrlWithMode(Uri url, LaunchMode mode) { 533 + return launchUrl(url, mode: mode); 534 + } 535 + 514 536 Future<void> _launchUrl(Uri url) async { 515 - log.d('AuthRepository: Launching external URL ${_sanitizeUriForLog(url)}'); 516 - if (!await launchUrl(url, mode: LaunchMode.externalApplication)) { 537 + final launchMode = _oauthLaunchModeForPlatform(isWeb: kIsWeb, platform: defaultTargetPlatform); 538 + log.d('AuthRepository: Launching OAuth URL ${_sanitizeUriForLog(url)} with mode $launchMode'); 539 + 540 + if (!await _launchUrlWithMode(url, launchMode)) { 517 541 throw Exception('Could not launch $url'); 518 542 } 543 + 544 + _oauthLaunchMode = launchMode; 545 + } 546 + 547 + Future<void> _dismissOAuthBrowserIfNeeded() async { 548 + final launchMode = _oauthLaunchMode; 549 + _oauthLaunchMode = null; 550 + 551 + if (launchMode == null || launchMode != LaunchMode.inAppBrowserView) { 552 + return; 553 + } 554 + 555 + try { 556 + final supportsClose = await _supportsCloseForMode(launchMode); 557 + if (!supportsClose) { 558 + return; 559 + } 560 + 561 + await _closeInAppBrowser(); 562 + log.d('AuthRepository: Dismissed OAuth in-app browser'); 563 + } catch (error, stackTrace) { 564 + log.w('AuthRepository: Failed to dismiss OAuth in-app browser', error: error, stackTrace: stackTrace); 565 + } 566 + } 567 + 568 + @visibleForTesting 569 + static LaunchMode oauthLaunchModeForTest({required bool isWeb, required TargetPlatform platform}) { 570 + return _oauthLaunchModeForPlatform(isWeb: isWeb, platform: platform); 571 + } 572 + 573 + static LaunchMode _oauthLaunchModeForPlatform({required bool isWeb, required TargetPlatform platform}) { 574 + if (isWeb) { 575 + return LaunchMode.platformDefault; 576 + } 577 + 578 + return switch (platform) { 579 + TargetPlatform.android || TargetPlatform.iOS => LaunchMode.inAppBrowserView, 580 + _ => LaunchMode.externalApplication, 581 + }; 582 + } 583 + 584 + @visibleForTesting 585 + Future<void> dismissOAuthBrowserForTest(LaunchMode mode) async { 586 + _oauthLaunchMode = mode; 587 + await _dismissOAuthBrowserIfNeeded(); 519 588 } 520 589 521 590 bool _isSupportedLoopbackRedirect(Uri redirectUri) { ··· 551 620 _pendingOAuthContext = null; 552 621 _pendingHandle = null; 553 622 _pendingService = null; 623 + _oauthLaunchMode = null; 554 624 } 555 625 556 626 @visibleForTesting
+4 -4
lib/main.dart
··· 6 6 import 'package:flutter_bloc/flutter_bloc.dart'; 7 7 import 'package:go_router/go_router.dart'; 8 8 import 'package:lazurite/core/database/app_database.dart'; 9 + import 'package:lazurite/core/embedding/embedding_service.dart'; 9 10 import 'package:lazurite/core/logging/app_logger.dart'; 10 - import 'package:lazurite/core/objectbox/objectbox_store.dart'; 11 11 import 'package:lazurite/core/logging/logging_bloc_observer.dart'; 12 12 import 'package:lazurite/core/logging/logging_navigator_observer.dart'; 13 13 import 'package:lazurite/core/network/xrpc_client_factory.dart'; 14 + import 'package:lazurite/core/objectbox/objectbox_store.dart'; 14 15 import 'package:lazurite/core/router/app_router.dart'; 15 16 import 'package:lazurite/core/scheduler/post_scheduler.dart'; 16 17 import 'package:lazurite/core/theme/app_theme.dart'; ··· 22 23 import 'package:lazurite/features/devtools/cubit/dev_tools_cubit.dart'; 23 24 import 'package:lazurite/features/feed/bloc/feed_bloc.dart'; 24 25 import 'package:lazurite/features/feed/cubit/feed_preferences_cubit.dart'; 26 + import 'package:lazurite/features/feed/cubit/liked_posts_sync_cubit.dart'; 25 27 import 'package:lazurite/features/feed/cubit/post_action_cache.dart'; 26 28 import 'package:lazurite/features/feed/cubit/saved_posts_cubit.dart'; 27 29 import 'package:lazurite/features/feed/data/feed_repository.dart'; 30 + import 'package:lazurite/features/feed/data/liked_posts_repository.dart'; 28 31 import 'package:lazurite/features/feed/data/post_action_repository.dart'; 29 32 import 'package:lazurite/features/feed/data/post_thread_repository.dart'; 30 33 import 'package:lazurite/features/lists/data/list_repository.dart'; ··· 35 38 import 'package:lazurite/features/profile/bloc/profile_bloc.dart'; 36 39 import 'package:lazurite/features/profile/data/profile_action_repository.dart'; 37 40 import 'package:lazurite/features/profile/data/profile_repository.dart'; 38 - import 'package:lazurite/features/feed/cubit/liked_posts_sync_cubit.dart'; 39 41 import 'package:lazurite/features/search/bloc/search_bloc.dart'; 40 - import 'package:lazurite/core/embedding/embedding_service.dart'; 41 - import 'package:lazurite/features/feed/data/liked_posts_repository.dart'; 42 42 import 'package:lazurite/features/search/cubit/semantic_index_cubit.dart'; 43 43 import 'package:lazurite/features/search/cubit/semantic_search_cubit.dart'; 44 44 import 'package:lazurite/features/search/data/embedding_repository.dart';
+77
test/features/auth/data/auth_repository_test.dart
··· 1 + import 'package:flutter/foundation.dart'; 1 2 import 'package:flutter_test/flutter_test.dart'; 2 3 import 'package:lazurite/core/database/app_database.dart'; 3 4 import 'package:lazurite/features/auth/data/auth_repository.dart'; 4 5 import 'package:lazurite/features/auth/data/models/auth_models.dart'; 5 6 import 'package:mocktail/mocktail.dart'; 7 + import 'package:url_launcher/url_launcher.dart'; 6 8 7 9 class MockAppDatabase extends Mock implements AppDatabase {} 8 10 ··· 171 173 172 174 await authRepository.stopCallbackServerForTest(); 173 175 expect(authRepository.callbackPort, equals(0)); 176 + }); 177 + }); 178 + 179 + group('oauth browser launch mode', () { 180 + test('uses in-app browser view on mobile', () { 181 + expect( 182 + AuthRepository.oauthLaunchModeForTest(isWeb: false, platform: TargetPlatform.iOS), 183 + equals(LaunchMode.inAppBrowserView), 184 + ); 185 + expect( 186 + AuthRepository.oauthLaunchModeForTest(isWeb: false, platform: TargetPlatform.android), 187 + equals(LaunchMode.inAppBrowserView), 188 + ); 189 + }); 190 + 191 + test('uses external application on non-mobile native platforms', () { 192 + expect( 193 + AuthRepository.oauthLaunchModeForTest(isWeb: false, platform: TargetPlatform.macOS), 194 + equals(LaunchMode.externalApplication), 195 + ); 196 + expect( 197 + AuthRepository.oauthLaunchModeForTest(isWeb: false, platform: TargetPlatform.windows), 198 + equals(LaunchMode.externalApplication), 199 + ); 200 + }); 201 + 202 + test('uses platform default mode on web', () { 203 + expect( 204 + AuthRepository.oauthLaunchModeForTest(isWeb: true, platform: TargetPlatform.iOS), 205 + equals(LaunchMode.platformDefault), 206 + ); 207 + }); 208 + }); 209 + 210 + group('oauth browser dismissal', () { 211 + test('dismisses in-app browser when close is supported', () async { 212 + var closeCalls = 0; 213 + var supportChecks = 0; 214 + authRepository = AuthRepository( 215 + database: mockDatabase, 216 + launchUrlWithMode: (_, __) async => true, 217 + supportsCloseForMode: (_) async { 218 + supportChecks += 1; 219 + return true; 220 + }, 221 + closeInAppBrowser: () async { 222 + closeCalls += 1; 223 + }, 224 + ); 225 + 226 + await authRepository.dismissOAuthBrowserForTest(LaunchMode.inAppBrowserView); 227 + 228 + expect(supportChecks, equals(1)); 229 + expect(closeCalls, equals(1)); 230 + }); 231 + 232 + test('does not attempt close for non in-app browser launch modes', () async { 233 + var closeCalls = 0; 234 + var supportChecks = 0; 235 + authRepository = AuthRepository( 236 + database: mockDatabase, 237 + launchUrlWithMode: (_, __) async => true, 238 + supportsCloseForMode: (_) async { 239 + supportChecks += 1; 240 + return true; 241 + }, 242 + closeInAppBrowser: () async { 243 + closeCalls += 1; 244 + }, 245 + ); 246 + 247 + await authRepository.dismissOAuthBrowserForTest(LaunchMode.externalApplication); 248 + 249 + expect(supportChecks, equals(0)); 250 + expect(closeCalls, equals(0)); 174 251 }); 175 252 }); 176 253 });