diff --git a/android/app/src/main/kotlin/com/example/lazurite/MainActivity.kt b/android/app/src/main/kotlin/com/example/lazurite/MainActivity.kt index 2a5fa8b..437a6d9 100644 --- a/android/app/src/main/kotlin/com/example/lazurite/MainActivity.kt +++ b/android/app/src/main/kotlin/com/example/lazurite/MainActivity.kt @@ -1,5 +1,25 @@ package org.stormlightlabs.lazurite +import android.view.WindowManager import io.flutter.embedding.android.FlutterActivity -class MainActivity : FlutterActivity() +class MainActivity : FlutterActivity() { + override fun onPause() { + clearLockInterferingWindowFlags() + super.onPause() + } + + override fun onStop() { + clearLockInterferingWindowFlags() + super.onStop() + } + + private fun clearLockInterferingWindowFlags() { + window.clearFlags( + WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON or + WindowManager.LayoutParams.FLAG_SHOW_WHEN_LOCKED or + WindowManager.LayoutParams.FLAG_TURN_SCREEN_ON or + WindowManager.LayoutParams.FLAG_DISMISS_KEYGUARD + ) + } +} diff --git a/android/app/src/main/res/values-night-v31/styles.xml b/android/app/src/main/res/values-night-v31/styles.xml index a3eee00..7e41e71 100644 --- a/android/app/src/main/res/values-night-v31/styles.xml +++ b/android/app/src/main/res/values-night-v31/styles.xml @@ -17,5 +17,6 @@ This Theme is only used starting with V2 of Flutter's Android embedding. --> diff --git a/android/app/src/main/res/values-night/styles.xml b/android/app/src/main/res/values-night/styles.xml index 3c4a1fe..4a66128 100644 --- a/android/app/src/main/res/values-night/styles.xml +++ b/android/app/src/main/res/values-night/styles.xml @@ -18,5 +18,6 @@ This Theme is only used starting with V2 of Flutter's Android embedding. --> diff --git a/android/app/src/main/res/values-v31/styles.xml b/android/app/src/main/res/values-v31/styles.xml index b008a5b..ba78589 100644 --- a/android/app/src/main/res/values-v31/styles.xml +++ b/android/app/src/main/res/values-v31/styles.xml @@ -17,5 +17,6 @@ This Theme is only used starting with V2 of Flutter's Android embedding. --> diff --git a/android/app/src/main/res/values/styles.xml b/android/app/src/main/res/values/styles.xml index 847e1be..7a29a07 100644 --- a/android/app/src/main/res/values/styles.xml +++ b/android/app/src/main/res/values/styles.xml @@ -18,5 +18,6 @@ This Theme is only used starting with V2 of Flutter's Android embedding. --> diff --git a/lib/core/database/app_database.dart b/lib/core/database/app_database.dart index 9ce707c..a0cb5e9 100644 --- a/lib/core/database/app_database.dart +++ b/lib/core/database/app_database.dart @@ -1,3 +1,5 @@ +import 'dart:async'; + import 'package:drift/drift.dart'; import 'package:drift_flutter/drift_flutter.dart'; import 'package:lazurite/core/database/tables.dart'; @@ -35,6 +37,7 @@ class AppDatabase extends _$AppDatabase { AppDatabase({QueryExecutor? executor}) : super(executor ?? _openConnection()); static const activeAccountDidSettingKey = 'active_account_did'; + Future _serializedWriteTail = Future.value(); @override int get schemaVersion => 23; @@ -177,9 +180,29 @@ class AppDatabase extends _$AppDatabase { static QueryExecutor _openConnection() => driftDatabase( name: 'lazurite_db', - native: const DriftNativeOptions(databaseDirectory: getApplicationSupportDirectory), + native: const DriftNativeOptions( + databaseDirectory: getApplicationSupportDirectory, + shareAcrossIsolates: true, + setup: _configureNativeDatabaseConnection, + ), ); + /// Serializes read-modify-write database operations issued through this + /// [AppDatabase] instance so callers do not compute writes from stale reads. + Future runSerializedWrite(Future Function() operation) { + final previousTail = _serializedWriteTail; + final completion = Completer(); + _serializedWriteTail = completion.future; + + return previousTail.then((_) async { + try { + return await operation(); + } finally { + completion.complete(); + } + }); + } + Future getAccount(String did) => (select(accounts)..where((a) => a.did.equals(did))).getSingleOrNull(); Future getActiveAccount() async { @@ -268,14 +291,16 @@ class AppDatabase extends _$AppDatabase { Future deleteSetting(String key) => (delete(settings)..where((s) => s.key.equals(key))).go(); Future clearLocalCaches() async { - await transaction(() async { - await delete(cachedProfiles).go(); - await delete(cachedPosts).go(); - await delete(cachedFeedPages).go(); - await delete(cachedFeedPosts).go(); - await delete(cachedThreadRoots).go(); - await delete(labelerCache).go(); - await customStatement("DELETE FROM settings WHERE key LIKE 'moderation_preferences::%'"); + await runSerializedWrite(() async { + await transaction(() async { + await delete(cachedProfiles).go(); + await delete(cachedPosts).go(); + await delete(cachedFeedPages).go(); + await delete(cachedFeedPosts).go(); + await delete(cachedThreadRoots).go(); + await delete(labelerCache).go(); + await customStatement("DELETE FROM settings WHERE key LIKE 'moderation_preferences::%'"); + }); }); } @@ -422,11 +447,13 @@ class AppDatabase extends _$AppDatabase { } Future replaceSavedFeeds(String accountDid, List feeds) async { - await transaction(() async { - await deleteAllSavedFeeds(accountDid); - for (final feed in feeds) { - await insertSavedFeed(feed); - } + await runSerializedWrite(() async { + await transaction(() async { + await deleteAllSavedFeeds(accountDid); + for (final feed in feeds) { + await insertSavedFeed(feed); + } + }); }); } @@ -837,3 +864,10 @@ class AppDatabase extends _$AppDatabase { return rows.length; } } + +void _configureNativeDatabaseConnection(dynamic database) { + database.execute('PRAGMA busy_timeout = 5000'); + database.execute('PRAGMA journal_mode = WAL'); + database.execute('PRAGMA synchronous = NORMAL'); + database.execute('PRAGMA foreign_keys = ON'); +} diff --git a/lib/core/l10n/app_localizations.dart b/lib/core/l10n/app_localizations.dart index 00c43a6..ab32dde 100644 --- a/lib/core/l10n/app_localizations.dart +++ b/lib/core/l10n/app_localizations.dart @@ -4417,6 +4417,18 @@ abstract class AppLocalizations { /// **'Clear all logs'** String get tooltipClearAllLogs; + /// Tooltip for collapsing a log row stack trace preview + /// + /// In en, this message translates to: + /// **'Collapse stack trace'** + String get tooltipCollapseStackTrace; + + /// Tooltip for expanding a log row stack trace preview + /// + /// In en, this message translates to: + /// **'Expand stack trace'** + String get tooltipExpandStackTrace; + /// Tooltip for opening pds.ls /// /// In en, this message translates to: diff --git a/lib/core/l10n/app_localizations_en.dart b/lib/core/l10n/app_localizations_en.dart index 7c0208a..5fa6ea9 100644 --- a/lib/core/l10n/app_localizations_en.dart +++ b/lib/core/l10n/app_localizations_en.dart @@ -2418,6 +2418,12 @@ class AppLocalizationsEn extends AppLocalizations { @override String get tooltipClearAllLogs => 'Clear all logs'; + @override + String get tooltipCollapseStackTrace => 'Collapse stack trace'; + + @override + String get tooltipExpandStackTrace => 'Expand stack trace'; + @override String get tooltipGoToPdsLs => 'Go to pds.ls'; diff --git a/lib/core/l10n/intl_en.arb b/lib/core/l10n/intl_en.arb index ff3b3cb..4f9d799 100644 --- a/lib/core/l10n/intl_en.arb +++ b/lib/core/l10n/intl_en.arb @@ -3439,6 +3439,14 @@ "@tooltipClearAllLogs": { "description": "Tooltip for clearing all log files" }, + "tooltipCollapseStackTrace": "Collapse stack trace", + "@tooltipCollapseStackTrace": { + "description": "Tooltip for collapsing a log row stack trace preview" + }, + "tooltipExpandStackTrace": "Expand stack trace", + "@tooltipExpandStackTrace": { + "description": "Tooltip for expanding a log row stack trace preview" + }, "tooltipGoToPdsLs": "Go to pds.ls", "@tooltipGoToPdsLs": { "description": "Tooltip for opening pds.ls" diff --git a/lib/core/logging/app_logger.dart b/lib/core/logging/app_logger.dart index 4778602..dc7ba75 100644 --- a/lib/core/logging/app_logger.dart +++ b/lib/core/logging/app_logger.dart @@ -1,10 +1,10 @@ import 'dart:io'; import 'package:flutter/foundation.dart'; -import 'package:logger/logger.dart'; -import 'package:path_provider/path_provider.dart'; import 'package:lazurite/core/logging/app_file_log_printer.dart'; import 'package:lazurite/core/logging/daily_log_file_output.dart'; +import 'package:logger/logger.dart'; +import 'package:path_provider/path_provider.dart'; class AppLogger { AppLogger._(); @@ -20,20 +20,16 @@ class AppLogger { Future initialize() async { await dispose(); + const level = kReleaseMode ? Level.info : Level.debug; _logDirectory = await _getLogDirectory(); _fileOutput = DailyLogFileOutput(directoryPath: _logDirectory!, retentionDays: 3); - _fileLogger = Logger( - filter: ProductionFilter(), - printer: AppFileLogPrinter(), - output: _fileOutput!, - level: Level.trace, - ); + _fileLogger = Logger(filter: ProductionFilter(), printer: AppFileLogPrinter(), output: _fileOutput!, level: level); final initFutures = >[_fileLogger!.init]; - if (kDebugMode) { + if (!kReleaseMode) { _consoleLogger = Logger( - filter: DevelopmentFilter(), + filter: ProductionFilter(), printer: PrettyPrinter( methodCount: 2, errorMethodCount: 8, @@ -43,7 +39,7 @@ class AppLogger { dateTimeFormat: DateTimeFormat.onlyTimeAndSinceStart, ), output: ConsoleOutput(), - level: Level.trace, + level: level, ); initFutures.add(_consoleLogger!.init); } diff --git a/lib/features/feed/data/feed_repository.dart b/lib/features/feed/data/feed_repository.dart index 6e79386..ead53e4 100644 --- a/lib/features/feed/data/feed_repository.dart +++ b/lib/features/feed/data/feed_repository.dart @@ -347,78 +347,80 @@ class FeedRepository { /// Cache writes are best-effort and must never break feed rendering. Future _cacheFeedWindow({required String feedKey, required FeedResult result, required String? cursor}) async { try { - final existingPosts = await _database.getCachedFeedPosts(_accountDid, feedKey); + await _database.runSerializedWrite(() async { + final existingPosts = await _database.getCachedFeedPosts(_accountDid, feedKey); - final merged = []; - final seen = {}; + final merged = []; + final seen = {}; - void addPost(FeedViewPost post) { - final uri = post.post.uri.toString(); - if (seen.add(uri)) { - merged.add(post); + void addPost(FeedViewPost post) { + final uri = post.post.uri.toString(); + if (seen.add(uri)) { + merged.add(post); + } } - } - if (cursor == null) { - for (final post in result.posts) { - addPost(post); - } - for (final cached in existingPosts) { - if (seen.contains(cached.postUri)) { - continue; + if (cursor == null) { + for (final post in result.posts) { + addPost(post); } - try { - addPost(FeedViewPost.fromJson(jsonDecode(cached.postJson) as Map)); - } catch (error, stackTrace) { - log.w( - 'feed.cacheWindow decode failed account=$_accountDid feedKey=$feedKey postUri=${cached.postUri}', - error: error, - stackTrace: stackTrace, - ); + for (final cached in existingPosts) { + if (seen.contains(cached.postUri)) { + continue; + } + try { + addPost(FeedViewPost.fromJson(jsonDecode(cached.postJson) as Map)); + } catch (error, stackTrace) { + log.w( + 'feed.cacheWindow decode failed account=$_accountDid feedKey=$feedKey postUri=${cached.postUri}', + error: error, + stackTrace: stackTrace, + ); + } } - } - } else { - for (final cached in existingPosts) { - try { - addPost(FeedViewPost.fromJson(jsonDecode(cached.postJson) as Map)); - } catch (error, stackTrace) { - log.w( - 'feed.cacheWindow decode failed account=$_accountDid feedKey=$feedKey postUri=${cached.postUri}', - error: error, - stackTrace: stackTrace, - ); + } else { + for (final cached in existingPosts) { + try { + addPost(FeedViewPost.fromJson(jsonDecode(cached.postJson) as Map)); + } catch (error, stackTrace) { + log.w( + 'feed.cacheWindow decode failed account=$_accountDid feedKey=$feedKey postUri=${cached.postUri}', + error: error, + stackTrace: stackTrace, + ); + } + } + for (final post in result.posts) { + addPost(post); } } - for (final post in result.posts) { - addPost(post); + + final limited = merged.take(OfflineCachePolicy.feedPostLimit).toList(growable: false); + final companions = []; + for (var i = 0; i < limited.length; i++) { + final post = limited[i]; + final uri = post.post.uri.toString(); + final sortOrder = OfflineCachePolicy.feedPostLimit - i; + companions.add( + CachedFeedPostsCompanion.insert( + accountDid: _accountDid, + feedKey: feedKey, + postUri: uri, + postJson: jsonEncode(post.toJson()), + sortOrder: sortOrder, + ), + ); } - } - final limited = merged.take(OfflineCachePolicy.feedPostLimit).toList(growable: false); - final companions = []; - for (var i = 0; i < limited.length; i++) { - final post = limited[i]; - final uri = post.post.uri.toString(); - final sortOrder = OfflineCachePolicy.feedPostLimit - i; - companions.add( - CachedFeedPostsCompanion.insert( + await _database.transaction(() async { + await _database.deleteCachedFeedPostsForFeed(_accountDid, feedKey); + await _database.upsertCachedFeedPosts(accountDid: _accountDid, feedKey: feedKey, posts: companions); + await _database.cacheFeedPage( accountDid: _accountDid, feedKey: feedKey, - postUri: uri, - postJson: jsonEncode(post.toJson()), - sortOrder: sortOrder, - ), - ); - } - - await _database.transaction(() async { - await _database.deleteCachedFeedPostsForFeed(_accountDid, feedKey); - await _database.upsertCachedFeedPosts(accountDid: _accountDid, feedKey: feedKey, posts: companions); - await _database.cacheFeedPage( - accountDid: _accountDid, - feedKey: feedKey, - payload: jsonEncode({'cursor': result.cursor, 'lastRequestCursor': cursor}), - ); + payload: jsonEncode({'cursor': result.cursor, 'lastRequestCursor': cursor}), + ); + }); }); } catch (error, stackTrace) { log.w( diff --git a/lib/features/logs/cubit/log_viewer_cubit.dart b/lib/features/logs/cubit/log_viewer_cubit.dart index 7b891cf..43907c2 100644 --- a/lib/features/logs/cubit/log_viewer_cubit.dart +++ b/lib/features/logs/cubit/log_viewer_cubit.dart @@ -158,7 +158,9 @@ class LogViewerCubit extends Cubit { if (searchQuery.isNotEmpty) { final query = searchQuery.toLowerCase(); filtered = filtered.where((entry) { - return entry.message.toLowerCase().contains(query) || (entry.source?.toLowerCase().contains(query) ?? false); + return entry.message.toLowerCase().contains(query) || + (entry.source?.toLowerCase().contains(query) ?? false) || + (entry.stackTrace?.toLowerCase().contains(query) ?? false); }).toList(); } diff --git a/lib/features/logs/data/log_entry.dart b/lib/features/logs/data/log_entry.dart index 030c8fc..c18479b 100644 --- a/lib/features/logs/data/log_entry.dart +++ b/lib/features/logs/data/log_entry.dart @@ -3,12 +3,13 @@ import 'package:logger/logger.dart'; import 'package:lazurite/core/logging/log_redactor.dart'; class LogEntry extends Equatable { - const LogEntry({required this.timestamp, required this.level, required this.message, this.source}); + const LogEntry({required this.timestamp, required this.level, required this.message, this.source, this.stackTrace}); final DateTime timestamp; final Level level; final String message; final String? source; + final String? stackTrace; static LogEntry? tryParse(String line) { final trimmed = line.trim(); @@ -35,6 +36,13 @@ class LogEntry extends Equatable { String message; String? source; + String? stackTrace; + + final stackMarkerIndex = remaining.indexOf(' STACK: '); + if (stackMarkerIndex >= 0) { + stackTrace = remaining.substring(stackMarkerIndex + ' STACK: '.length).trim(); + remaining = remaining.substring(0, stackMarkerIndex).trim(); + } final colonIndex = remaining.indexOf(':'); if (colonIndex > 0 && colonIndex < 30 && !remaining.substring(0, colonIndex).contains(' ')) { @@ -46,14 +54,16 @@ class LogEntry extends Equatable { final redactedSource = source == null ? null : LogRedactor.redact(source); final redactedMessage = LogRedactor.redact(message); + final redactedStackTrace = stackTrace == null ? null : LogRedactor.redact(stackTrace); - if (redactedMessage.isEmpty && redactedSource == null) return null; + if (redactedMessage.isEmpty && redactedSource == null && (redactedStackTrace?.isEmpty ?? true)) return null; return LogEntry( timestamp: timestamp ?? DateTime.now(), level: level, message: redactedMessage, source: redactedSource, + stackTrace: redactedStackTrace?.isEmpty ?? true ? null : redactedStackTrace, ); } @@ -108,6 +118,25 @@ class LogEntry extends Equatable { '${timestamp.millisecond.toString().padLeft(3, '0')}'; } + String get copyText { + final buffer = StringBuffer() + ..write('[${level.name.toUpperCase()}] TIME: ') + ..write(timestamp.toIso8601String()) + ..write(' '); + if (source != null) { + buffer + ..write(source) + ..write(': '); + } + buffer.write(message); + if (stackTrace != null) { + buffer + ..write('\nSTACK: ') + ..write(stackTrace); + } + return buffer.toString(); + } + @override - List get props => [timestamp, level, message, source]; + List get props => [timestamp, level, message, source, stackTrace]; } diff --git a/lib/features/logs/presentation/logs_screen.dart b/lib/features/logs/presentation/logs_screen.dart index 17c2c7e..2755fdd 100644 --- a/lib/features/logs/presentation/logs_screen.dart +++ b/lib/features/logs/presentation/logs_screen.dart @@ -2,6 +2,7 @@ import 'dart:async'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:flutter/services.dart'; import 'package:logger/logger.dart'; import 'package:lazurite/core/l10n/l10n.dart'; import 'package:lazurite/core/logging/app_logger.dart'; @@ -424,11 +425,13 @@ class _LogEntryTileState extends State<_LogEntryTile> { Widget build(BuildContext context) { final levelColor = _getLevelColor(context, widget.entry.level); final badgeColor = _getBadgeColor(context, widget.entry.level); + final stackTrace = widget.entry.stackTrace; + final hasStackTrace = stackTrace != null; return InkWell( onTap: () => setState(() => _expanded = !_expanded), child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + padding: const EdgeInsets.fromLTRB(16, 8, 8, 8), child: Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ @@ -458,9 +461,18 @@ class _LogEntryTileState extends State<_LogEntryTile> { Text( widget.entry.message, style: TextStyle(fontFamily: 'JetBrains Mono', fontSize: 12, color: context.colorScheme.onSurface), - maxLines: _expanded ? null : 2, + maxLines: _expanded ? null : 3, overflow: _expanded ? null : TextOverflow.ellipsis, ), + if (hasStackTrace) + Padding( + padding: const EdgeInsets.only(top: 6), + child: _StackTracePreview( + stackTrace: stackTrace, + expanded: _expanded, + onToggle: () => setState(() => _expanded = !_expanded), + ), + ), if (widget.entry.source != null) Padding( padding: const EdgeInsets.only(top: 2), @@ -476,12 +488,27 @@ class _LogEntryTileState extends State<_LogEntryTile> { ], ), ), + const SizedBox(width: 4), + IconButton( + icon: const Icon(Icons.content_copy_outlined, size: 18), + tooltip: MaterialLocalizations.of(context).copyButtonLabel, + visualDensity: VisualDensity.compact, + onPressed: () => _copyEntry(context), + ), ], ), ), ); } + Future _copyEntry(BuildContext context) async { + await Clipboard.setData(ClipboardData(text: widget.entry.copyText)); + if (!context.mounted) { + return; + } + ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(context.l10n.messageCopied))); + } + Color _getLevelColor(BuildContext context, Level level) { final colorScheme = context.colorScheme; switch (level) { @@ -517,3 +544,49 @@ class _LogEntryTileState extends State<_LogEntryTile> { bool _isFatalOrError(Level level) => level == Level.fatal || level == Level.error; } + +class _StackTracePreview extends StatelessWidget { + const _StackTracePreview({required this.stackTrace, required this.expanded, required this.onToggle}); + + final String stackTrace; + final bool expanded; + final VoidCallback onToggle; + + @override + Widget build(BuildContext context) { + final colorScheme = context.colorScheme; + + return DecoratedBox( + decoration: BoxDecoration( + color: colorScheme.surfaceContainerHighest.withAlpha(128), + border: Border.all(color: colorScheme.outlineVariant), + borderRadius: BorderRadius.circular(6), + ), + child: Padding( + padding: const EdgeInsets.fromLTRB(8, 6, 4, 6), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + child: Text( + stackTrace, + style: TextStyle(fontFamily: 'JetBrains Mono', fontSize: 11, color: colorScheme.onSurfaceVariant), + maxLines: expanded ? null : 2, + overflow: expanded ? null : TextOverflow.ellipsis, + ), + ), + const SizedBox(width: 4), + IconButton( + icon: Icon(expanded ? Icons.unfold_less : Icons.unfold_more, size: 18), + tooltip: expanded ? context.l10n.tooltipCollapseStackTrace : context.l10n.tooltipExpandStackTrace, + visualDensity: VisualDensity.compact, + padding: EdgeInsets.zero, + constraints: const BoxConstraints.tightFor(width: 28, height: 28), + onPressed: onToggle, + ), + ], + ), + ), + ); + } +} diff --git a/test/core/database/app_database_test.dart b/test/core/database/app_database_test.dart index 90fd8e1..a614099 100644 --- a/test/core/database/app_database_test.dart +++ b/test/core/database/app_database_test.dart @@ -1,3 +1,5 @@ +import 'dart:async'; + import 'package:drift/native.dart'; import 'package:drift/drift.dart' show Value; import 'package:flutter_test/flutter_test.dart'; @@ -15,6 +17,49 @@ void main() { }); group('AppDatabase', () { + group('Serialized writes', () { + test('runSerializedWrite waits for the previous write to finish', () async { + final events = []; + final firstStarted = Completer(); + final releaseFirst = Completer(); + + final first = database.runSerializedWrite(() async { + events.add('first-start'); + firstStarted.complete(); + await releaseFirst.future; + events.add('first-end'); + return 'first'; + }); + + await firstStarted.future; + final second = database.runSerializedWrite(() async { + events.add('second'); + return 'second'; + }); + + await Future.delayed(Duration.zero); + expect(events, ['first-start']); + + releaseFirst.complete(); + + expect(await Future.wait([first, second]), ['first', 'second']); + expect(events, ['first-start', 'first-end', 'second']); + }); + + test('runSerializedWrite continues after a failed write', () async { + await expectLater( + database.runSerializedWrite(() async { + throw StateError('write failed'); + }), + throwsA(isA()), + ); + + final result = await database.runSerializedWrite(() async => 1); + + expect(result, 1); + }); + }); + group('Account operations', () { test('should insert and retrieve an account', () async { final account = AccountsCompanion.insert( diff --git a/test/features/logs/cubit/log_viewer_cubit_test.dart b/test/features/logs/cubit/log_viewer_cubit_test.dart index dd3e78f..2129bd0 100644 --- a/test/features/logs/cubit/log_viewer_cubit_test.dart +++ b/test/features/logs/cubit/log_viewer_cubit_test.dart @@ -76,6 +76,33 @@ void main() { }, ); + test('search query matches stack traces', () async { + final sourceDir = await Directory.systemTemp.createTemp('lazurite_log_viewer_source_'); + final sourceFile = File('${sourceDir.path}/lazurite_2026-05-06.log'); + await sourceFile.writeAsString( + '[E] TIME: 2026-05-06T10:00:00.000 FeedRepository: cache failed ' + 'STACK: #0 AppDatabase.transaction\n' + '[I] TIME: 2026-05-06T10:00:01.000 FeedRepository: cache recovered', + ); + + final cubit = LogViewerCubit( + refreshInterval: const Duration(hours: 1), + logFilesProvider: () async => [sourceFile], + ); + addTearDown(() async { + await cubit.close(); + if (await sourceDir.exists()) { + await sourceDir.delete(recursive: true); + } + }); + + await _waitForLoaded(cubit); + cubit.setSearchQuery('transaction'); + + expect(cubit.state.filteredEntries, hasLength(1)); + expect(cubit.state.filteredEntries.single.stackTrace, contains('AppDatabase.transaction')); + }); + test('getTodaysLogFile returns a redacted share copy', () async { final sourceDir = await Directory.systemTemp.createTemp('lazurite_log_viewer_test_'); final sourceFile = File('${sourceDir.path}/lazurite_2026-05-05.log'); diff --git a/test/features/logs/data/log_entry_test.dart b/test/features/logs/data/log_entry_test.dart index 78d3aa2..9268666 100644 --- a/test/features/logs/data/log_entry_test.dart +++ b/test/features/logs/data/log_entry_test.dart @@ -54,6 +54,20 @@ void main() { expect(entry.message, 'Unhandled exception'); }); + test('separates stack traces from the visible message', () { + final entry = LogEntry.tryParse( + '[E] TIME: 2026-03-16T14:32:12.450 FeedRepository: feed.cacheWindow failed ' + 'ERROR: cannot commit transaction ' + 'STACK: #0 AppDatabase.transaction | #1 FeedRepository._cacheFeedWindow', + ); + + expect(entry, isNotNull); + expect(entry!.level, Level.error); + expect(entry.source, 'FeedRepository'); + expect(entry.message, 'feed.cacheWindow failed ERROR: cannot commit transaction'); + expect(entry.stackTrace, '#0 AppDatabase.transaction | #1 FeedRepository._cacheFeedWindow'); + }); + test('returns null for empty line', () { final entry = LogEntry.tryParse(''); expect(entry, isNull); @@ -117,6 +131,22 @@ void main() { }); }); + group('copyText', () { + test('includes the full row and stack trace', () { + final entry = LogEntry( + timestamp: DateTime(2026, 5, 9, 10, 15, 30, 125), + level: Level.error, + source: 'FeedRepository', + message: 'feed.cacheWindow failed', + stackTrace: '#0 AppDatabase.transaction', + ); + + expect(entry.copyText, contains('[ERROR] TIME: 2026-05-09T10:15:30.125')); + expect(entry.copyText, contains('FeedRepository: feed.cacheWindow failed')); + expect(entry.copyText, contains('STACK: #0 AppDatabase.transaction')); + }); + }); + group('equality', () { test('entries with same values are equal', () { final timestamp = DateTime.now();