diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index a3b6077..49baad8 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -48,6 +48,17 @@ + + + + + + + + diff --git a/integration_test/oauth_callback_https_integration_test.dart b/integration_test/oauth_callback_https_integration_test.dart new file mode 100644 index 0000000..8d85d13 --- /dev/null +++ b/integration_test/oauth_callback_https_integration_test.dart @@ -0,0 +1,67 @@ +import 'package:bloc_test/bloc_test.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:go_router/go_router.dart'; +import 'package:integration_test/integration_test.dart'; +import 'package:lazurite/features/auth/bloc/auth_bloc.dart'; +import 'package:lazurite/features/auth/presentation/oauth_callback_screen.dart'; +import 'package:mocktail/mocktail.dart'; + +class MockAuthBloc extends MockBloc implements AuthBloc {} + +void main() { + IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + + setUpAll(() { + registerFallbackValue(Uri.parse('https://example.com/oauth/callback')); + }); + + testWidgets('forwards HTTPS callback URI query params and returns to login', (tester) async { + final authBloc = MockAuthBloc(); + when(() => authBloc.state).thenReturn(const AuthState.unauthenticated()); + whenListen(authBloc, const Stream.empty(), initialState: const AuthState.unauthenticated()); + + Uri? capturedUri; + when(() => authBloc.handleOAuthRedirectUri(any())).thenAnswer((invocation) async { + capturedUri = invocation.positionalArguments.first as Uri; + return true; + }); + + final router = GoRouter( + initialLocation: OAuthCallbackScreen.routePath, + routes: [ + GoRoute( + path: OAuthCallbackScreen.routePath, + builder: (context, state) => OAuthCallbackScreen( + callbackUri: Uri.parse( + 'https://lazurite.stormlightlabs.org/oauth/callback?code=abc&state=xyz&iss=https%3A%2F%2Fbsky.social', + ), + ), + ), + GoRoute( + path: '/login', + builder: (context, state) => const Scaffold(body: Text('login')), + ), + ], + ); + + await tester.pumpWidget( + BlocProvider.value( + value: authBloc, + child: MaterialApp.router(routerConfig: router), + ), + ); + await tester.pumpAndSettle(); + + verify(() => authBloc.handleOAuthRedirectUri(any())).called(1); + expect(capturedUri, isNotNull); + expect(capturedUri!.scheme, equals('https')); + expect(capturedUri!.host, equals('lazurite.stormlightlabs.org')); + expect(capturedUri!.path, equals('/oauth/callback')); + expect(capturedUri!.queryParameters['code'], equals('abc')); + expect(capturedUri!.queryParameters['state'], equals('xyz')); + expect(capturedUri!.queryParameters['iss'], equals('https://bsky.social')); + expect(find.text('login'), findsOneWidget); + }); +} diff --git a/ios/Runner/Runner.entitlements b/ios/Runner/Runner.entitlements index 903def2..0e35574 100644 --- a/ios/Runner/Runner.entitlements +++ b/ios/Runner/Runner.entitlements @@ -2,6 +2,10 @@ + com.apple.developer.associated-domains + + applinks:lazurite.stormlightlabs.org + aps-environment development diff --git a/lib/core/embedding/embedding_service.dart b/lib/core/embedding/embedding_service.dart index 60524b1..414b3c0 100644 --- a/lib/core/embedding/embedding_service.dart +++ b/lib/core/embedding/embedding_service.dart @@ -3,8 +3,8 @@ import 'dart:math' show sqrt; import 'package:flutter/foundation.dart'; import 'package:flutter/services.dart'; -import 'package:lazurite/core/logging/app_logger.dart'; import 'package:lazurite/core/embedding/word_piece_tokenizer.dart'; +import 'package:lazurite/core/logging/app_logger.dart'; import 'package:tflite_flutter/tflite_flutter.dart'; /// L2-normalize [vector], returning a new [Float32List]. @@ -94,7 +94,6 @@ List _buildModelInputs( } } - // Fallback mapping when tensor names are opaque or stripped. if (!assignedInputIds && inputTensors.isNotEmpty) { inputs[0] = inputIds; } @@ -133,7 +132,6 @@ Float32List _extractEmbeddingFromModelOutput(Object? output, List attention return Float32List.fromList(first); } - // [batch, seq, hidden] shape: mean-pool token embeddings. if (first is List && first.isNotEmpty && first.first is List) { final tokenRows = first.cast>(); final hiddenSize = tokenRows.first.length; diff --git a/lib/features/auth/data/auth_repository.dart b/lib/features/auth/data/auth_repository.dart index 1627406..c4ef80c 100644 --- a/lib/features/auth/data/auth_repository.dart +++ b/lib/features/auth/data/auth_repository.dart @@ -68,7 +68,18 @@ class AuthRepository { static const String _fallbackService = 'bsky.social'; static const String _mobileOAuthRedirectScheme = 'org.stormlightlabs.lazurite'; static const String _mobileOAuthRedirectPath = '/oauth/callback'; + static const String _httpsOAuthRedirectHost = 'lazurite.stormlightlabs.org'; + static const String _httpsOAuthRedirectPath = '/oauth/callback'; + static const bool _androidHttpsCallbackEnabled = bool.fromEnvironment( + 'OAUTH_ANDROID_HTTPS_CALLBACK_ENABLED', + defaultValue: true, + ); + static const bool _iosHttpsCallbackEnabled = bool.fromEnvironment( + 'OAUTH_IOS_HTTPS_CALLBACK_ENABLED', + defaultValue: true, + ); static final Uri _mobileOAuthRedirectUri = Uri.parse('$_mobileOAuthRedirectScheme:$_mobileOAuthRedirectPath'); + static final Uri _httpsOAuthRedirectUri = Uri.https(_httpsOAuthRedirectHost, _httpsOAuthRedirectPath); final AppDatabase _database; final LaunchUrlWithMode _launchUrlWithMode; @@ -214,8 +225,23 @@ class AuthRepository { final metadata = await _loadClientMetadata(kClientId); log.d('AuthRepository: Loaded client metadata with redirect URIs: ${metadata.redirectUris.join(', ')}'); - final redirectUri = _selectOAuthRedirectUriTemplate(metadata.redirectUris); - log.i('AuthRepository: Using custom-scheme OAuth callback redirect ${_sanitizeUriForLog(redirectUri)}'); + final isAndroidNative = !kIsWeb && defaultTargetPlatform == TargetPlatform.android; + final isIosNative = !kIsWeb && defaultTargetPlatform == TargetPlatform.iOS; + final redirectUri = _selectOAuthRedirectUriTemplate( + metadata.redirectUris, + isAndroid: isAndroidNative, + httpsAndroidCallbackEnabled: _androidHttpsCallbackEnabled, + isIos: isIosNative, + httpsIosCallbackEnabled: _iosHttpsCallbackEnabled, + ); + log.d( + 'AuthRepository: OAuth callback strategy ' + 'androidNative=$isAndroidNative ' + 'androidHttpsCallbackEnabled=$_androidHttpsCallbackEnabled ' + 'iosNative=$isIosNative ' + 'iosHttpsCallbackEnabled=$_iosHttpsCallbackEnabled', + ); + log.i('AuthRepository: Using OAuth callback redirect ${_sanitizeUriForLog(redirectUri)}'); Object? lastAttemptError; StackTrace? lastAttemptStackTrace; @@ -237,7 +263,7 @@ class AuthRepository { return await _oauthCompleter!.future.timeout( const Duration(minutes: 3), - onTimeout: () => throw TimeoutException('Timed out waiting for OAuth callback on custom scheme redirect'), + onTimeout: () => throw TimeoutException('Timed out waiting for OAuth callback redirect'), ); } catch (error, stackTrace) { lastAttemptError = error; @@ -793,16 +819,16 @@ class AuthRepository { return _oauthLaunchModeForPlatform(isWeb: isWeb, platform: platform); } + /// ATProto OAuth providers can enforce browser-like fetch metadata semantics. + /// Prefer the system browser app on mobile for consistent behavior. static LaunchMode _oauthLaunchModeForPlatform({required bool isWeb, required TargetPlatform platform}) { if (isWeb) { return LaunchMode.platformDefault; } return switch (platform) { - // ATProto OAuth providers can enforce browser-like fetch metadata semantics - // that are not always met by embedded WebViews. Prefer browser tab UX. - TargetPlatform.android => LaunchMode.inAppBrowserView, - TargetPlatform.iOS => LaunchMode.inAppBrowserView, + TargetPlatform.android => LaunchMode.externalApplication, + TargetPlatform.iOS => LaunchMode.externalApplication, _ => LaunchMode.externalApplication, }; } @@ -817,11 +843,21 @@ class AuthRepository { return redirectUri.scheme == _mobileOAuthRedirectScheme && redirectUri.path == _mobileOAuthRedirectPath; } + bool _isSupportedHttpsRedirect(Uri redirectUri) { + return redirectUri.scheme == 'https' && + redirectUri.host == _httpsOAuthRedirectHost && + redirectUri.path == _httpsOAuthRedirectPath; + } + Uri? _normalizeOAuthCallbackUri(Uri callbackUri) { if (_isSupportedCustomSchemeRedirect(callbackUri)) { return callbackUri; } + if (_isSupportedHttpsRedirect(callbackUri)) { + return callbackUri; + } + if (!callbackUri.hasScheme && callbackUri.path == _mobileOAuthRedirectPath) { return Uri( scheme: _mobileOAuthRedirectScheme, @@ -834,21 +870,65 @@ class AuthRepository { return null; } - Uri _selectOAuthRedirectUriTemplate(List redirectUris) { + Uri _selectOAuthRedirectUriTemplate( + List redirectUris, { + required bool isAndroid, + required bool httpsAndroidCallbackEnabled, + required bool isIos, + required bool httpsIosCallbackEnabled, + }) { final candidates = redirectUris.map(Uri.parse).toList(growable: false); if (candidates.isEmpty) { throw UnsupportedError('OAuth client metadata does not declare any redirect URIs.'); } + Uri? customSchemeRedirect; + Uri? httpsRedirect; for (final candidate in candidates) { if (_isSupportedCustomSchemeRedirect(candidate)) { - return candidate; + customSchemeRedirect ??= candidate; + } + if (_isSupportedHttpsRedirect(candidate)) { + httpsRedirect ??= candidate; } } + if (isAndroid && httpsAndroidCallbackEnabled && httpsRedirect != null) { + return httpsRedirect; + } + if (isIos && httpsIosCallbackEnabled && httpsRedirect != null) { + return httpsRedirect; + } + if (customSchemeRedirect != null) { + return customSchemeRedirect; + } + if (httpsRedirect != null) { + return httpsRedirect; + } + throw UnsupportedError( - 'No supported OAuth redirect URI found. Lazurite currently requires ' - '${_mobileOAuthRedirectUri.toString()}.', + 'No supported OAuth redirect URI found. Lazurite currently supports ' + '${_mobileOAuthRedirectUri.toString()} and ${_httpsOAuthRedirectUri.toString()}.', + ); + } + + @visibleForTesting + Uri? normalizeOAuthCallbackUriForTest(Uri callbackUri) => _normalizeOAuthCallbackUri(callbackUri); + + @visibleForTesting + Uri selectOAuthRedirectUriTemplateForTest( + List redirectUris, { + required bool isAndroid, + required bool httpsAndroidCallbackEnabled, + required bool isIos, + required bool httpsIosCallbackEnabled, + }) { + return _selectOAuthRedirectUriTemplate( + redirectUris, + isAndroid: isAndroid, + httpsAndroidCallbackEnabled: httpsAndroidCallbackEnabled, + isIos: isIos, + httpsIosCallbackEnabled: httpsIosCallbackEnabled, ); } diff --git a/lib/features/feed/data/liked_posts_repository.dart b/lib/features/feed/data/liked_posts_repository.dart index 705748c..616fb7e 100644 --- a/lib/features/feed/data/liked_posts_repository.dart +++ b/lib/features/feed/data/liked_posts_repository.dart @@ -113,7 +113,6 @@ class LikedPostsRepository { } } - // Deterministic fallback for malformed/missing timestamps. return DateTime.fromMillisecondsSinceEpoch(0, isUtc: true); } diff --git a/lib/features/notifications/background/notification_background_worker.dart b/lib/features/notifications/background/notification_background_worker.dart index 3c3076e..fcfc62d 100644 --- a/lib/features/notifications/background/notification_background_worker.dart +++ b/lib/features/notifications/background/notification_background_worker.dart @@ -66,6 +66,9 @@ Future handleNotificationWorkmanagerTask(String taskName, Map ensureScheduled() async { if (Platform.isAndroid) { await Workmanager().registerPeriodicTask( @@ -82,9 +85,6 @@ class NotificationBackgroundScheduler { return; } - // iOS fetch/BGTask execution is system-managed. Workmanager's - // `registerPeriodicTask` channel method is Android-specific, so avoid - // calling it on iOS. try { await Workmanager().registerOneOffTask( notificationReconcileUniqueName, diff --git a/lib/features/notifications/domain/notification_reason_utils.dart b/lib/features/notifications/domain/notification_reason_utils.dart index 5a37cfc..a99ab3e 100644 --- a/lib/features/notifications/domain/notification_reason_utils.dart +++ b/lib/features/notifications/domain/notification_reason_utils.dart @@ -114,7 +114,6 @@ abstract final class NotificationReasonUtils { navigationMode: NotificationTapNavigationMode.push, ); } - // Fallback to actor profile if the payload is missing starter pack context. } if (isProfileNavigationReason(notification.reason)) { diff --git a/lib/features/profile/data/follow_audit_repository.dart b/lib/features/profile/data/follow_audit_repository.dart index dee5963..f07f862 100644 --- a/lib/features/profile/data/follow_audit_repository.dart +++ b/lib/features/profile/data/follow_audit_repository.dart @@ -351,7 +351,6 @@ class FollowAuditRepository { final sessionDid = _currentSessionDid(); if (sessionDid == null) { - // Test doubles and unauthenticated contexts may not expose session shape. return; } diff --git a/lib/features/profile/data/profile_context_repository.dart b/lib/features/profile/data/profile_context_repository.dart index 2a8ca80..16bfbc2 100644 --- a/lib/features/profile/data/profile_context_repository.dart +++ b/lib/features/profile/data/profile_context_repository.dart @@ -341,7 +341,6 @@ class ProfileContextRepository { final sessionDid = _currentSessionDid(); if (sessionDid == null) { - // Test doubles and unauthenticated contexts may not expose session shape. return; } diff --git a/lib/features/profile/data/profile_repository.dart b/lib/features/profile/data/profile_repository.dart index 0d29931..bff1af3 100644 --- a/lib/features/profile/data/profile_repository.dart +++ b/lib/features/profile/data/profile_repository.dart @@ -76,7 +76,6 @@ class ProfileRepository { rethrow; } - // Cache failures should not downgrade a fresh network response into stale fallback data. unawaited(_cacheProfileSafely(profile)); if (_moderationService?.shouldFilterProfileDetailedInView(profile) ?? false) { @@ -128,14 +127,14 @@ class ProfileRepository { return suggestions.where((p) => !moderationService.shouldFilterProfileInList(p)).toList(); } + /// Likes transport matrix: + /// - Self liked tab: app.bsky.feed.getActorLikes via viewer-auth context + /// (PDS-routed, read-after-write behavior for the current account). + /// - Non-self liked tab: actor repo scan on actor PDS via + /// com.atproto.repo.listRecords(app.bsky.feed.like), then hydrate subjects + /// on AppView via app.bsky.feed.getPosts. + /// Never route non-self repo reads through the viewer PDS. Future getActorLikes({required String actor, String? cursor, int limit = 50}) async { - // Likes transport matrix: - // - Self liked tab: app.bsky.feed.getActorLikes via viewer-auth context - // (PDS-routed, read-after-write behavior for the current account). - // - Non-self liked tab: actor repo scan on actor PDS via - // com.atproto.repo.listRecords(app.bsky.feed.like), then hydrate subjects - // on AppView via app.bsky.feed.getPosts. - // Never route non-self repo reads through the viewer PDS. if (_isCurrentSessionActor(actor)) { final headers = _appViewContext.appBskyHeadersWithoutProxy(await _moderationService?.headersForRequest()); log.i( @@ -331,7 +330,9 @@ class ProfileRepository { if (normalizedActor == sessionDid || normalizedActor == sessionHandle) { return true; } - } catch (_) {} + } catch (e) { + log.d('ProfileRepository: Unable to parse current session actor', error: e); + } try { final oauthSession = bluesky.oAuthSession; @@ -339,8 +340,8 @@ class ProfileRepository { if (normalizedActor == oauthDid) { return true; } - } catch (_) { - // Ignore non-standard test doubles/wrappers missing OAuth shape. + } catch (e) { + log.d('ProfileRepository: Unable to parse current session actor', error: e); } return false; } diff --git a/lib/features/search/data/semantic_search_repository.dart b/lib/features/search/data/semantic_search_repository.dart index fc1ffe9..1025150 100644 --- a/lib/features/search/data/semantic_search_repository.dart +++ b/lib/features/search/data/semantic_search_repository.dart @@ -74,7 +74,7 @@ class SemanticSearchRepository { if (postJson == null) { continue; } - // FTS rank determines order; map position to a readable confidence range. + final score = (90.0 - (index * 2.5)).clamp(55.0, 95.0).toDouble(); results.add(SemanticSearchResult(postUri: match.postUri, score: score, source: match.source, postJson: postJson)); } diff --git a/lib/shared/presentation/widgets/global_tap_outside_unfocus.dart b/lib/shared/presentation/widgets/global_tap_outside_unfocus.dart index 387313a..07cb671 100644 --- a/lib/shared/presentation/widgets/global_tap_outside_unfocus.dart +++ b/lib/shared/presentation/widgets/global_tap_outside_unfocus.dart @@ -9,13 +9,13 @@ class GlobalTapOutsideUnfocus extends StatelessWidget { final Widget child; + /// On invoke, we preserve Flutter's default down-event behavior on touch so + /// overlay interactions (like typeahead suggestion taps) are not interrupted. @override Widget build(BuildContext context) => Actions( actions: >{ EditableTextTapOutsideIntent: CallbackAction( onInvoke: (intent) { - // Preserve Flutter's default down-event behavior on touch so overlay - // interactions (like typeahead suggestion taps) are not interrupted. if (intent.pointerDownEvent.kind != ui.PointerDeviceKind.touch) { intent.focusNode.unfocus(); } diff --git a/test/core/router/app_router_test.dart b/test/core/router/app_router_test.dart index 7a716e4..0c988d2 100644 --- a/test/core/router/app_router_test.dart +++ b/test/core/router/app_router_test.dart @@ -528,4 +528,38 @@ void main() { router.dispose(); }); + + testWidgets('processes absolute HTTPS oauth callback route while authenticated', (tester) async { + final router = AppRouter(authBloc: authBloc).router; + final pendingCallback = Completer(); + when(() => authBloc.handleOAuthRedirectUri(any())).thenAnswer((_) => pendingCallback.future); + + await tester.pumpWidget(buildSubjectWithRouter(router)); + router.go('https://lazurite.stormlightlabs.org/oauth/callback?code=abc&state=xyz'); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 100)); + + verify( + () => authBloc.handleOAuthRedirectUri( + any( + that: predicate( + (uri) => + uri.scheme == 'https' && + uri.host == 'lazurite.stormlightlabs.org' && + uri.path == OAuthCallbackScreen.routePath && + uri.queryParameters['code'] == 'abc' && + uri.queryParameters['state'] == 'xyz', + ), + ), + ), + ).called(1); + + pendingCallback.complete(true); + await tester.pumpAndSettle(); + + expect(router.routeInformationProvider.value.uri.path, isNot(equals(OAuthCallbackScreen.routePath))); + expect(find.text('No feeds pinned'), findsOneWidget); + + router.dispose(); + }); } diff --git a/test/features/auth/data/auth_repository_test.dart b/test/features/auth/data/auth_repository_test.dart index 4217be0..d2bfe01 100644 --- a/test/features/auth/data/auth_repository_test.dart +++ b/test/features/auth/data/auth_repository_test.dart @@ -353,11 +353,7 @@ void main() { database: mockDatabase, resolveDidDocument: (_) async => { 'service': [ - { - 'id': '#atproto_pds', - 'type': 'AtprotoPersonalDataServer', - 'serviceEndpoint': 'https://pds.example', - }, + {'id': '#atproto_pds', 'type': 'AtprotoPersonalDataServer', 'serviceEndpoint': 'https://pds.example'}, ], }, ); @@ -380,6 +376,145 @@ void main() { }); }); + group('oauth callback normalization', () { + test('accepts canonical custom scheme callback URI', () { + final normalized = authRepository.normalizeOAuthCallbackUriForTest( + Uri.parse('org.stormlightlabs.lazurite:/oauth/callback?code=abc&state=xyz'), + ); + + expect(normalized, isNotNull); + expect(normalized!.scheme, equals('org.stormlightlabs.lazurite')); + expect(normalized.path, equals('/oauth/callback')); + }); + + test('normalizes path-only callback URI to canonical custom scheme', () { + final normalized = authRepository.normalizeOAuthCallbackUriForTest( + Uri.parse('/oauth/callback?code=abc&state=xyz'), + ); + + expect(normalized, isNotNull); + expect(normalized!.toString(), equals('org.stormlightlabs.lazurite:/oauth/callback?code=abc&state=xyz')); + }); + + test('accepts exact HTTPS callback URI with oauth query parameters', () { + final normalized = authRepository.normalizeOAuthCallbackUriForTest( + Uri.parse( + 'https://lazurite.stormlightlabs.org/oauth/callback?code=abc&state=xyz&iss=https%3A%2F%2Fbsky.social', + ), + ); + + expect(normalized, isNotNull); + expect(normalized!.scheme, equals('https')); + expect(normalized.host, equals('lazurite.stormlightlabs.org')); + expect(normalized.path, equals('/oauth/callback')); + expect(normalized.queryParameters['code'], equals('abc')); + expect(normalized.queryParameters['state'], equals('xyz')); + }); + + test('rejects HTTPS callback URI with unexpected host', () { + final normalized = authRepository.normalizeOAuthCallbackUriForTest( + Uri.parse('https://example.com/oauth/callback?code=abc&state=xyz'), + ); + + expect(normalized, isNull); + }); + + test('rejects HTTPS callback URI with unexpected path', () { + final normalized = authRepository.normalizeOAuthCallbackUriForTest( + Uri.parse('https://lazurite.stormlightlabs.org/callback?code=abc&state=xyz'), + ); + + expect(normalized, isNull); + }); + }); + + group('oauth redirect URI selection', () { + test('prefers HTTPS callback on Android when flag is enabled', () { + final selected = authRepository.selectOAuthRedirectUriTemplateForTest( + const ['org.stormlightlabs.lazurite:/oauth/callback', 'https://lazurite.stormlightlabs.org/oauth/callback'], + isAndroid: true, + httpsAndroidCallbackEnabled: true, + isIos: false, + httpsIosCallbackEnabled: true, + ); + + expect(selected.toString(), equals('https://lazurite.stormlightlabs.org/oauth/callback')); + }); + + test('uses custom scheme callback on Android when HTTPS flag is disabled', () { + final selected = authRepository.selectOAuthRedirectUriTemplateForTest( + const ['org.stormlightlabs.lazurite:/oauth/callback', 'https://lazurite.stormlightlabs.org/oauth/callback'], + isAndroid: true, + httpsAndroidCallbackEnabled: false, + isIos: false, + httpsIosCallbackEnabled: true, + ); + + expect(selected.toString(), equals('org.stormlightlabs.lazurite:/oauth/callback')); + }); + + test('uses custom scheme callback when HTTPS callback is unavailable', () { + final selected = authRepository.selectOAuthRedirectUriTemplateForTest( + const ['org.stormlightlabs.lazurite:/oauth/callback'], + isAndroid: true, + httpsAndroidCallbackEnabled: true, + isIos: false, + httpsIosCallbackEnabled: true, + ); + + expect(selected.toString(), equals('org.stormlightlabs.lazurite:/oauth/callback')); + }); + + test('uses HTTPS callback when custom scheme callback is unavailable', () { + final selected = authRepository.selectOAuthRedirectUriTemplateForTest( + const ['https://lazurite.stormlightlabs.org/oauth/callback'], + isAndroid: true, + httpsAndroidCallbackEnabled: true, + isIos: false, + httpsIosCallbackEnabled: true, + ); + + expect(selected.toString(), equals('https://lazurite.stormlightlabs.org/oauth/callback')); + }); + + test('prefers HTTPS callback on iOS when flag is enabled', () { + final selected = authRepository.selectOAuthRedirectUriTemplateForTest( + const ['org.stormlightlabs.lazurite:/oauth/callback', 'https://lazurite.stormlightlabs.org/oauth/callback'], + isAndroid: false, + httpsAndroidCallbackEnabled: true, + isIos: true, + httpsIosCallbackEnabled: true, + ); + + expect(selected.toString(), equals('https://lazurite.stormlightlabs.org/oauth/callback')); + }); + + test('uses custom scheme callback on iOS when HTTPS flag is disabled', () { + final selected = authRepository.selectOAuthRedirectUriTemplateForTest( + const ['org.stormlightlabs.lazurite:/oauth/callback', 'https://lazurite.stormlightlabs.org/oauth/callback'], + isAndroid: false, + httpsAndroidCallbackEnabled: true, + isIos: true, + httpsIosCallbackEnabled: false, + ); + + expect(selected.toString(), equals('org.stormlightlabs.lazurite:/oauth/callback')); + }); + + test('throws when no supported callback URI is present', () { + expect( + () => authRepository.selectOAuthRedirectUriTemplateForTest( + const ['https://example.com/oauth/callback'], + isAndroid: true, + httpsAndroidCallbackEnabled: true, + isIos: false, + httpsIosCallbackEnabled: true, + ), + throwsA(isA()), + ); + }); + }); + group('clearSession', () { test('should delete all accounts', () async { when(() => mockDatabase.deleteAllAccounts()).thenAnswer((_) async => 1); @@ -407,17 +542,17 @@ void main() { }); group('oauth browser launch mode', () { - test('uses in-app browser view on iOS', () { + test('uses external application on iOS', () { expect( AuthRepository.oauthLaunchModeForTest(isWeb: false, platform: TargetPlatform.iOS), - equals(LaunchMode.inAppBrowserView), + equals(LaunchMode.externalApplication), ); }); - test('uses in-app browser view on Android', () { + test('uses external application on Android', () { expect( AuthRepository.oauthLaunchModeForTest(isWeb: false, platform: TargetPlatform.android), - equals(LaunchMode.inAppBrowserView), + equals(LaunchMode.externalApplication), ); }); @@ -507,7 +642,7 @@ OAuthClientMetadata _testClientMetadata() { applicationType: 'native', clientName: 'Lazurite Test', clientUri: 'https://lazurite.stormlightlabs.org', - redirectUris: ['org.stormlightlabs.lazurite:/oauth/callback'], + redirectUris: ['https://lazurite.stormlightlabs.org/oauth/callback', 'org.stormlightlabs.lazurite:/oauth/callback'], responseTypes: ['code'], grantTypes: ['authorization_code', 'refresh_token'], scope: 'atproto', diff --git a/test/features/profile/data/profile_repository_test.dart b/test/features/profile/data/profile_repository_test.dart index 509504f..46c1d64 100644 --- a/test/features/profile/data/profile_repository_test.dart +++ b/test/features/profile/data/profile_repository_test.dart @@ -1,7 +1,7 @@ import 'dart:convert'; -import 'package:drift/native.dart'; import 'package:bluesky/app_bsky_actor_defs.dart'; +import 'package:drift/native.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'; @@ -107,7 +107,6 @@ void main() { bluesky: _FakeBlueskyClient(actor: _FakeActorService(onGetProfile: (_) async => _FakeResponse(profile))), ); - // Force cache operations to fail while keeping network response successful. await database.close(); final result = await repository.getProfile(profile.did); diff --git a/www/.well-known/apple-app-site-association b/www/.well-known/apple-app-site-association new file mode 100644 index 0000000..3496eba --- /dev/null +++ b/www/.well-known/apple-app-site-association @@ -0,0 +1,14 @@ +{ + "applinks": { + "apps": [], + "details": [ + { + "appID": "8TVR4TPL9Y.org.stormlightlabs.lazurite", + "paths": [ + "/oauth/callback", + "/oauth/callback/*" + ] + } + ] + } +} diff --git a/www/.well-known/assetlinks.json b/www/.well-known/assetlinks.json new file mode 100644 index 0000000..1217402 --- /dev/null +++ b/www/.well-known/assetlinks.json @@ -0,0 +1,13 @@ +[ + { + "relation": ["delegate_permission/common.handle_all_urls"], + "target": { + "namespace": "android_app", + "package_name": "org.stormlightlabs.lazurite", + "sha256_cert_fingerprints": [ + "63:86:09:5D:D0:5A:ED:B8:65:7B:CF:C1:ED:C1:1C:0E:A6:FA:F4:89:04:71:1B:63:C4:1F:8D:37:B8:D4:0E:0E", + "25:25:9E:B8:32:9B:D2:B7:58:8F:53:07:AD:5C:D8:75:57:55:A3:59:52:E9:9A:CC:37:8F:E2:BE:2E:56:CB:61" + ] + } + } +] diff --git a/www/_headers b/www/_headers new file mode 100644 index 0000000..3dd53a9 --- /dev/null +++ b/www/_headers @@ -0,0 +1,2 @@ +/.well-known/apple-app-site-association + Content-Type: application/json; charset=utf-8 diff --git a/www/client-metadata.json b/www/client-metadata.json index e3e9733..d608b2f 100644 --- a/www/client-metadata.json +++ b/www/client-metadata.json @@ -1,14 +1,17 @@ { - "client_id": "https://lazurite.stormlightlabs.org/client-metadata.json", - "client_name": "Lazurite", - "client_uri": "https://lazurite.stormlightlabs.org", - "redirect_uris": ["org.stormlightlabs.lazurite:/oauth/callback"], - "scope": "atproto transition:generic transition:chat.bsky", - "grant_types": ["authorization_code", "refresh_token"], - "response_types": ["code"], - "token_endpoint_auth_method": "none", - "application_type": "native", - "dpop_bound_access_tokens": true, - "software_id": "org.stormlightlabs.lazurite", - "software_version": "1.0.0" + "client_id": "https://lazurite.stormlightlabs.org/client-metadata.json", + "client_name": "Lazurite", + "client_uri": "https://lazurite.stormlightlabs.org", + "redirect_uris": [ + "https://lazurite.stormlightlabs.org/oauth/callback", + "org.stormlightlabs.lazurite:/oauth/callback" + ], + "scope": "atproto transition:generic transition:chat.bsky", + "grant_types": ["authorization_code", "refresh_token"], + "response_types": ["code"], + "token_endpoint_auth_method": "none", + "application_type": "native", + "dpop_bound_access_tokens": true, + "software_id": "org.stormlightlabs.lazurite", + "software_version": "1.0.0" } diff --git a/www/csae-policy.html b/www/csae-policy.html index 1da724d..4df72c3 100644 --- a/www/csae-policy.html +++ b/www/csae-policy.html @@ -9,222 +9,14 @@ Lazurite CSAE Policy + - - +
diff --git a/www/index.html b/www/index.html index fb85cd2..a34a569 100644 --- a/www/index.html +++ b/www/index.html @@ -8,546 +8,20 @@ content="Lazurite - A beautiful Bluesky client for mobile and desktop. Material You on iOS & Android, native desktop with semantic search." /> Lazurite for BlueSky - + + - - - +
-

A better Bluesky client

+

The ATmosphere client that rocks.

@@ -557,9 +31,9 @@ In Active Development
-

Bluesky, everywhere you are

+

Roam the ATmosphere

- Lazurite is a native Bluesky client built for people who want more from their social experience. + Lazurite is a client for Bluesky & BlackSky built for people who want more from their social experience. A full-featured mobile app for iOS and Android, paired with a powerful desktop companion for macOS, Windows, and Linux.

diff --git a/www/oauth/callback/index.html b/www/oauth/callback/index.html new file mode 100644 index 0000000..287634d --- /dev/null +++ b/www/oauth/callback/index.html @@ -0,0 +1,57 @@ + + + + + + Lazurite Authentication Complete + + + + + + +
+
+

Authentication Complete

+

Lazurite is finishing sign-in. If the app does not reopen automatically, tap below.

+ Open Lazurite +

If this still fails, use your browser menu and choose “Open in app”.

+
+
+ + + + diff --git a/www/privacy.html b/www/privacy.html index 687a1ee..06e054b 100644 --- a/www/privacy.html +++ b/www/privacy.html @@ -9,199 +9,14 @@ Lazurite Privacy Policy + - - +
diff --git a/www/static/shared.css b/www/static/shared.css new file mode 100644 index 0000000..55394eb --- /dev/null +++ b/www/static/shared.css @@ -0,0 +1,719 @@ +:root { + --bg-dark: #000000; + --surface-dark: #191919; + --surface-variant: #1f1f1f; + --outline: rgba(255, 255, 255, 0.1); + --outline-bright: rgba(255, 255, 255, 0.2); + --text-primary: #f4f6fb; + --text-secondary: #ababab; + --text-tertiary: #6f6f6f; + + --primary: #7dafff; + --secondary: #0073de; + --tertiary: #33b1ff; + --cyan: #08bdba; + --purple: #be95ff; + --error: #ff8080; + + --font-title: "Lora", serif; + --font-display: "Google Sans", sans-serif; + --font-body: "Inter", sans-serif; + --font-mono: "Google Sans Code", monospace; +} + +* { + margin: 0; + padding: 0; + box-sizing: border-box; +} + +body.home-page, +body.legal-page, +body.oauth-callback-page { + font-family: var(--font-body); + background-color: var(--bg-dark); + color: var(--text-primary); + line-height: 1.6; + min-height: 100vh; + display: flex; + flex-direction: column; +} + +body.home-page a:hover, +body.legal-page a:hover { + color: var(--secondary); +} + +body.home-page a, +body.legal-page a { + color: var(--primary); + text-decoration: none; +} + +body.legal-page .container, +.oauth-callback-container { + max-width: 920px; + margin: 0 auto; + padding: 2rem; + flex: 1; +} + +body.home-page .platform, +body.home-page .feature, +body.home-page .tech-stack, +body.legal-page .legal-card, +.oauth-callback-main { + background: var(--surface-dark); + border: 1px solid var(--outline); + border-radius: 1rem; +} + +body.home-page .platform h2, +body.home-page .section-title, +body.home-page .feature h3, +body.home-page .tech-stack h2, +body.home-page .hero h1, +body.legal-page h1, +body.legal-page h2, +body.oauth-callback-page h1 { + font-family: var(--font-display); +} + +body.home-page footer, +body.legal-page footer, +body.legal-page .meta, +.oauth-callback-hint { + font-size: 0.875rem; + color: var(--text-tertiary); +} + +body.home-page .container { + max-width: 1200px; + margin: 0 auto; + padding: 2rem; + flex: 1; + display: flex; + flex-direction: column; + justify-content: center; +} + +body.home-page header { + text-align: center; + margin-bottom: 4rem; +} + +body.home-page .logo, +body.legal-page .logo { + font-family: var(--font-title); + font-weight: 700; + background: linear-gradient(135deg, var(--primary) 0%, var(--secondary) 50%, var(--tertiary) 100%); + -webkit-background-clip: text; + -webkit-text-fill-color: transparent; + background-clip: text; + letter-spacing: -0.02em; + display: inline-flex; + align-items: center; + gap: 0.5rem; +} + +body.home-page .logo { + font-size: 3.5rem; + margin-bottom: 1rem; +} + +body.home-page .logo-icon, +body.legal-page .logo-icon { + mask-image: url("/static/logo.svg"); + background-color: var(--primary); + mask-repeat: no-repeat; + mask-size: contain; + mask-position: center; + + -webkit-mask-image: url("/static/logo.svg"); + -webkit-mask-repeat: no-repeat; + -webkit-mask-size: contain; + -webkit-mask-position: center; +} + +body.home-page .logo-icon { + width: 3rem; + height: 3rem; +} + +body.home-page .tagline { + font-size: 1.25rem; + color: var(--text-secondary); + font-weight: 400; + letter-spacing: 0.01em; +} + +body.home-page main { + max-width: 900px; + margin: 0 auto; +} + +body.home-page .hero { + text-align: center; + margin-bottom: 4rem; +} + +body.home-page .hero h1 { + font-family: var(--font-display); + font-size: 2.5rem; + font-weight: 600; + margin-bottom: 1.5rem; + color: var(--text-primary); + line-height: 1.2; +} + +body.home-page .hero p { + font-size: 1.125rem; + color: var(--text-secondary); + margin-bottom: 2rem; + line-height: 1.8; +} + +body.home-page .status-badge { + display: inline-flex; + align-items: center; + gap: 0.5rem; + background: var(--surface-dark); + padding: 0.75rem 1.5rem; + border-radius: 2rem; + border: 1px solid var(--outline); + font-size: 0.875rem; + font-weight: 500; + letter-spacing: 0.02em; + margin-bottom: 3rem; +} + +body.home-page .status-dot { + width: 8px; + height: 8px; + border-radius: 50%; + background: var(--primary); + animation: pulse 2s ease-in-out infinite; +} + +@keyframes pulse { + 0%, + 100% { + opacity: 1; + } + + 50% { + opacity: 0.5; + } +} + +body.home-page .platforms { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 2rem; + margin-bottom: 4rem; +} + +body.home-page .platform { + padding: 2rem; +} + +body.home-page .platform-header { + display: flex; + align-items: center; + gap: 0.75rem; + margin-bottom: 1.25rem; +} + +body.home-page .platform-icon { + width: 40px; + height: 40px; + border-radius: 10px; + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; +} + +body.home-page .platform-icon.mobile { + background: linear-gradient(135deg, var(--primary), var(--secondary)); +} + +body.home-page .platform-icon img { + width: 22px; + height: 22px; +} + +body.home-page .platform h2 { + font-family: var(--font-display); + font-size: 1.375rem; + font-weight: 600; +} + +body.home-page .platform-badge { + font-size: 0.6875rem; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.06em; + padding: 0.2rem 0.5rem; + border-radius: 0.25rem; + margin-left: auto; +} + +body.home-page .platform-badge.alpha { + background: rgba(190, 149, 255, 0.15); + color: var(--purple); +} + +body.home-page .platform-badge.beta { + background: rgba(125, 175, 255, 0.15); + color: var(--primary); +} + +body.home-page .platform-desc { + color: var(--text-secondary); + font-size: 0.9375rem; + line-height: 1.6; + margin-bottom: 1.25rem; +} + +body.home-page .platform-targets { + display: flex; + gap: 0.5rem; + flex-wrap: wrap; + margin-bottom: 1.25rem; +} + +body.home-page .target-tag { + background: var(--surface-variant); + padding: 0.3rem 0.625rem; + border-radius: 0.375rem; + font-size: 0.8125rem; + font-family: var(--font-mono); + color: var(--text-secondary); +} + +body.home-page .platform-features { + list-style: none; +} + +body.home-page .platform-features li { + color: var(--text-secondary); + font-size: 0.875rem; + padding: 0.3rem 0; + padding-left: 1.25rem; + position: relative; +} + +body.home-page .platform-features li::before { + content: ""; + position: absolute; + left: 0; + top: 0.7rem; + width: 6px; + height: 6px; + border-radius: 50%; + background: var(--primary); + opacity: 0.6; +} + +body.home-page .platform-screenshot { + margin-top: 1.5rem; + border-radius: 0.75rem; + overflow: hidden; + border: 1px solid var(--outline); +} + +body.home-page .platform-screenshot img { + width: 100%; + height: auto; + display: block; +} + +body.home-page .platform-screenshot .caption { + font-size: 0.75rem; + color: var(--text-tertiary); + text-align: center; + padding: 0.5rem; + background: var(--surface-variant); +} + +body.home-page .hero-screenshots { + display: flex; + gap: 1.5rem; + justify-content: center; + align-items: flex-end; + margin-bottom: 3rem; + padding: 0 1rem; +} + +body.home-page .hero-screenshot { + border-radius: 1rem; + overflow: hidden; + border: 1px solid var(--outline); + box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4); +} + +body.home-page .hero-screenshot img { + display: block; + width: 100%; + height: auto; +} + +body.home-page .hero-screenshot.phone { + width: 200px; + flex-shrink: 0; +} + +body.home-page .hero-screenshot.desktop-shot { + width: 640px; + flex-shrink: 0; +} + +@media (max-width: 768px) { + body.home-page .hero-screenshots { + flex-direction: column; + align-items: center; + } + + body.home-page .hero-screenshot.phone { + width: 160px; + } + + body.home-page .hero-screenshot.desktop-shot { + width: 100%; + max-width: 360px; + } +} + +body.home-page .section-title { + font-family: var(--font-display); + font-size: 1.5rem; + font-weight: 600; + margin-bottom: 1.5rem; + color: var(--text-primary); +} + +body.home-page .features { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(250px, 1fr)); + gap: 1.5rem; + margin-bottom: 4rem; +} + +body.home-page .feature { + padding: 1.75rem; + transition: + transform 0.2s ease, + border-color 0.2s ease; +} + +body.home-page .feature:hover { + transform: translateY(-2px); + border-color: var(--outline-bright); +} + +body.home-page .feature h3 { + font-family: var(--font-display); + font-size: 1.125rem; + margin-bottom: 0.5rem; + color: var(--text-primary); +} + +body.home-page .feature p { + color: var(--text-secondary); + font-size: 0.875rem; + line-height: 1.6; +} + +body.home-page .feature-icon { + width: 44px; + height: 44px; + background: linear-gradient(135deg, var(--primary), var(--secondary)); + border-radius: 10px; + display: flex; + align-items: center; + justify-content: center; + margin-bottom: 0.875rem; + padding: 10px; +} + +body.home-page .platform-icon.desktop, +body.home-page .feature-icon.alt { + background: linear-gradient(135deg, var(--purple), var(--tertiary)); +} + +body.home-page .feature-icon img { + width: 100%; + height: 100%; +} + +body.home-page .tech-stack { + padding: 2rem; + margin-bottom: 4rem; +} + +body.home-page .tech-stack h2 { + font-family: var(--font-display); + font-size: 1.5rem; + margin-bottom: 1.5rem; + color: var(--text-primary); +} + +body.home-page .tech-group { + margin-bottom: 1rem; +} + +body.home-page .tech-group:last-child { + margin-bottom: 0; +} + +body.home-page .tech-group-label { + font-size: 0.75rem; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.08em; + color: var(--text-tertiary); + margin-bottom: 0.5rem; +} + +body.home-page .tech-list { + display: flex; + flex-wrap: wrap; + gap: 0.5rem; +} + +body.home-page .tech-tag { + background: var(--surface-variant); + padding: 0.4rem 0.875rem; + border-radius: 0.5rem; + font-size: 0.8125rem; + font-family: var(--font-mono); + color: var(--text-secondary); + border: 1px solid transparent; + transition: border-color 0.2s ease; +} + +body.home-page .tech-tag:hover { + border-color: var(--outline-bright); +} + +body.home-page .oauth-info { + background: var(--surface-variant); + padding: 1rem 1.5rem; + border-radius: 0.75rem; + border-left: 3px solid var(--primary); + margin-top: 2rem; + font-size: 0.875rem; + color: var(--text-secondary); +} + +body.home-page .oauth-info a { + font-weight: 500; + transition: color 0.2s ease; +} + +body.home-page .oauth-info a:hover { + text-decoration: underline; +} + +body.home-page footer, +body.legal-page footer { + text-align: center; + padding: 2rem; + color: var(--text-tertiary); + font-size: 0.875rem; + border-top: 1px solid var(--outline); +} + +body.home-page footer { + margin-top: 4rem; +} + +body.home-page footer a { + transition: color 0.2s ease; +} + +@media (max-width: 768px) { + body.home-page .logo { + font-size: 2.5rem; + } + + body.home-page .hero h1 { + font-size: 2rem; + } + + body.home-page .hero p { + font-size: 1rem; + } + + body.home-page .platforms { + grid-template-columns: 1fr; + } + + body.home-page .features { + grid-template-columns: 1fr; + } + + body.home-page .container { + padding: 1.5rem; + } +} + +body.legal-page .top-nav { + display: flex; + justify-content: flex-start; + margin-bottom: 2rem; +} + +body.legal-page .top-nav a { + font-size: 0.875rem; +} + +body.legal-page .logo { + font-size: 2.4rem; +} + +body.legal-page .logo-icon { + width: 2rem; + height: 2rem; +} + +body.legal-page .legal-card { + margin-top: 1.25rem; + padding: 2rem; +} + +body.legal-page h1 { + font-size: 2rem; + line-height: 1.2; + margin-bottom: 0.4rem; +} + +body.legal-page .meta { + margin-bottom: 1.5rem; +} + +body.legal-page .lead { + color: var(--text-secondary); + margin-bottom: 1.5rem; +} + +body.legal-page section { + margin-bottom: 1.25rem; +} + +body.legal-page section:last-of-type { + margin-bottom: 0; +} + +body.legal-page h2 { + font-size: 1.0625rem; + color: var(--primary); + margin-bottom: 0.45rem; +} + +body.legal-page p { + color: var(--text-secondary); + font-size: 0.95rem; + margin-bottom: 0.5rem; +} + +body.legal-page .links { + margin-top: 1.25rem; + background: var(--surface-variant); + border: 1px solid var(--outline); + border-radius: 0.75rem; + padding: 1rem 1.2rem; +} + +body.legal-page .links p { + margin-bottom: 0.35rem; +} + +body.legal-page .links p:last-child { + margin-bottom: 0; +} + +body.legal-page footer { + margin-top: 2rem; +} + +body.privacy-page .links p { + margin-bottom: 0; +} + +body.csae-page .lead { + margin-bottom: 1.25rem; +} + +body.csae-page .alert { + margin-bottom: 1.5rem; + background: rgba(255, 128, 128, 0.12); + border: 1px solid rgba(255, 128, 128, 0.35); + border-radius: 0.75rem; + padding: 0.85rem 1rem; + color: #ffd4d4; + font-size: 0.95rem; +} + +body.csae-page ul { + margin: 0.25rem 0 0.75rem 1.2rem; + color: var(--text-secondary); + font-size: 0.95rem; +} + +body.csae-page li { + margin-bottom: 0.35rem; +} + +@media (max-width: 768px) { + body.legal-page .container, + body.legal-page .legal-card { + padding: 1.25rem; + } + + body.legal-page h1 { + font-size: 1.6rem; + } +} + +.oauth-callback-container { + width: 100%; + display: flex; + align-items: center; + justify-content: center; +} + +.oauth-callback-main { + width: min(520px, 100%); + margin-top: 0; + padding: 2rem; + text-align: center; +} + +.oauth-callback-copy { + margin: 0 0 0.9rem; + line-height: 1.6; +} + +.oauth-callback-button { + appearance: none; + display: inline-block; + border: 0; + border-radius: 999px; + background: var(--secondary); + color: #ffffff; + text-decoration: none; + font-weight: 600; + padding: 12px 20px; + margin-top: 0.3rem; +} + +.oauth-callback-button:hover { + background: var(--primary); +} + +.oauth-callback-hint { + margin-top: 0.85rem; + margin-bottom: 0; +} diff --git a/www/terms.html b/www/terms.html index 915bcc5..c37e793 100644 --- a/www/terms.html +++ b/www/terms.html @@ -9,201 +9,14 @@ Lazurite Terms of Service + - - +