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