Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 24 additions & 1 deletion lib/src/db/database.dart
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ const corresGameTTL = Duration(days: 60);
const gameTTL = Duration(days: 90);
const chatReadMessagesTTL = Duration(days: 180);
const httpLogTTL = Duration(days: 7);
const appLogTTL = Duration(days: 7);

const kStorageAnonId = '**anonymous**';

Expand Down Expand Up @@ -67,7 +68,7 @@ Future<Database> openAppDatabase(DatabaseFactory dbFactory, String path) {
return dbFactory.openDatabase(
path,
options: OpenDatabaseOptions(
version: 4,
version: 5,
onConfigure: (db) async {
final version = await _getDatabaseVersion(db);
_logger.info('SQLite version: $version');
Expand All @@ -79,6 +80,7 @@ Future<Database> openAppDatabase(DatabaseFactory dbFactory, String path) {
_deleteOldEntries(db, 'game', gameTTL),
_deleteOldEntries(db, 'chat_read_messages', chatReadMessagesTTL),
_deleteOldEntries(db, 'http_log', httpLogTTL),
_deleteOldEntries(db, 'app_log', appLogTTL),
]);
},
onCreate: (db, version) async {
Expand All @@ -89,6 +91,7 @@ Future<Database> openAppDatabase(DatabaseFactory dbFactory, String path) {
_createChatReadMessagesTableV1(batch);
_createGameTableV2(batch);
_createHttpLogTableV4(batch);
_createAppLogTableV5(batch);
await batch.commit();
},
onUpgrade: (db, oldVersion, newVersion) async {
Expand All @@ -102,6 +105,9 @@ Future<Database> openAppDatabase(DatabaseFactory dbFactory, String path) {
if (oldVersion < 4) {
_createHttpLogTableV4(batch);
}
if (oldVersion < 5) {
_createAppLogTableV5(batch);
}
await batch.commit();
},
onDowngrade: onDatabaseDowngradeDelete,
Expand Down Expand Up @@ -207,6 +213,23 @@ void _createHttpLogTableV4(Batch batch) {
''');
}

void _createAppLogTableV5(Batch batch) {
batch.execute('DROP TABLE IF EXISTS app_log');
batch.execute('''
CREATE TABLE app_log(
id INTEGER PRIMARY KEY AUTOINCREMENT,
logTime TEXT NOT NULL,
loggerName TEXT NOT NULL,
levelValue INTEGER NOT NULL,
levelName TEXT NOT NULL,
message TEXT NOT NULL,
error TEXT,
stackTrace TEXT,
lastModified TEXT NOT NULL
)
''');
}

Future<void> _deleteOldEntries(Database db, String table, Duration ttl) async {
final date = DateTime.now().subtract(ttl);

Expand Down
14 changes: 12 additions & 2 deletions lib/src/log.dart
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import 'dart:io' show Platform;
import 'package:flutter/foundation.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:lichess_mobile/src/binding.dart';
import 'package:lichess_mobile/src/model/app_log/app_log_storage.dart';
import 'package:lichess_mobile/src/model/settings/log_preferences.dart';
import 'package:lichess_mobile/src/utils/lru_list.dart';
import 'package:logging/logging.dart';
Expand All @@ -16,9 +17,10 @@ final appLogServiceProvider = Provider<AppLogService>(
name: 'AppLogServiceProvider',
);

/// Manages log entries created via [Logger] instances
/// Manages log entries created via [Logger] instances.
///
/// Currently, simply saves the most recent log entries in memory, so they do not persists across app restarts.
/// Log entries are stored in memory for the current session and persisted to the
/// SQLite database so they survive app restarts.
class AppLogService {
AppLogService(this.ref);

Expand Down Expand Up @@ -66,6 +68,14 @@ class AppLogService {
}

_logs.put(record);

// Persist to database asynchronously (fire-and-forget).
// The try-catch guards against ref being invalid (e.g. disposed ProviderScope in tests).
try {
ref
.read(appLogStorageProvider.future)
.then((storage) => storage.save(AppLogEntry.fromLogRecord(record)), onError: (_) {});
} catch (_) {}
});
}

Expand Down
74 changes: 74 additions & 0 deletions lib/src/model/app_log/app_log_paginator.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
import 'package:fast_immutable_collections/fast_immutable_collections.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:freezed_annotation/freezed_annotation.dart';
import 'package:lichess_mobile/src/model/app_log/app_log_storage.dart';
import 'package:lichess_mobile/src/model/settings/log_preferences.dart';

part 'app_log_paginator.freezed.dart';

/// The number of app logs to fetch per page.
const _pageSize = 20;

/// A provider for [AppLogPaginator].
final appLogPaginatorProvider = AsyncNotifierProvider.autoDispose<AppLogPaginator, AppLogState>(
AppLogPaginator.new,
name: 'AppLogPaginatorProvider',
);

/// A Riverpod controller for managing paginated app log entries.
class AppLogPaginator extends AsyncNotifier<AppLogState> {
@override
Future<AppLogState> build() async {
final storage = await ref.read(appLogStorageProvider.future);
final minLevelValue = ref.watch(logPreferencesProvider.select((p) => p.level.value));
return AppLogState(
data: IList.new([
await AsyncValue.guard(() => storage.page(limit: _pageSize, minLevelValue: minLevelValue)),
]),
);
}

/// Fetches the next page of app logs.
Future<void> next() async {
if (state.hasValue && state.requireValue.hasMore) {
final storage = await ref.read(appLogStorageProvider.future);
final minLevelValue = ref.read(logPreferencesProvider.select((p) => p.level.value));
final asyncPage = await AsyncValue.guard(
() => storage.page(
limit: _pageSize,
cursor: state.requireValue.nextPage,
minLevelValue: minLevelValue,
),
);
state = AsyncValue.data(
state.requireValue.copyWith(data: state.requireValue.data.add(asyncPage)),
);
}
}

/// Deletes all app logs from the database and refreshes.
Future<void> deleteAll() async {
final storage = await ref.read(appLogStorageProvider.future);
await storage.deleteAll();
ref.invalidateSelf();
}

/// Refreshes by fetching the first page again.
Future<void> refresh() async {
ref.invalidateSelf();
}
}

@freezed
sealed class AppLogState with _$AppLogState {
const AppLogState._();

const factory AppLogState({required IList<AsyncValue<AppLogPage>> data}) = _AppLogState;

bool get initialized => data.isNotEmpty;
List<AppLogEntry> get logs => data.expand((e) => e.value?.items ?? <AppLogEntry>[]).toList();
int? get nextPage => data.lastOrNull?.value?.next;
bool get hasMore => initialized && nextPage != null;
bool get isLoading => data.lastOrNull?.isLoading == true;
bool get isDeleteButtonVisible => logs.isNotEmpty;
}
92 changes: 92 additions & 0 deletions lib/src/model/app_log/app_log_storage.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
import 'package:collection/collection.dart';
import 'package:fast_immutable_collections/fast_immutable_collections.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:freezed_annotation/freezed_annotation.dart';
import 'package:lichess_mobile/src/db/database.dart';
import 'package:logging/logging.dart';
import 'package:sqflite/sqflite.dart';

part 'app_log_storage.g.dart';
part 'app_log_storage.freezed.dart';

/// Provides an instance of [AppLogStorage] using Riverpod.
final appLogStorageProvider = FutureProvider<AppLogStorage>((Ref ref) async {
final db = await ref.watch(databaseProvider.future);
return AppLogStorage(db);
}, name: 'AppLogStorageProvider');

const kAppLogStorageTable = 'app_log';

/// Manages the storage of app logs in a SQLite database.
class AppLogStorage {
const AppLogStorage(this._db);
final Database _db;

/// Retrieves a paginated list of [AppLogEntry] entries from the database.
///
/// If [minLevelValue] is provided, only entries with a level value greater than
/// or equal to [minLevelValue] are returned.
Future<AppLogPage> page({int? cursor, int? minLevelValue, int limit = 100}) async {
final whereClause = [
if (cursor != null) 'id <= $cursor',
if (minLevelValue != null) 'levelValue >= $minLevelValue',
];
final res = await _db.query(
kAppLogStorageTable,
limit: limit + 1,
orderBy: 'id DESC',
where: whereClause.isNotEmpty ? whereClause.join(' AND ') : null,
);
return AppLogPage(
items: res.take(limit).map(AppLogEntry.fromJson).toIList(),
next: res.elementAtOrNull(limit)?['id'] as int?,
);
}

/// Saves an [AppLogEntry] to the database.
Future<void> save(AppLogEntry entry) async {
await _db.insert(kAppLogStorageTable, {
...entry.toJson(),
'lastModified': DateTime.now().toIso8601String(),
}, conflictAlgorithm: ConflictAlgorithm.replace);
}

/// Deletes all app log entries from the database.
Future<void> deleteAll() async {
await _db.delete(kAppLogStorageTable);
}
}

/// Represents a persisted app log entry.
@Freezed(fromJson: true, toJson: true)
sealed class AppLogEntry with _$AppLogEntry {
const AppLogEntry._();

const factory AppLogEntry({
required DateTime logTime,
required String loggerName,
required int levelValue,
required String levelName,
required String message,
String? error,
String? stackTrace,
}) = _AppLogEntry;

factory AppLogEntry.fromLogRecord(LogRecord record) => AppLogEntry(
logTime: record.time,
loggerName: record.loggerName,
levelValue: record.level.value,
levelName: record.level.name,
message: record.message,
error: record.error?.toString(),
stackTrace: record.stackTrace?.toString(),
);

factory AppLogEntry.fromJson(Map<String, dynamic> json) => _$AppLogEntryFromJson(json);
}

/// A paginated collection of app log entries.
@freezed
sealed class AppLogPage with _$AppLogPage {
const factory AppLogPage({required IList<AppLogEntry> items, required int? next}) = _AppLogPage;
}
Loading