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: url scheme for redirects

+103 -25
+8
android/app/src/main/AndroidManifest.xml
··· 28 28 <action android:name="android.intent.action.MAIN"/> 29 29 <category android:name="android.intent.category.LAUNCHER"/> 30 30 </intent-filter> 31 + <intent-filter> 32 + <action android:name="android.intent.action.VIEW" /> 33 + 34 + <category android:name="android.intent.category.DEFAULT" /> 35 + <category android:name="android.intent.category.BROWSABLE" /> 36 + 37 + <data android:scheme="lazurite" android:host="auth-complete" /> 38 + </intent-filter> 31 39 </activity> 32 40 <!-- Don't delete the meta-data below. 33 41 This is used by the Flutter tool to generate GeneratedPluginRegistrant.java -->
+13
ios/Runner/Info.plist
··· 18 18 <string>$(PRODUCT_BUNDLE_IDENTIFIER)</string> 19 19 <key>CFBundleInfoDictionaryVersion</key> 20 20 <string>6.0</string> 21 + <key>CFBundleURLTypes</key> 22 + <array> 23 + <dict> 24 + <key>CFBundleTypeRole</key> 25 + <string>Editor</string> 26 + <key>CFBundleURLName</key> 27 + <string>org.stormlightlabs.lazurite.auth</string> 28 + <key>CFBundleURLSchemes</key> 29 + <array> 30 + <string>lazurite</string> 31 + </array> 32 + </dict> 33 + </array> 21 34 <key>CFBundleName</key> 22 35 <string>lazurite</string> 23 36 <key>CFBundlePackageType</key>
+53 -15
lib/features/auth/data/auth_repository.dart
··· 6 6 import 'package:atproto_core/atproto_core.dart' as atcore; 7 7 import 'package:atproto_oauth/atproto_oauth.dart'; 8 8 import 'package:drift/drift.dart'; 9 + import 'package:flutter/foundation.dart'; 9 10 import 'package:http/http.dart' as http; 10 11 import 'package:lazurite/core/database/app_database.dart'; 11 12 import 'package:lazurite/core/logging/app_logger.dart'; ··· 19 20 static const String kClientId = 'https://lazurite.stormlightlabs.org/client-metadata.json'; 20 21 static const String _oauthService = 'bsky.social'; 21 22 static const String _fallbackService = 'bsky.social'; 23 + static final Uri _appReopenUri = Uri.parse('lazurite://auth-complete'); 22 24 23 25 final AppDatabase _database; 24 26 25 27 HttpServer? _callbackServer; 26 28 StreamSubscription<HttpRequest>? _callbackSubscription; 29 + int _callbackServerPort = 0; 27 30 Completer<AuthTokens?>? _oauthCompleter; 28 31 OAuthClient? _pendingOAuthClient; 29 32 OAuthContext? _pendingOAuthContext; ··· 263 266 ); 264 267 } 265 268 269 + await _stopCallbackServer(); 270 + 266 271 final requestedPort = _requestedCallbackPort(redirectUriTemplate); 267 272 log.d( 268 273 'AuthRepository: Binding OAuth callback server to ' ··· 270 275 'for ${redirectUriTemplate.path}', 271 276 ); 272 277 273 - _callbackServer = await HttpServer.bind(InternetAddress.loopbackIPv4, requestedPort); 278 + final callbackServer = await HttpServer.bind(InternetAddress.loopbackIPv4, requestedPort); 279 + _callbackServer = callbackServer; 280 + _callbackServerPort = callbackServer.port; 274 281 final redirectUri = redirectUriTemplate.replace( 275 282 host: InternetAddress.loopbackIPv4.address, 276 - port: _callbackServer!.port, 283 + port: callbackServer.port, 277 284 ); 278 285 log.i('AuthRepository: OAuth callback server listening on ${_sanitizeUriForLog(redirectUri)}'); 279 286 280 - _callbackSubscription = _callbackServer!.listen( 287 + _callbackSubscription = callbackServer.listen( 281 288 (request) { 282 289 unawaited(_handleCallbackRequest(request, redirectUri)); 283 290 }, ··· 305 312 await _callbackSubscription?.cancel(); 306 313 _callbackSubscription = null; 307 314 308 - final callbackBody = utf8.encode(_callbackPageHtml); 315 + final callbackPageHtml = buildCallbackPageHtmlForTest(); 316 + final callbackBody = utf8.encode(callbackPageHtml); 309 317 request.response 310 318 ..statusCode = HttpStatus.ok 311 319 ..headers.contentType = ContentType.html 312 320 ..headers.contentLength = callbackBody.length 313 - ..write(_callbackPageHtml); 321 + ..write(callbackPageHtml); 314 322 await request.response.close(); 315 323 316 324 final callbackUrl = uri.replace(scheme: redirectUri.scheme, host: redirectUri.host, port: redirectUri.port); ··· 402 410 } 403 411 404 412 Future<void> _stopCallbackServer() async { 405 - if (_callbackServer != null) { 406 - log.d('AuthRepository: Stopping OAuth callback server on port ${_callbackServer!.port}'); 407 - } 408 - await _callbackSubscription?.cancel(); 413 + final callbackSubscription = _callbackSubscription; 414 + final callbackServer = _callbackServer; 415 + final callbackServerPort = _callbackServerPort; 416 + 409 417 _callbackSubscription = null; 410 - await _callbackServer?.close(); 411 418 _callbackServer = null; 419 + _callbackServerPort = 0; 420 + 421 + if (callbackServerPort > 0) { 422 + log.d('AuthRepository: Stopping OAuth callback server on port $callbackServerPort'); 423 + } 424 + await callbackSubscription?.cancel(); 425 + if (callbackServer == null) { 426 + return; 427 + } 428 + 429 + try { 430 + await callbackServer.close(force: true); 431 + } on HttpException catch (error, stackTrace) { 432 + log.w('AuthRepository: OAuth callback server was already closed', error: error, stackTrace: stackTrace); 433 + } 412 434 } 413 435 414 436 Future<String> _resolveServiceForIdentifier(String identifier) async { ··· 529 551 _pendingService = null; 530 552 } 531 553 532 - int get callbackPort => _callbackServer?.port ?? 0; 554 + @visibleForTesting 555 + String buildCallbackPageHtmlForTest() => _buildCallbackPageHtml(_appReopenUri); 556 + 557 + @visibleForTesting 558 + Future<Uri> startCallbackServerForTest(Uri redirectUriTemplate) => _startCallbackServer(redirectUriTemplate); 559 + 560 + @visibleForTesting 561 + Future<void> stopCallbackServerForTest() => _stopCallbackServer(); 562 + 563 + int get callbackPort => _callbackServerPort; 533 564 } 534 565 535 - const String _callbackPageHtml = ''' 566 + String _buildCallbackPageHtml(Uri reopenUri) { 567 + final escapedReopenUrl = HtmlEscape(HtmlEscapeMode.element).convert(reopenUri.toString()); 568 + return ''' 536 569 <!DOCTYPE html> 537 570 <html lang="en"> 538 571 <head> ··· 584 617 <body> 585 618 <main> 586 619 <h1>Authentication Complete</h1> 587 - <p>If this page does not close automatically, switch back to Lazurite.</p> 588 - <button type="button" onclick="window.close()">Close This Tab</button> 620 + <p>Lazurite is finishing sign-in. If it does not reopen automatically, tap the button below.</p> 621 + <button type="button" onclick="window.location.href = '$escapedReopenUrl'">Return to Lazurite</button> 589 622 </main> 590 623 <script> 591 624 window.setTimeout(function () { 625 + window.location.replace('$escapedReopenUrl'); 626 + }, 150); 627 + 628 + window.setTimeout(function () { 592 629 window.close(); 593 - }, 250); 630 + }, 600); 594 631 </script> 595 632 </body> 596 633 </html> 597 634 '''; 635 + }
+7 -10
lib/features/connectivity/presentation/connectivity_banner_host.dart
··· 14 14 return Stack( 15 15 children: [ 16 16 Positioned.fill(child: child), 17 - Positioned( 18 - top: 0, 19 - left: 0, 20 - right: 0, 21 - child: SafeArea( 22 - bottom: false, 23 - child: AnimatedSlide( 24 - duration: const Duration(milliseconds: 200), 25 - offset: state.isOffline ? Offset.zero : const Offset(0, -1), 17 + if (state.isOffline) 18 + Positioned( 19 + top: 0, 20 + left: 0, 21 + right: 0, 22 + child: SafeArea( 23 + bottom: false, 26 24 child: IgnorePointer( 27 25 ignoring: true, 28 26 child: Padding( ··· 59 57 ), 60 58 ), 61 59 ), 62 - ), 63 60 ], 64 61 ); 65 62 },
+22
test/features/auth/data/auth_repository_test.dart
··· 148 148 verify(() => mockDatabase.deleteSetting(AppDatabase.activeAccountDidSettingKey)).called(1); 149 149 }); 150 150 }); 151 + 152 + group('callback server', () { 153 + test('builds a callback page that can return to the app', () { 154 + final html = authRepository.buildCallbackPageHtmlForTest(); 155 + 156 + expect(html, contains('lazurite://auth-complete')); 157 + expect(html, contains('Return to Lazurite')); 158 + }); 159 + 160 + test('can stop the callback server twice without throwing', () async { 161 + final redirectUri = await authRepository.startCallbackServerForTest(Uri.parse('http://127.0.0.1/callback')); 162 + 163 + expect(redirectUri.host, equals('127.0.0.1')); 164 + expect(authRepository.callbackPort, greaterThan(0)); 165 + 166 + await authRepository.stopCallbackServerForTest(); 167 + expect(authRepository.callbackPort, equals(0)); 168 + 169 + await authRepository.stopCallbackServerForTest(); 170 + expect(authRepository.callbackPort, equals(0)); 171 + }); 172 + }); 151 173 }); 152 174 }