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: make settings available for unauthenticated users (#12)

* feat: make settings available for unauthenticated users

* feat: log redaction for sensitive information

* refactor: move Log access from "About" to "Advanced"

* feat: update routing for settings and related screens to remove login prefix

* feat: update log redaction to include additional sensitive keys

* make sure log files are cleaned up after share

* feat: make devtools route public and update settings navigation

* feat: log redaction for scalar key-value pairs

* safer settings navigation through stack

authored by

Owais and committed by
GitHub
44b20503 5320aa6b

+869 -197
+3 -1
lib/core/logging/app_file_log_printer.dart
··· 1 1 import 'dart:convert'; 2 2 3 3 import 'package:logger/logger.dart'; 4 + import 'package:lazurite/core/logging/log_redactor.dart'; 4 5 5 6 class AppFileLogPrinter extends LogPrinter { 6 7 AppFileLogPrinter(); ··· 58 59 } 59 60 60 61 String _sanitize(String value) { 61 - return value.split('\n').map((line) => line.trim()).where((line) => line.isNotEmpty).join(' | '); 62 + final collapsed = value.split('\n').map((line) => line.trim()).where((line) => line.isNotEmpty).join(' | '); 63 + return LogRedactor.redact(collapsed); 62 64 } 63 65 64 66 String? _sanitizeStackTrace(StackTrace? stackTrace) {
+39
lib/core/logging/log_redactor.dart
··· 1 + class LogRedactor { 2 + LogRedactor._(); 3 + 4 + static const String _sensitiveKeyPattern = 5 + r'access[_-]?token|refresh[_-]?token|token|authcode|password|client[_-]?secret|authorization|' 6 + r'dpop(?:[_-]?nonce|[_-]?private[_-]?key|[_-]?public[_-]?key|nonce|privatekey|publickey)|' 7 + r'app[_-]?password'; 8 + static const String _sensitiveJsonOnlyKeyPattern = r'code|state|authcode'; 9 + 10 + static final RegExp _jwtPattern = RegExp(r'\beyJ[A-Za-z0-9_-]{6,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\b'); 11 + static final RegExp _bearerPattern = RegExp(r'\bbearer\s+[A-Za-z0-9._~+\/-]+=*', caseSensitive: false); 12 + static final RegExp _sensitiveQueryParamPattern = RegExp( 13 + r'([?&](?:access_token|refresh_token|code|state|token|password|client_secret|authcode)=)([^&#\s|]+)', 14 + caseSensitive: false, 15 + ); 16 + static final RegExp _sensitiveJsonKeyValuePattern = RegExp( 17 + '("($_sensitiveKeyPattern|$_sensitiveJsonOnlyKeyPattern)"\\s*:\\s*)"(?:[^"\\\\]|\\\\.)*"', 18 + caseSensitive: false, 19 + ); 20 + static final RegExp _sensitiveScalarKeyValuePattern = RegExp( 21 + '(?<![?&])\\b($_sensitiveKeyPattern)\\b\\s*[:=]\\s*(?:"(?:[^"\\\\]|\\\\.)*"|\'(?:[^\'\\\\]|\\\\.)*\'|[^\\s,|}]+)', 22 + caseSensitive: false, 23 + ); 24 + static final RegExp _sensitiveScalarCodeStatePattern = RegExp( 25 + '(?<![?&])\\b($_sensitiveJsonOnlyKeyPattern)\\b\\s*=\\s*(?:"(?:[^"\\\\]|\\\\.)*"|\'(?:[^\'\\\\]|\\\\.)*\'|[^\\s,|}]+)', 26 + caseSensitive: false, 27 + ); 28 + 29 + static String redact(String input) { 30 + var redacted = input; 31 + redacted = redacted.replaceAllMapped(_sensitiveQueryParamPattern, (match) => '${match.group(1)}[REDACTED]'); 32 + redacted = redacted.replaceAll(_bearerPattern, 'Bearer [REDACTED]'); 33 + redacted = redacted.replaceAllMapped(_sensitiveJsonKeyValuePattern, (match) => '${match.group(1)}"[REDACTED]"'); 34 + redacted = redacted.replaceAllMapped(_sensitiveScalarKeyValuePattern, (match) => '${match.group(1)}: [REDACTED]'); 35 + redacted = redacted.replaceAllMapped(_sensitiveScalarCodeStatePattern, (match) => '${match.group(1)}: [REDACTED]'); 36 + redacted = redacted.replaceAll(_jwtPattern, '[REDACTED_JWT]'); 37 + return redacted; 38 + } 39 + }
+77 -55
lib/core/router/app_router.dart
··· 1 1 import 'dart:async'; 2 2 3 + import 'package:atproto/atproto.dart' as atp; 3 4 import 'package:atproto_core/atproto_core.dart' show AtUri; 4 5 import 'package:bluesky/bluesky.dart'; 5 6 import 'package:flutter/material.dart'; ··· 18 19 import 'package:lazurite/features/auth/presentation/oauth_callback_screen.dart'; 19 20 import 'package:lazurite/features/compose/bloc/compose_bloc.dart'; 20 21 import 'package:lazurite/features/compose/presentation/compose_route_args.dart'; 22 + import 'package:lazurite/features/devtools/cubit/dev_tools_cubit.dart'; 21 23 import 'package:lazurite/features/compose/presentation/compose_screen.dart'; 22 24 import 'package:lazurite/features/devtools/presentation/dev_tools_screen.dart'; 23 25 import 'package:lazurite/features/feed/presentation/feed_detail_screen.dart'; ··· 104 106 final path = state.uri.path; 105 107 final publicPaths = { 106 108 '/login', 109 + '/settings', 110 + '/settings/about', 111 + '/settings/logs', 112 + '/settings/devtools', 107 113 '/terms', 108 114 '/privacy', 109 115 OAuthCallbackScreen.routePath, ··· 125 131 }, 126 132 routes: [ 127 133 GoRoute(path: '/login', pageBuilder: (context, state) => _page(context, state, const LoginScreen())), 134 + GoRoute( 135 + path: '/settings', 136 + pageBuilder: (context, state) => _page(context, state, const SettingsScreen()), 137 + routes: [ 138 + GoRoute( 139 + path: 'moderation', 140 + pageBuilder: (context, state) => _page(context, state, const ModerationSettingsScreen()), 141 + routes: [ 142 + GoRoute( 143 + path: 'detail', 144 + pageBuilder: (context, state) => 145 + _page(context, state, LabelerDetailScreen(did: state.uri.queryParameters['did'] ?? '')), 146 + ), 147 + ], 148 + ), 149 + GoRoute(path: 'about', pageBuilder: (context, state) => _page(context, state, const AboutScreen())), 150 + GoRoute(path: 'logs', pageBuilder: (context, state) => _page(context, state, const LogsScreen())), 151 + GoRoute( 152 + path: 'clean-follows', 153 + pageBuilder: (context, state) => _page( 154 + context, 155 + state, 156 + BlocProvider( 157 + create: (_) => FollowAuditCubit( 158 + repository: FollowAuditRepository( 159 + bluesky: context.read<Bluesky>(), 160 + appViewProviderResolver: () => context.read<SettingsCubit>().state.appViewProvider, 161 + ), 162 + ownDid: context.read<String>(), 163 + ), 164 + child: const FollowAuditScreen(), 165 + ), 166 + ), 167 + ), 168 + GoRoute( 169 + path: 'devtools', 170 + pageBuilder: (context, state) => _page(context, state, _buildDevToolsRoute(context, state)), 171 + ), 172 + GoRoute( 173 + path: 'video-limits', 174 + pageBuilder: (context, state) => _page( 175 + context, 176 + state, 177 + BlocProvider( 178 + create: (_) => VideoUploadLimitsCubit(repository: context.read<VideoRepository>()), 179 + child: const VideoUploadLimitsScreen(), 180 + ), 181 + ), 182 + ), 183 + ], 184 + ), 128 185 GoRoute( 129 186 path: OAuthCallbackScreen.routePath, 130 187 parentNavigatorKey: _rootNavigatorKey, ··· 393 450 path: 'trending', 394 451 pageBuilder: (context, state) => _page(context, state, const TrendingScreen()), 395 452 ), 396 - GoRoute( 397 - path: 'settings', 398 - pageBuilder: (context, state) => _page(context, state, const SettingsScreen()), 399 - routes: [ 400 - GoRoute( 401 - path: 'moderation', 402 - pageBuilder: (context, state) => _page(context, state, const ModerationSettingsScreen()), 403 - routes: [ 404 - GoRoute( 405 - path: 'detail', 406 - pageBuilder: (context, state) => 407 - _page(context, state, LabelerDetailScreen(did: state.uri.queryParameters['did'] ?? '')), 408 - ), 409 - ], 410 - ), 411 - GoRoute( 412 - path: 'about', 413 - pageBuilder: (context, state) => _page(context, state, const AboutScreen()), 414 - ), 415 - GoRoute(path: 'logs', pageBuilder: (context, state) => _page(context, state, const LogsScreen())), 416 - GoRoute( 417 - path: 'clean-follows', 418 - pageBuilder: (context, state) => _page( 419 - context, 420 - state, 421 - BlocProvider( 422 - create: (_) => FollowAuditCubit( 423 - repository: FollowAuditRepository( 424 - bluesky: context.read<Bluesky>(), 425 - appViewProviderResolver: () => context.read<SettingsCubit>().state.appViewProvider, 426 - ), 427 - ownDid: context.read<String>(), 428 - ), 429 - child: const FollowAuditScreen(), 430 - ), 431 - ), 432 - ), 433 - GoRoute( 434 - path: 'devtools', 435 - pageBuilder: (context, state) => 436 - _page(context, state, DevToolsScreen(initialQuery: state.uri.queryParameters['query'])), 437 - ), 438 - GoRoute( 439 - path: 'video-limits', 440 - pageBuilder: (context, state) => _page( 441 - context, 442 - state, 443 - BlocProvider( 444 - create: (_) => VideoUploadLimitsCubit(repository: context.read<VideoRepository>()), 445 - child: const VideoUploadLimitsScreen(), 446 - ), 447 - ), 448 - ), 449 - ], 450 - ), 451 453 ], 452 454 ), 453 455 ], ··· 590 592 } catch (_) { 591 593 return null; 592 594 } 595 + } 596 + 597 + Widget _buildDevToolsRoute(BuildContext context, GoRouterState state) { 598 + atp.ATProto atproto; 599 + try { 600 + atproto = context.read<Bluesky>().atproto; 601 + } catch (_) { 602 + final providerKey = context.read<SettingsCubit>().state.appViewProvider; 603 + final provider = AppViewProviders.descriptorForSetting(providerKey); 604 + atproto = atp.ATProto.anonymous( 605 + service: provider.publicBaseUrl.host, 606 + getClient: XrpcNetworkInterceptor.wrapGetClient(), 607 + postClient: XrpcNetworkInterceptor.wrapPostClient(), 608 + ); 609 + } 610 + 611 + return BlocProvider( 612 + create: (_) => DevToolsCubit(atproto: atproto), 613 + child: DevToolsScreen(initialQuery: state.uri.queryParameters['query']), 614 + ); 593 615 } 594 616 } 595 617
+10
lib/features/auth/presentation/login_screen.dart
··· 117 117 final colorScheme = theme.colorScheme; 118 118 119 119 return Scaffold( 120 + appBar: AppBar( 121 + automaticallyImplyLeading: false, 122 + actions: [ 123 + IconButton( 124 + tooltip: 'Settings', 125 + icon: const Icon(Icons.settings_outlined), 126 + onPressed: () => context.push('/settings'), 127 + ), 128 + ], 129 + ), 120 130 body: DecoratedBox( 121 131 decoration: BoxDecoration( 122 132 gradient: LinearGradient(
+59 -2
lib/features/logs/cubit/log_viewer_cubit.dart
··· 5 5 import 'package:flutter_bloc/flutter_bloc.dart'; 6 6 import 'package:logger/logger.dart'; 7 7 import 'package:lazurite/core/logging/app_logger.dart'; 8 + import 'package:lazurite/core/logging/log_redactor.dart'; 8 9 import 'package:lazurite/features/logs/data/log_entry.dart'; 9 10 10 11 part 'log_viewer_state.dart'; 11 12 12 13 class LogViewerCubit extends Cubit<LogViewerState> { 13 - LogViewerCubit({Duration refreshInterval = const Duration(seconds: 1)}) : super(LogViewerState.initial()) { 14 + LogViewerCubit({ 15 + Duration refreshInterval = const Duration(seconds: 1), 16 + Future<File?> Function()? todaysLogFileProvider, 17 + Directory Function()? systemTempDirectoryProvider, 18 + }) : _todaysLogFileProvider = todaysLogFileProvider ?? log.getTodaysLogFile, 19 + _systemTempDirectoryProvider = systemTempDirectoryProvider ?? (() => Directory.systemTemp), 20 + super(LogViewerState.initial()) { 14 21 unawaited(loadLogs()); 15 22 _refreshTimer = Timer.periodic(refreshInterval, (_) => unawaited(loadLogs(showLoading: false))); 16 23 } 17 24 25 + static const String _shareDirectoryName = 'lazurite_logs_share'; 26 + static const String _shareFileName = 'redacted_share.log'; 27 + static const String _legacyShareDirectoryPrefix = 'lazurite_logs_share_'; 28 + 29 + final Future<File?> Function() _todaysLogFileProvider; 30 + final Directory Function() _systemTempDirectoryProvider; 18 31 Timer? _refreshTimer; 19 32 bool _isLoading = false; 20 33 ··· 110 123 return filtered; 111 124 } 112 125 113 - Future<File?> getTodaysLogFile() => log.getTodaysLogFile(); 126 + Future<File?> getTodaysLogFile() async { 127 + final rawFile = await _todaysLogFileProvider(); 128 + if (rawFile == null || !await rawFile.exists()) { 129 + return null; 130 + } 131 + 132 + final systemTempDirectory = _systemTempDirectoryProvider(); 133 + await _cleanupLegacyShareTempDirectories(systemTempDirectory); 134 + 135 + final content = await rawFile.readAsString(); 136 + final redactedLines = content.split('\n').map(LogRedactor.redact).join('\n'); 137 + final shareDirectory = Directory('${systemTempDirectory.path}/$_shareDirectoryName'); 138 + if (!await shareDirectory.exists()) { 139 + await shareDirectory.create(recursive: true); 140 + } 141 + 142 + final redactedFile = File('${shareDirectory.path}/$_shareFileName'); 143 + await redactedFile.writeAsString(redactedLines, flush: true); 144 + return redactedFile; 145 + } 146 + 147 + Future<void> _cleanupLegacyShareTempDirectories(Directory systemTempDirectory) async { 148 + if (!await systemTempDirectory.exists()) { 149 + return; 150 + } 151 + 152 + await for (final entity in systemTempDirectory.list(followLinks: false)) { 153 + if (entity is! Directory) { 154 + continue; 155 + } 156 + 157 + final name = entity.uri.pathSegments.isNotEmpty 158 + ? entity.uri.pathSegments[entity.uri.pathSegments.length - 2] 159 + : ''; 160 + if (!name.startsWith(_legacyShareDirectoryPrefix)) { 161 + continue; 162 + } 163 + 164 + try { 165 + await entity.delete(recursive: true); 166 + } catch (_) { 167 + // Best-effort cleanup only. 168 + } 169 + } 170 + } 114 171 115 172 Future<void> clearAllLogs() async { 116 173 await log.clearAllLogs();
+11 -2
lib/features/logs/data/log_entry.dart
··· 1 1 import 'package:equatable/equatable.dart'; 2 2 import 'package:logger/logger.dart'; 3 + import 'package:lazurite/core/logging/log_redactor.dart'; 3 4 4 5 class LogEntry extends Equatable { 5 6 const LogEntry({required this.timestamp, required this.level, required this.message, this.source}); ··· 43 44 message = remaining.trim(); 44 45 } 45 46 46 - if (message.isEmpty && source == null) return null; 47 + final redactedSource = source == null ? null : LogRedactor.redact(source); 48 + final redactedMessage = LogRedactor.redact(message); 47 49 48 - return LogEntry(timestamp: timestamp ?? DateTime.now(), level: level, message: message, source: source); 50 + if (redactedMessage.isEmpty && redactedSource == null) return null; 51 + 52 + return LogEntry( 53 + timestamp: timestamp ?? DateTime.now(), 54 + level: level, 55 + message: redactedMessage, 56 + source: redactedSource, 57 + ); 49 58 } 50 59 51 60 static Level _parseLevel(String? levelChar) {
+152 -129
lib/features/settings/presentation/settings_screen.dart
··· 9 9 import 'package:lazurite/core/network/app_view_provider.dart'; 10 10 import 'package:lazurite/core/network/atproto_host_resolver.dart'; 11 11 import 'package:lazurite/core/network/xrpc_network_interceptor.dart'; 12 - import 'package:lazurite/core/router/app_shell.dart'; 13 12 import 'package:lazurite/core/theme/app_theme.dart'; 14 13 import 'package:lazurite/core/theme/feed_layout.dart'; 15 14 import 'package:lazurite/core/theme/theme_extensions.dart'; ··· 29 28 30 29 @override 31 30 Widget build(BuildContext context) { 31 + final authState = context.watch<AuthBloc>().state; 32 + final tokens = authState.tokens; 33 + final showAccountSettings = authState.isAuthenticated && tokens != null; 34 + final backFallbackRoute = showAccountSettings ? '/' : '/login'; 35 + 32 36 return Scaffold( 33 37 appBar: AppBar( 34 - leading: const AppShellMenuButton(), 38 + leading: IconButton( 39 + tooltip: 'Back', 40 + onPressed: () { 41 + final router = GoRouter.of(context); 42 + if (router.canPop()) { 43 + router.pop(); 44 + return; 45 + } 46 + router.go(backFallbackRoute); 47 + }, 48 + icon: const Icon(Icons.arrow_back), 49 + ), 35 50 title: _title(context), 36 - actions: [ 37 - IconButton( 38 - tooltip: 'Log Out', 39 - onPressed: () { 40 - context.read<AuthBloc>().add(const LogoutRequested()); 41 - }, 42 - icon: Icon(Icons.logout, color: context.colorScheme.error), 43 - ), 44 - ], 51 + actions: showAccountSettings 52 + ? [ 53 + IconButton( 54 + tooltip: 'Log Out', 55 + onPressed: () { 56 + context.read<AuthBloc>().add(const LogoutRequested()); 57 + }, 58 + icon: Icon(Icons.logout, color: context.colorScheme.error), 59 + ), 60 + ] 61 + : null, 45 62 ), 46 63 body: ListView( 47 64 children: [ 48 - BlocBuilder<AuthBloc, AuthState>( 49 - builder: (context, authState) { 50 - final tokens = authState.tokens; 51 - if (!authState.isAuthenticated || tokens == null) { 52 - return const SizedBox.shrink(); 53 - } 65 + if (showAccountSettings) 66 + BlocBuilder<AccountSwitcherCubit, AccountSwitcherState>( 67 + builder: (context, switcherState) { 68 + final authenticatedTokens = tokens; 69 + final subtitle = switcherState.accounts.length > 1 70 + ? '${switcherState.accounts.length} accounts — tap to switch' 71 + : '@${authenticatedTokens.handle}'; 54 72 55 - return BlocBuilder<AccountSwitcherCubit, AccountSwitcherState>( 56 - builder: (context, switcherState) { 57 - final subtitle = switcherState.accounts.length > 1 58 - ? '${switcherState.accounts.length} accounts — tap to switch' 59 - : '@${tokens.handle}'; 60 - 61 - return ListTile( 62 - leading: ProfileAvatar(size: 40, fallbackText: tokens.displayName ?? tokens.handle), 63 - title: Text(tokens.displayName ?? tokens.handle), 64 - subtitle: Text(subtitle), 65 - trailing: const Icon(Icons.chevron_right), 66 - onTap: () => showAccountSwitcherSheet(context), 67 - ); 68 - }, 69 - ); 70 - }, 71 - ), 73 + return ListTile( 74 + leading: ProfileAvatar( 75 + size: 40, 76 + fallbackText: authenticatedTokens.displayName ?? authenticatedTokens.handle, 77 + ), 78 + title: Text(authenticatedTokens.displayName ?? authenticatedTokens.handle), 79 + subtitle: Text(subtitle), 80 + trailing: const Icon(Icons.chevron_right), 81 + onTap: () => showAccountSwitcherSheet(context), 82 + ); 83 + }, 84 + ), 72 85 const SizedBox(height: 24), 73 86 _buildSectionHeader(context, 'Appearance'), 74 87 _buildThemeSelector(context), 75 88 const SizedBox(height: 24), 76 89 _buildSectionHeader(context, 'Layout'), 77 90 _buildLayoutSettings(context), 78 - const SizedBox(height: 24), 79 - _buildSectionHeader(context, 'Moderation'), 80 - const _ModerationSettingsPreview(), 91 + if (showAccountSettings) ...[ 92 + const SizedBox(height: 24), 93 + _buildSectionHeader(context, 'Moderation'), 94 + const _ModerationSettingsPreview(), 95 + ], 81 96 const SizedBox(height: 24), 82 97 _buildSectionHeader(context, 'Search'), 83 - _buildSearchSettings(context), 84 - const SizedBox(height: 24), 85 - _buildSectionHeader(context, 'Account'), 86 - const _AtProtocolConnectionCard(), 87 - const SizedBox(height: 12), 88 - _SettingsTile( 89 - icon: Icons.dynamic_feed_outlined, 90 - title: 'Feeds', 91 - subtitle: 'Manage pinned and saved feeds', 92 - onTap: () => context.push('/feeds'), 93 - ), 94 - _SettingsTile( 95 - icon: Icons.bookmark_outline, 96 - title: 'Bookmarks & Likes', 97 - subtitle: 'View your bookmarked and liked posts', 98 - onTap: () => context.push('/bookmarks'), 99 - ), 100 - _SettingsTile( 101 - icon: Icons.videocam_outlined, 102 - title: 'Video Upload Limits', 103 - subtitle: 'Check your daily video quota', 104 - onTap: () => context.push('/settings/video-limits'), 105 - ), 106 - const SizedBox(height: 24), 107 - _buildSectionHeader(context, 'Account Maintenance'), 108 - _SettingsTile( 109 - icon: Icons.cleaning_services_outlined, 110 - title: 'Clean Follows', 111 - subtitle: 'Audit and unfollow problematic accounts in bulk', 112 - onTap: () => context.push('/settings/clean-follows'), 113 - ), 98 + _buildSearchSettings(context, showTypeaheadSettings: showAccountSettings), 99 + if (showAccountSettings) ...[ 100 + const SizedBox(height: 24), 101 + _buildSectionHeader(context, 'Account'), 102 + const _AtProtocolConnectionCard(), 103 + const SizedBox(height: 12), 104 + _SettingsTile( 105 + icon: Icons.dynamic_feed_outlined, 106 + title: 'Feeds', 107 + subtitle: 'Manage pinned and saved feeds', 108 + onTap: () => context.push('/feeds'), 109 + ), 110 + _SettingsTile( 111 + icon: Icons.bookmark_outline, 112 + title: 'Bookmarks & Likes', 113 + subtitle: 'View your bookmarked and liked posts', 114 + onTap: () => context.push('/bookmarks'), 115 + ), 116 + _SettingsTile( 117 + icon: Icons.videocam_outlined, 118 + title: 'Video Upload Limits', 119 + subtitle: 'Check your daily video quota', 120 + onTap: () => context.push('/settings/video-limits'), 121 + ), 122 + const SizedBox(height: 24), 123 + _buildSectionHeader(context, 'Account Maintenance'), 124 + _SettingsTile( 125 + icon: Icons.cleaning_services_outlined, 126 + title: 'Clean Follows', 127 + subtitle: 'Audit and unfollow problematic accounts in bulk', 128 + onTap: () => context.push('/settings/clean-follows'), 129 + ), 130 + ], 114 131 const SizedBox(height: 24), 115 132 _buildSectionHeader(context, 'Advanced'), 116 133 _buildAdvancedSettings(context), ··· 131 148 onTap: () => context.push('/settings/devtools'), 132 149 ), 133 150 _SettingsTile( 134 - icon: Icons.description_outlined, 135 - title: 'Logs', 136 - subtitle: 'View app log files', 137 - onTap: () => context.push('/settings/logs'), 138 - ), 139 - _SettingsTile( 140 151 icon: Icons.info_outline, 141 152 title: 'About', 142 153 subtitle: 'Stormlight Labs', ··· 154 165 subtitle: 'How Lazurite handles data', 155 166 onTap: () => context.push('/privacy'), 156 167 ), 157 - const SizedBox(height: 24), 158 - _buildSectionHeader(context, 'Danger Zone'), 159 - _SettingsTile( 160 - icon: Icons.logout, 161 - title: 'Log Out', 162 - isDestructive: true, 163 - onTap: () { 164 - context.read<AuthBloc>().add(const LogoutRequested()); 165 - }, 166 - ), 168 + if (showAccountSettings) ...[ 169 + const SizedBox(height: 24), 170 + _buildSectionHeader(context, 'Danger Zone'), 171 + _SettingsTile( 172 + icon: Icons.logout, 173 + title: 'Log Out', 174 + isDestructive: true, 175 + onTap: () { 176 + context.read<AuthBloc>().add(const LogoutRequested()); 177 + }, 178 + ), 179 + ], 167 180 const SizedBox(height: 24), 168 181 Center(child: Text('Lazurite v1.0.0', style: context.textTheme.bodySmall)), 169 182 const SizedBox(height: 24), ··· 309 322 ); 310 323 } 311 324 312 - Widget _buildSearchSettings(BuildContext context) => BlocBuilder<SettingsCubit, SettingsState>( 313 - builder: (context, settingsState) { 314 - final theme = Theme.of(context); 315 - return Container( 316 - decoration: BoxDecoration( 317 - border: Border( 318 - top: BorderSide(color: theme.dividerColor), 319 - bottom: BorderSide(color: theme.dividerColor), 320 - ), 321 - color: theme.cardColor, 322 - ), 323 - child: Column( 324 - children: [ 325 - ListTile( 326 - leading: const Icon(Icons.tune_outlined), 327 - title: const Text('Typeahead Provider'), 328 - subtitle: Text( 329 - settingsState.typeaheadProvider == 'community' 330 - ? 'Community (waow.tech) selected. Third-party service, works before login.' 331 - : 'Bluesky official endpoint selected.', 325 + Widget _buildSearchSettings(BuildContext context, {required bool showTypeaheadSettings}) => 326 + BlocBuilder<SettingsCubit, SettingsState>( 327 + builder: (context, settingsState) { 328 + final theme = Theme.of(context); 329 + return Container( 330 + decoration: BoxDecoration( 331 + border: Border( 332 + top: BorderSide(color: theme.dividerColor), 333 + bottom: BorderSide(color: theme.dividerColor), 332 334 ), 335 + color: theme.cardColor, 333 336 ), 334 - Padding( 335 - padding: const EdgeInsets.fromLTRB(16, 0, 16, 12), 336 - child: Align( 337 - alignment: Alignment.centerLeft, 338 - child: SegmentedButton<String>( 339 - segments: const [ 340 - ButtonSegment<String>(value: 'bluesky', label: Text('Bluesky')), 341 - ButtonSegment<String>(value: 'community', label: Text('Community')), 342 - ], 343 - selected: {settingsState.typeaheadProvider}, 344 - onSelectionChanged: (selection) { 345 - context.read<SettingsCubit>().setTypeaheadProvider(selection.first); 346 - }, 337 + child: Column( 338 + children: [ 339 + if (showTypeaheadSettings) ...[ 340 + ListTile( 341 + leading: const Icon(Icons.tune_outlined), 342 + title: const Text('Typeahead Provider'), 343 + subtitle: Text( 344 + settingsState.typeaheadProvider == 'community' 345 + ? 'Community (waow.tech) selected. Third-party service.' 346 + : 'Bluesky official endpoint selected.', 347 + ), 348 + ), 349 + Padding( 350 + padding: const EdgeInsets.fromLTRB(16, 0, 16, 12), 351 + child: Align( 352 + alignment: Alignment.centerLeft, 353 + child: SegmentedButton<String>( 354 + segments: const [ 355 + ButtonSegment<String>(value: 'bluesky', label: Text('Bluesky')), 356 + ButtonSegment<String>(value: 'community', label: Text('Community')), 357 + ], 358 + selected: {settingsState.typeaheadProvider}, 359 + onSelectionChanged: (selection) { 360 + context.read<SettingsCubit>().setTypeaheadProvider(selection.first); 361 + }, 362 + ), 363 + ), 364 + ), 365 + const Divider(height: 1), 366 + ], 367 + const _SettingsTile( 368 + icon: Icons.manage_search_outlined, 369 + title: 'Semantic Search', 370 + subtitle: 'Manage semantic search from Bookmarks & Likes -> Search', 347 371 ), 348 - ), 372 + ], 349 373 ), 350 - const Divider(height: 1), 351 - const _SettingsTile( 352 - icon: Icons.manage_search_outlined, 353 - title: 'Semantic Search', 354 - subtitle: 'Manage semantic search from Bookmarks & Likes -> Search', 355 - ), 356 - ], 357 - ), 374 + ); 375 + }, 358 376 ); 359 - }, 360 - ); 361 377 362 378 Widget _buildDeveloperSettings(BuildContext context) { 363 379 final settingsCubit = context.read<SettingsCubit>(); ··· 390 406 trailing: const Icon(Icons.warning_amber_rounded), 391 407 onTap: crashReportingService?.crash, 392 408 ), 393 - if (kDebugMode) ...[ 409 + if (kDebugMode || kProfileMode) ...[ 394 410 const Divider(height: 1), 395 411 _SettingsTile( 396 412 icon: Icons.lock_reset_outlined, ··· 433 449 ), 434 450 child: Column( 435 451 children: [ 452 + _SettingsTile( 453 + icon: Icons.description_outlined, 454 + title: 'Logs', 455 + subtitle: 'View app log files', 456 + onTap: () => context.push('/settings/logs'), 457 + ), 458 + const Divider(height: 1), 436 459 _ConstellationUrlTile(currentUrl: state.constellationUrl), 437 460 const Divider(height: 1), 438 461 _SettingsTile(
+44
test/core/logging/app_file_log_printer_test.dart
··· 31 31 32 32 expect(lines.single, startsWith('[FATAL] TIME: 2026-03-16T14:32:12.450')); 33 33 }); 34 + 35 + test('redacts secrets while preserving handle and DID context', () { 36 + final printer = AppFileLogPrinter(); 37 + const jwt = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJkaWQ6cGxjOnRlc3QifQ.signaturevaluehere12345'; 38 + final lines = printer.log( 39 + LogEvent( 40 + Level.info, 41 + 'AuthRepository: OAuth callback for did:plc:ewvi7nxzyoun6zhxrhs64oiz ' 42 + 'handle user.bsky.social redirect /oauth/callback?code=abc123&state=xyz ' 43 + 'token $jwt', 44 + time: DateTime(2026, 3, 16, 14, 32, 12, 450), 45 + ), 46 + ); 47 + 48 + expect(lines.single, contains('did:plc:ewvi7nxzyoun6zhxrhs64oiz')); 49 + expect(lines.single, contains('user.bsky.social')); 50 + expect(lines.single, isNot(contains(jwt))); 51 + expect(lines.single, isNot(contains('code=abc123'))); 52 + expect(lines.single, isNot(contains('state=xyz'))); 53 + expect(lines.single, contains('[REDACTED_JWT]')); 54 + expect(lines.single, contains('code=[REDACTED]')); 55 + expect(lines.single, contains('state=[REDACTED]')); 56 + }); 57 + 58 + test('redacts sensitive values in structured map logs', () { 59 + final printer = AppFileLogPrinter(); 60 + final lines = printer.log( 61 + LogEvent(Level.info, { 62 + 'access_token': 'token123', 63 + 'refresh_token': 'refresh456', 64 + 'dpop_public_key': 'pubkey', 65 + 'dpop_private_key': 'privkey', 66 + }, time: DateTime(2026, 3, 16, 14, 32, 12, 450)), 67 + ); 68 + 69 + expect(lines.single, contains('"access_token":"[REDACTED]"')); 70 + expect(lines.single, contains('"refresh_token":"[REDACTED]"')); 71 + expect(lines.single, contains('"dpop_public_key":"[REDACTED]"')); 72 + expect(lines.single, contains('"dpop_private_key":"[REDACTED]"')); 73 + expect(lines.single, isNot(contains('token123'))); 74 + expect(lines.single, isNot(contains('refresh456'))); 75 + expect(lines.single, isNot(contains('pubkey'))); 76 + expect(lines.single, isNot(contains('privkey'))); 77 + }); 34 78 }); 35 79 }
+80
test/core/logging/log_redactor_test.dart
··· 1 + import 'package:flutter_test/flutter_test.dart'; 2 + import 'package:lazurite/core/logging/log_redactor.dart'; 3 + 4 + void main() { 5 + group('LogRedactor', () { 6 + test('preserves DID and handle identifiers for debugging context', () { 7 + const input = 'Profile load for did:plc:ewvi7nxzyoun6zhxrhs64oiz @river.bsky.social'; 8 + final output = LogRedactor.redact(input); 9 + 10 + expect(output, contains('did:plc:ewvi7nxzyoun6zhxrhs64oiz')); 11 + expect(output, contains('@river.bsky.social')); 12 + }); 13 + 14 + test('redacts sensitive query parameter values', () { 15 + const input = '/oauth/callback?code=abc123&state=xyz&did=did:plc:abc'; 16 + final output = LogRedactor.redact(input); 17 + 18 + expect(output, contains('code=[REDACTED]')); 19 + expect(output, contains('state=[REDACTED]')); 20 + expect(output, contains('did=did:plc:abc')); 21 + }); 22 + 23 + test('redacts sensitive key-value pairs and bearer tokens', () { 24 + const input = 'authorization=Bearer super-secret access_token=token123 app_password=hunter2'; 25 + final output = LogRedactor.redact(input); 26 + 27 + expect(output, isNot(contains('super-secret'))); 28 + expect(output, isNot(contains('token123'))); 29 + expect(output, isNot(contains('hunter2'))); 30 + expect(output, contains('authorization: [REDACTED]')); 31 + expect(output, contains('access_token: [REDACTED]')); 32 + expect(output, contains('app_password: [REDACTED]')); 33 + }); 34 + 35 + test('redacts JSON-formatted sensitive key values', () { 36 + const input = '{"access_token":"token123","refresh_token":"refresh456","state":"xyz"}'; 37 + final output = LogRedactor.redact(input); 38 + 39 + expect(output, contains('"access_token":"[REDACTED]"')); 40 + expect(output, contains('"refresh_token":"[REDACTED]"')); 41 + expect(output, contains('"state":"[REDACTED]"')); 42 + expect(output, isNot(contains('token123'))); 43 + expect(output, isNot(contains('refresh456'))); 44 + expect(output, isNot(contains('"state":"xyz"'))); 45 + }); 46 + 47 + test('redacts snake_case DPoP key fields', () { 48 + const input = 49 + '{"dpop_public_key":"pubKeyValue","dpop_private_key":"privKeyValue"} ' 50 + 'dpop_public_key=pubRaw dpop_private_key=privRaw'; 51 + final output = LogRedactor.redact(input); 52 + 53 + expect(output, contains('"dpop_public_key":"[REDACTED]"')); 54 + expect(output, contains('"dpop_private_key":"[REDACTED]"')); 55 + expect(output, contains('dpop_public_key: [REDACTED]')); 56 + expect(output, contains('dpop_private_key: [REDACTED]')); 57 + expect(output, isNot(contains('pubKeyValue'))); 58 + expect(output, isNot(contains('privKeyValue'))); 59 + expect(output, isNot(contains('pubRaw'))); 60 + expect(output, isNot(contains('privRaw'))); 61 + }); 62 + 63 + test('redacts scalar code and state key-value fragments', () { 64 + const input = 'oauth callback fragment: state=xyz123 code=abc456 did=did:plc:alice'; 65 + final output = LogRedactor.redact(input); 66 + 67 + expect(output, contains('state: [REDACTED]')); 68 + expect(output, contains('code: [REDACTED]')); 69 + expect(output, isNot(contains('xyz123'))); 70 + expect(output, isNot(contains('abc456'))); 71 + expect(output, contains('did=did:plc:alice')); 72 + }); 73 + 74 + test('preserves non-sensitive text', () { 75 + const input = 'NavObserver: Route pushed: /profile/me (from /)'; 76 + final output = LogRedactor.redact(input); 77 + expect(output, input); 78 + }); 79 + }); 80 + }
+100 -3
test/core/router/app_router_test.dart
··· 401 401 expect(find.byTooltip('Open menu'), findsOneWidget); 402 402 }); 403 403 404 - testWidgets('redirects to login after logout without crashing on the settings route', (tester) async { 404 + testWidgets('stays on public settings after logout without crashing', (tester) async { 405 405 await tester.binding.setSurfaceSize(const Size(430, 932)); 406 406 addTearDown(() => tester.binding.setSurfaceSize(null)); 407 407 ··· 454 454 await tester.pump(); 455 455 await tester.pumpAndSettle(); 456 456 457 - expect(find.text('Continue'), findsOneWidget); 457 + expect(find.text('APPEARANCE'), findsOneWidget); 458 + expect(find.byTooltip('Log Out'), findsNothing); 458 459 expect(tester.takeException(), isNull); 459 460 460 461 router.dispose(); 461 462 }); 462 463 463 - testWidgets('allows unauthenticated access to privacy and terms routes', (tester) async { 464 + testWidgets('allows unauthenticated access to public settings, devtools, privacy, and terms routes', (tester) async { 464 465 currentAuthState = const AuthState.unauthenticated(); 465 466 when(() => authBloc.state).thenReturn(currentAuthState); 466 467 whenListen(authBloc, Stream<AuthState>.value(currentAuthState), initialState: currentAuthState); ··· 478 479 ); 479 480 await tester.pumpAndSettle(); 480 481 482 + router.go('/settings'); 483 + await tester.pumpAndSettle(); 484 + expect(find.text('APPEARANCE'), findsOneWidget); 485 + expect(find.text('ACCOUNT'), findsNothing); 486 + 487 + router.go('/settings/logs'); 488 + await tester.pumpAndSettle(); 489 + expect(find.text('Logs'), findsWidgets); 490 + 491 + router.go('/settings/about'); 492 + await tester.pumpAndSettle(); 493 + expect(find.text('About'), findsWidgets); 494 + 495 + router.go('/settings/devtools'); 496 + await tester.pumpAndSettle(); 497 + expect(find.text('PDS Explorer'), findsWidgets); 498 + 481 499 router.go('/privacy'); 482 500 await tester.pumpAndSettle(); 483 501 expect(find.text('Privacy Policy'), findsWidgets); ··· 485 503 router.go('/terms'); 486 504 await tester.pumpAndSettle(); 487 505 expect(find.text('Terms of Service'), findsWidgets); 506 + 507 + router.dispose(); 508 + }); 509 + 510 + testWidgets('unauthenticated settings back button falls back to login when there is no stack to pop', (tester) async { 511 + currentAuthState = const AuthState.unauthenticated(); 512 + when(() => authBloc.state).thenReturn(currentAuthState); 513 + whenListen(authBloc, Stream<AuthState>.value(currentAuthState), initialState: currentAuthState); 514 + 515 + final router = AppRouter(authBloc: authBloc).router; 516 + 517 + await tester.pumpWidget( 518 + MultiBlocProvider( 519 + providers: [ 520 + BlocProvider<AuthBloc>.value(value: authBloc), 521 + BlocProvider<SettingsCubit>.value(value: settingsCubit), 522 + ], 523 + child: MaterialApp.router(routerConfig: router), 524 + ), 525 + ); 526 + await tester.pumpAndSettle(); 527 + 528 + router.go('/settings'); 529 + await tester.pumpAndSettle(); 530 + expect(find.text('APPEARANCE'), findsOneWidget); 531 + 532 + await tester.tap(find.byTooltip('Back')); 533 + await tester.pumpAndSettle(); 534 + 535 + expect(find.text('Continue'), findsOneWidget); 536 + 537 + router.dispose(); 538 + }); 539 + 540 + testWidgets('authenticated settings back button falls back to home when there is no stack to pop', (tester) async { 541 + currentAuthState = const AuthState.authenticated(tokens); 542 + when(() => authBloc.state).thenReturn(currentAuthState); 543 + whenListen(authBloc, Stream<AuthState>.value(currentAuthState), initialState: currentAuthState); 544 + 545 + final router = AppRouter(authBloc: authBloc).router; 546 + 547 + await tester.pumpWidget(buildSubjectWithRouter(router)); 548 + await tester.pumpAndSettle(); 549 + 550 + router.go('/settings'); 551 + await tester.pumpAndSettle(); 552 + expect(find.text('APPEARANCE'), findsOneWidget); 553 + 554 + await tester.tap(find.byTooltip('Back')); 555 + await tester.pumpAndSettle(); 556 + 557 + expect(find.text('HOME'), findsAtLeastNWidgets(1)); 558 + expect(find.text('APPEARANCE'), findsNothing); 559 + 560 + router.dispose(); 561 + }); 562 + 563 + testWidgets('keeps account-scoped settings routes auth-gated when unauthenticated', (tester) async { 564 + currentAuthState = const AuthState.unauthenticated(); 565 + when(() => authBloc.state).thenReturn(currentAuthState); 566 + whenListen(authBloc, Stream<AuthState>.value(currentAuthState), initialState: currentAuthState); 567 + 568 + final router = AppRouter(authBloc: authBloc).router; 569 + 570 + await tester.pumpWidget( 571 + MultiBlocProvider( 572 + providers: [ 573 + BlocProvider<AuthBloc>.value(value: authBloc), 574 + BlocProvider<SettingsCubit>.value(value: settingsCubit), 575 + ], 576 + child: MaterialApp.router(routerConfig: router), 577 + ), 578 + ); 579 + await tester.pumpAndSettle(); 580 + 581 + router.go('/settings/video-limits'); 582 + await tester.pumpAndSettle(); 583 + 584 + expect(find.text('Continue'), findsOneWidget); 488 585 489 586 router.dispose(); 490 587 });
+18 -5
test/features/auth/presentation/login_screen_test.dart
··· 61 61 path: '/privacy', 62 62 builder: (context, state) => const Scaffold(body: Text('privacy-route')), 63 63 ), 64 + GoRoute( 65 + path: '/settings', 66 + builder: (context, state) => const Scaffold(body: Text('public-settings-route')), 67 + ), 64 68 ], 65 69 initialLocation: '/login', 66 70 ); ··· 73 77 ); 74 78 } 75 79 76 - testWidgets('shows terms and privacy links', (tester) async { 80 + testWidgets('shows settings icon plus terms and privacy links', (tester) async { 77 81 await tester.pumpWidget(buildSubject()); 78 82 await tester.pumpAndSettle(); 79 83 ··· 82 86 await tester.scrollUntilVisible(find.text('Privacy Policy'), 200, scrollable: scrollable); 83 87 await tester.pumpAndSettle(); 84 88 89 + expect(find.byTooltip('Settings'), findsOneWidget); 90 + expect(find.text('Settings'), findsNothing); 85 91 expect(find.text('Terms of Service'), findsOneWidget); 86 92 expect(find.text('Privacy Policy'), findsOneWidget); 93 + }); 94 + 95 + testWidgets('tapping settings icon opens public settings route', (tester) async { 96 + await tester.pumpWidget(buildSubject()); 97 + await tester.pumpAndSettle(); 98 + 99 + await tester.tap(find.byTooltip('Settings')); 100 + await tester.pumpAndSettle(); 101 + 102 + expect(find.text('public-settings-route'), findsOneWidget); 87 103 }); 88 104 89 105 testWidgets('tapping Terms of Service opens terms route', (tester) async { ··· 173 189 final blackSkyRow = find.ancestor(of: find.text('BlackSky'), matching: find.byType(Row)); 174 190 final blackSkyLogo = find.descendant(of: blackSkyRow, matching: find.byType(SvgPicture)); 175 191 final blackSkySvg = tester.widget<SvgPicture>(blackSkyLogo.first); 176 - expect( 177 - blackSkySvg.colorFilter, 178 - const ColorFilter.mode(Color(0xFF6868B6), BlendMode.srcIn), 179 - ); 192 + expect(blackSkySvg.colorFilter, const ColorFilter.mode(Color(0xFF6868B6), BlendMode.srcIn)); 180 193 181 194 final blueSkyRow = find.ancestor(of: find.text('BlueSky'), matching: find.byType(Row)); 182 195 final blueSkyLogo = find.descendant(of: blueSkyRow, matching: find.byType(SvgPicture));
+90
test/features/logs/cubit/log_viewer_cubit_test.dart
··· 1 + import 'dart:io'; 2 + 1 3 import 'package:bloc_test/bloc_test.dart'; 2 4 import 'package:flutter_test/flutter_test.dart'; 3 5 import 'package:logger/logger.dart'; ··· 71 73 expect(cubit.state.searchQuery, 'test query'); 72 74 }, 73 75 ); 76 + 77 + test('getTodaysLogFile returns a redacted share copy', () async { 78 + final sourceDir = await Directory.systemTemp.createTemp('lazurite_log_viewer_test_'); 79 + final sourceFile = File('${sourceDir.path}/lazurite_2026-05-05.log'); 80 + await sourceFile.writeAsString( 81 + '[I] TIME: 2026-05-05T10:00:00.000 AuthRepository: ' 82 + 'Login for did:plc:ewvi7nxzyoun6zhxrhs64oiz river.bsky.social ' 83 + '/oauth/callback?code=abc123&state=xyz\n', 84 + ); 85 + 86 + final cubit = LogViewerCubit(todaysLogFileProvider: () async => sourceFile); 87 + addTearDown(() async { 88 + await cubit.close(); 89 + if (await sourceDir.exists()) { 90 + await sourceDir.delete(recursive: true); 91 + } 92 + }); 93 + 94 + final sharedFile = await cubit.getTodaysLogFile(); 95 + expect(sharedFile, isNotNull); 96 + expect(sharedFile!.path, isNot(equals(sourceFile.path))); 97 + addTearDown(() async { 98 + final shareDirectory = sharedFile.parent; 99 + if (await shareDirectory.exists()) { 100 + await shareDirectory.delete(recursive: true); 101 + } 102 + }); 103 + final sharedContent = await sharedFile.readAsString(); 104 + expect(sharedContent, contains('did:plc:ewvi7nxzyoun6zhxrhs64oiz')); 105 + expect(sharedContent, contains('river.bsky.social')); 106 + expect(sharedContent, isNot(contains('code=abc123'))); 107 + expect(sharedContent, isNot(contains('state=xyz'))); 108 + expect(sharedContent, contains('code=[REDACTED]')); 109 + expect(sharedContent, contains('state=[REDACTED]')); 110 + }); 111 + 112 + test('reuses a stable shared file path across repeated shares', () async { 113 + final tempRoot = await Directory.systemTemp.createTemp('lazurite_log_viewer_temp_root_'); 114 + final sourceDir = await tempRoot.createTemp('lazurite_log_source_'); 115 + final sourceFile = File('${sourceDir.path}/lazurite_2026-05-06.log'); 116 + await sourceFile.writeAsString('[I] TIME: 2026-05-06T10:00:00.000 code=abc123'); 117 + 118 + final cubit = LogViewerCubit( 119 + todaysLogFileProvider: () async => sourceFile, 120 + systemTempDirectoryProvider: () => tempRoot, 121 + ); 122 + addTearDown(() async { 123 + await cubit.close(); 124 + if (await tempRoot.exists()) { 125 + await tempRoot.delete(recursive: true); 126 + } 127 + }); 128 + 129 + final first = await cubit.getTodaysLogFile(); 130 + final second = await cubit.getTodaysLogFile(); 131 + 132 + expect(first, isNotNull); 133 + expect(second, isNotNull); 134 + expect(second!.path, equals(first!.path)); 135 + }); 136 + 137 + test('cleans up legacy per-share temp directories', () async { 138 + final tempRoot = await Directory.systemTemp.createTemp('lazurite_log_viewer_temp_root_'); 139 + final sourceDir = await tempRoot.createTemp('lazurite_log_source_'); 140 + final sourceFile = File('${sourceDir.path}/lazurite_2026-05-06.log'); 141 + await sourceFile.writeAsString('[I] TIME: 2026-05-06T10:00:00.000 state=xyz'); 142 + 143 + final legacyOne = await tempRoot.createTemp('lazurite_logs_share_'); 144 + final legacyTwo = await tempRoot.createTemp('lazurite_logs_share_'); 145 + await File('${legacyOne.path}/old.log').writeAsString('legacy 1'); 146 + await File('${legacyTwo.path}/old.log').writeAsString('legacy 2'); 147 + 148 + final cubit = LogViewerCubit( 149 + todaysLogFileProvider: () async => sourceFile, 150 + systemTempDirectoryProvider: () => tempRoot, 151 + ); 152 + addTearDown(() async { 153 + await cubit.close(); 154 + if (await tempRoot.exists()) { 155 + await tempRoot.delete(recursive: true); 156 + } 157 + }); 158 + 159 + final sharedFile = await cubit.getTodaysLogFile(); 160 + expect(sharedFile, isNotNull); 161 + expect(await legacyOne.exists(), isFalse); 162 + expect(await legacyTwo.exists(), isFalse); 163 + }); 74 164 }); 75 165 }
+13
test/features/logs/data/log_entry_test.dart
··· 70 70 expect(entry!.level, Level.debug); 71 71 expect(entry.message, 'Some random log message'); 72 72 }); 73 + 74 + test('redacts secret values while preserving handle and DID context', () { 75 + final entry = LogEntry.tryParse( 76 + '[I] AuthRepository: Login for did:plc:ewvi7nxzyoun6zhxrhs64oiz user.bsky.social ' 77 + '/oauth/callback?code=abc123&state=xyz', 78 + ); 79 + 80 + expect(entry, isNotNull); 81 + expect(entry!.message, contains('did:plc:ewvi7nxzyoun6zhxrhs64oiz')); 82 + expect(entry.message, contains('user.bsky.social')); 83 + expect(entry.message, contains('code=[REDACTED]')); 84 + expect(entry.message, contains('state=[REDACTED]')); 85 + }); 73 86 }); 74 87 75 88 group('levelPrefix', () {
+27
test/features/settings/presentation/search_settings_test.dart
··· 6 6 import 'package:lazurite/core/theme/feed_layout.dart'; 7 7 import 'package:lazurite/features/account/cubit/account_switcher_cubit.dart'; 8 8 import 'package:lazurite/features/auth/bloc/auth_bloc.dart'; 9 + import 'package:lazurite/features/auth/data/models/auth_models.dart'; 9 10 import 'package:lazurite/features/settings/bloc/settings_cubit.dart'; 10 11 import 'package:lazurite/features/settings/bloc/settings_state.dart'; 11 12 import 'package:lazurite/features/settings/presentation/settings_screen.dart'; ··· 27 28 ); 28 29 29 30 void main() { 31 + const tokens = AuthTokens( 32 + accessToken: 'access', 33 + refreshToken: 'refresh', 34 + did: 'did:plc:test', 35 + handle: 'test.bsky.social', 36 + displayName: 'Test User', 37 + ); 38 + 30 39 late MockAuthBloc authBloc; 31 40 late MockAccountSwitcherCubit accountSwitcherCubit; 32 41 late MockSettingsCubit settingsCubit; ··· 71 80 }); 72 81 73 82 testWidgets('shows typeahead provider selector', (tester) async { 83 + const authenticatedState = AuthState.authenticated(tokens); 84 + when(() => authBloc.state).thenReturn(authenticatedState); 85 + whenListen(authBloc, const Stream<AuthState>.empty(), initialState: authenticatedState); 86 + 74 87 await tester.pumpWidget(buildSubject()); 75 88 await tester.pumpAndSettle(); 76 89 await tester.scrollUntilVisible(find.text('Typeahead Provider'), 300); ··· 85 98 await tester.binding.setSurfaceSize(const Size(800, 2400)); 86 99 addTearDown(() => tester.binding.setSurfaceSize(null)); 87 100 101 + const authenticatedState = AuthState.authenticated(tokens); 102 + when(() => authBloc.state).thenReturn(authenticatedState); 103 + whenListen(authBloc, const Stream<AuthState>.empty(), initialState: authenticatedState); 104 + 88 105 await tester.pumpWidget(buildSubject()); 89 106 await tester.pumpAndSettle(); 90 107 await tester.scrollUntilVisible(find.text('Typeahead Provider'), 300); ··· 93 110 await tester.pumpAndSettle(); 94 111 95 112 verify(() => settingsCubit.setTypeaheadProvider('community')).called(1); 113 + }); 114 + 115 + testWidgets('hides typeahead provider selector when unauthenticated', (tester) async { 116 + await tester.pumpWidget(buildSubject()); 117 + await tester.pumpAndSettle(); 118 + await tester.scrollUntilVisible(find.text('Semantic Search'), 300); 119 + 120 + expect(find.text('Typeahead Provider'), findsNothing); 121 + expect(find.text('Community'), findsNothing); 122 + expect(find.text('Bluesky'), findsNothing); 96 123 }); 97 124 98 125 testWidgets('shows semantic search management hint', (tester) async {
+146
test/features/settings/presentation/settings_screen_test.dart
··· 158 158 return MaterialApp.router(routerConfig: router); 159 159 } 160 160 161 + Widget buildPublicRoutedSubject() { 162 + final router = GoRouter( 163 + routes: [ 164 + GoRoute( 165 + path: '/', 166 + builder: (context, state) => RepositoryProvider<CrashReportingService>.value( 167 + value: crashReportingService, 168 + child: MultiBlocProvider( 169 + providers: [ 170 + BlocProvider<AuthBloc>.value(value: authBloc), 171 + BlocProvider<AccountSwitcherCubit>.value(value: accountSwitcherCubit), 172 + BlocProvider<SettingsCubit>.value(value: settingsCubit), 173 + ], 174 + child: const SettingsScreen(), 175 + ), 176 + ), 177 + ), 178 + GoRoute( 179 + path: '/settings/about', 180 + builder: (context, state) => const Scaffold(body: Text('public-about-screen')), 181 + ), 182 + GoRoute( 183 + path: '/settings/logs', 184 + builder: (context, state) => const Scaffold(body: Text('public-logs-screen')), 185 + ), 186 + GoRoute( 187 + path: '/settings/devtools', 188 + builder: (context, state) => const Scaffold(body: Text('public-devtools-screen')), 189 + ), 190 + ], 191 + ); 192 + 193 + return MaterialApp.router(routerConfig: router); 194 + } 195 + 161 196 testWidgets('shows active settings controls that are wired up', (tester) async { 162 197 await tester.pumpWidget(buildSubject()); 163 198 await tester.pumpAndSettle(); ··· 303 338 await tester.pumpAndSettle(); 304 339 305 340 expect(find.text('ADVANCED'), findsOneWidget); 341 + expect(find.text('Logs'), findsOneWidget); 306 342 expect(find.text('Constellation URL'), findsOneWidget); 307 343 expect(find.text('https://constellation.microcosm.blue'), findsOneWidget); 308 344 expect(find.text('AppView Provider'), findsOneWidget); ··· 488 524 }); 489 525 490 526 testWidgets('shows Video Upload Limits tile in Account section', (tester) async { 527 + final tokens = _authenticatedTokens(); 528 + when(() => authBloc.state).thenReturn(AuthState.authenticated(tokens)); 529 + whenListen(authBloc, const Stream<AuthState>.empty(), initialState: AuthState.authenticated(tokens)); 530 + 491 531 await tester.pumpWidget(buildSubject()); 492 532 await tester.pumpAndSettle(); 493 533 ··· 499 539 }); 500 540 501 541 testWidgets('shows Account Maintenance section with Clean Follows tile', (tester) async { 542 + final tokens = _authenticatedTokens(); 543 + when(() => authBloc.state).thenReturn(AuthState.authenticated(tokens)); 544 + whenListen(authBloc, const Stream<AuthState>.empty(), initialState: AuthState.authenticated(tokens)); 545 + 502 546 await tester.pumpWidget(buildSubject()); 503 547 await tester.pumpAndSettle(); 504 548 ··· 521 565 expect(find.text('Privacy Policy'), findsOneWidget); 522 566 }); 523 567 568 + testWidgets('uses back button when unauthenticated', (tester) async { 569 + await tester.pumpWidget(buildSubject()); 570 + await tester.pumpAndSettle(); 571 + 572 + expect(find.byTooltip('Back'), findsOneWidget); 573 + expect(find.byTooltip('Open menu'), findsNothing); 574 + }); 575 + 576 + testWidgets('uses back button when authenticated', (tester) async { 577 + final tokens = _authenticatedTokens(); 578 + final authenticatedState = AuthState.authenticated(tokens); 579 + when(() => authBloc.state).thenReturn(authenticatedState); 580 + whenListen(authBloc, const Stream<AuthState>.empty(), initialState: authenticatedState); 581 + 582 + await tester.pumpWidget(buildSubject()); 583 + await tester.pumpAndSettle(); 584 + 585 + expect(find.byTooltip('Back'), findsOneWidget); 586 + expect(find.byTooltip('Open menu'), findsNothing); 587 + }); 588 + 589 + testWidgets('public mode hides account-gated sections and logout controls', (tester) async { 590 + await tester.pumpWidget( 591 + RepositoryProvider<CrashReportingService>.value( 592 + value: crashReportingService, 593 + child: MultiBlocProvider( 594 + providers: [ 595 + BlocProvider<AuthBloc>.value(value: authBloc), 596 + BlocProvider<AccountSwitcherCubit>.value(value: accountSwitcherCubit), 597 + BlocProvider<SettingsCubit>.value(value: settingsCubit), 598 + ], 599 + child: const MaterialApp(home: SettingsScreen()), 600 + ), 601 + ), 602 + ); 603 + await tester.pumpAndSettle(); 604 + 605 + expect(find.byTooltip('Log Out'), findsNothing); 606 + expect(find.text('ACCOUNT'), findsNothing); 607 + expect(find.text('ACCOUNT MAINTENANCE'), findsNothing); 608 + expect(find.text('DANGER ZONE'), findsNothing); 609 + expect(find.text('Video Upload Limits'), findsNothing); 610 + expect(find.text('Clean Follows'), findsNothing); 611 + expect(find.text('Typeahead Provider'), findsNothing); 612 + expect(find.text('APPEARANCE'), findsOneWidget); 613 + await tester.scrollUntilVisible(find.text('AT Explorer'), 300); 614 + await tester.pumpAndSettle(); 615 + expect(find.text('AT Explorer'), findsOneWidget); 616 + await tester.scrollUntilVisible(find.text('Terms of Service'), 300); 617 + await tester.pumpAndSettle(); 618 + expect(find.text('Terms of Service'), findsOneWidget); 619 + }); 620 + 621 + testWidgets('public mode Logs and About rows use public /settings routes', (tester) async { 622 + await tester.pumpWidget(buildPublicRoutedSubject()); 623 + await tester.pumpAndSettle(); 624 + 625 + final logsTile = find.widgetWithText(ListTile, 'Logs'); 626 + await tester.scrollUntilVisible(logsTile, 300); 627 + await tester.pumpAndSettle(); 628 + await tester.tap(logsTile); 629 + await tester.pumpAndSettle(); 630 + expect(find.text('public-logs-screen'), findsOneWidget); 631 + 632 + final router = GoRouter.of(tester.element(find.text('public-logs-screen'))); 633 + router.go('/'); 634 + await tester.pumpAndSettle(); 635 + 636 + final aboutTile = find.widgetWithText(ListTile, 'About'); 637 + await tester.scrollUntilVisible(aboutTile, 300); 638 + await tester.pumpAndSettle(); 639 + await tester.tap(aboutTile); 640 + await tester.pumpAndSettle(); 641 + expect(find.text('public-about-screen'), findsOneWidget); 642 + }); 643 + 644 + testWidgets('public mode AT Explorer row uses public /settings/devtools route', (tester) async { 645 + await tester.pumpWidget(buildPublicRoutedSubject()); 646 + await tester.pumpAndSettle(); 647 + 648 + await tester.scrollUntilVisible(find.text('AT Explorer'), 300); 649 + await tester.pumpAndSettle(); 650 + await tester.tap(find.text('AT Explorer')); 651 + await tester.pumpAndSettle(); 652 + 653 + expect(find.text('public-devtools-screen'), findsOneWidget); 654 + }); 655 + 524 656 testWidgets('tapping Clean Follows tile navigates to clean follows screen', (tester) async { 657 + final tokens = _authenticatedTokens(); 658 + when(() => authBloc.state).thenReturn(AuthState.authenticated(tokens)); 659 + whenListen(authBloc, const Stream<AuthState>.empty(), initialState: AuthState.authenticated(tokens)); 660 + 525 661 await tester.pumpWidget(buildRoutedSubject()); 526 662 await tester.pumpAndSettle(); 527 663 ··· 559 695 560 696 expect(find.text('privacy-screen'), findsOneWidget); 561 697 }); 698 + } 699 + 700 + AuthTokens _authenticatedTokens() { 701 + return const AuthTokens( 702 + accessToken: 'access-token', 703 + refreshToken: 'refresh-token', 704 + did: 'did:plc:test', 705 + handle: 'test.bsky.social', 706 + displayName: 'Test User', 707 + ); 562 708 } 563 709 564 710 String _buildJwt({required String aud, required String sub, required String clientId, required String iss}) {