feat(mobile) Add in app logging to show app's log information (#1014)

This commit is contained in:
Alex 2022-11-27 14:34:19 -06:00 committed by GitHub
parent fb3b36a569
commit 024177515d
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
20 changed files with 539 additions and 86 deletions

View file

@ -0,0 +1,34 @@
import 'package:hive/hive.dart';
part 'immich_logger_message.model.g.dart';
@HiveType(typeId: 3)
class ImmichLoggerMessage {
@HiveField(0)
String message;
@HiveField(1, defaultValue: "INFO")
String level;
@HiveField(2)
DateTime createdAt;
@HiveField(3)
String? context1;
@HiveField(4)
String? context2;
ImmichLoggerMessage({
required this.message,
required this.level,
required this.createdAt,
required this.context1,
required this.context2,
});
@override
String toString() {
return 'InAppLoggerMessage(message: $message, level: $level, createdAt: $createdAt)';
}
}

View file

@ -0,0 +1,53 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'immich_logger_message.model.dart';
// **************************************************************************
// TypeAdapterGenerator
// **************************************************************************
class ImmichLoggerMessageAdapter extends TypeAdapter<ImmichLoggerMessage> {
@override
final int typeId = 3;
@override
ImmichLoggerMessage read(BinaryReader reader) {
final numOfFields = reader.readByte();
final fields = <int, dynamic>{
for (int i = 0; i < numOfFields; i++) reader.readByte(): reader.read(),
};
return ImmichLoggerMessage(
message: fields[0] as String,
level: fields[1] == null ? 'INFO' : fields[1] as String,
createdAt: fields[2] as DateTime,
context1: fields[3] as String?,
context2: fields[4] as String?,
);
}
@override
void write(BinaryWriter writer, ImmichLoggerMessage obj) {
writer
..writeByte(5)
..writeByte(0)
..write(obj.message)
..writeByte(1)
..write(obj.level)
..writeByte(2)
..write(obj.createdAt)
..writeByte(3)
..write(obj.context1)
..writeByte(4)
..write(obj.context2);
}
@override
int get hashCode => typeId.hashCode;
@override
bool operator ==(Object other) =>
identical(this, other) ||
other is ImmichLoggerMessageAdapter &&
runtimeType == other.runtimeType &&
typeId == other.typeId;
}

View file

@ -1,6 +1,5 @@
import 'dart:collection';
import 'package:flutter/foundation.dart';
import 'package:hive/hive.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/constants/hive_box.dart';
@ -10,13 +9,14 @@ import 'package:immich_mobile/shared/models/asset.dart';
import 'package:immich_mobile/shared/services/device_info.service.dart';
import 'package:collection/collection.dart';
import 'package:intl/intl.dart';
import 'package:logging/logging.dart';
import 'package:openapi/api.dart';
import 'package:photo_manager/photo_manager.dart';
class AssetNotifier extends StateNotifier<List<Asset>> {
final AssetService _assetService;
final AssetCacheService _assetCacheService;
final log = Logger('AssetNotifier');
final DeviceInfoService _deviceInfoService = DeviceInfoService();
bool _getAllAssetInProgress = false;
bool _deleteInProgress = false;
@ -41,7 +41,7 @@ class AssetNotifier extends StateNotifier<List<Asset>> {
final remoteTask = _assetService.getRemoteAssets();
if (isCacheValid && state.isEmpty) {
state = await _assetCacheService.get();
debugPrint(
log.info(
"Reading assets from cache: ${stopwatch.elapsedMilliseconds}ms",
);
stopwatch.reset();
@ -52,25 +52,25 @@ class AssetNotifier extends StateNotifier<List<Asset>> {
final List<Asset> currentLocal = state.slice(0, remoteBegin);
List<Asset>? newRemote = await remoteTask;
List<Asset>? newLocal = await localTask;
debugPrint("Load assets: ${stopwatch.elapsedMilliseconds}ms");
log.info("Load assets: ${stopwatch.elapsedMilliseconds}ms");
stopwatch.reset();
if (newRemote == null &&
(newLocal == null || currentLocal.equals(newLocal))) {
debugPrint("state is already up-to-date");
log.info("state is already up-to-date");
return;
}
newRemote ??= state.slice(remoteBegin);
newLocal ??= [];
state = _combineLocalAndRemoteAssets(local: newLocal, remote: newRemote);
debugPrint("Combining assets: ${stopwatch.elapsedMilliseconds}ms");
log.info("Combining assets: ${stopwatch.elapsedMilliseconds}ms");
} finally {
_getAllAssetInProgress = false;
}
debugPrint("[getAllAsset] setting new asset state");
log.info("setting new asset state");
stopwatch.reset();
_cacheState();
debugPrint("Store assets in cache: ${stopwatch.elapsedMilliseconds}ms");
log.info("Store assets in cache: ${stopwatch.elapsedMilliseconds}ms");
}
List<Asset> _combineLocalAndRemoteAssets({
@ -155,8 +155,8 @@ class AssetNotifier extends StateNotifier<List<Asset>> {
if (local.isNotEmpty) {
try {
return await PhotoManager.editor.deleteWithIds(local);
} catch (e) {
debugPrint("Delete asset from device failed: $e");
} catch (e, stack) {
log.severe("Failed to delete asset from device", e, stack);
}
}
return [];

View file

@ -6,10 +6,11 @@ import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:http/http.dart';
import 'package:immich_mobile/constants/hive_box.dart';
import 'package:immich_mobile/shared/views/version_announcement_overlay.dart';
import 'package:logging/logging.dart';
class ReleaseInfoNotifier extends StateNotifier<String> {
ReleaseInfoNotifier() : super("");
final log = Logger('ReleaseInfoNotifier');
void checkGithubReleaseInfo() async {
final Client client = Client();
var box = Hive.box(hiveGithubReleaseInfoBox);
@ -28,9 +29,6 @@ class ReleaseInfoNotifier extends StateNotifier<String> {
String latestTagVersion = data["tag_name"];
state = latestTagVersion;
debugPrint("Local release version $localReleaseVersion");
debugPrint("Remote release veresion $latestTagVersion");
if (localReleaseVersion == null && latestTagVersion.isNotEmpty) {
VersionAnnouncementOverlayController.appLoader.show();
return;

View file

@ -6,23 +6,24 @@ import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/constants/hive_box.dart';
import 'package:immich_mobile/modules/login/providers/authentication.provider.dart';
import 'package:immich_mobile/shared/providers/asset.provider.dart';
import 'package:logging/logging.dart';
import 'package:openapi/api.dart';
import 'package:socket_io_client/socket_io_client.dart';
class WebscoketState {
class WebsocketState {
final Socket? socket;
final bool isConnected;
WebscoketState({
WebsocketState({
this.socket,
required this.isConnected,
});
WebscoketState copyWith({
WebsocketState copyWith({
Socket? socket,
bool? isConnected,
}) {
return WebscoketState(
return WebsocketState(
socket: socket ?? this.socket,
isConnected: isConnected ?? this.isConnected,
);
@ -30,13 +31,13 @@ class WebscoketState {
@override
String toString() =>
'WebscoketState(socket: $socket, isConnected: $isConnected)';
'WebsocketState(socket: $socket, isConnected: $isConnected)';
@override
bool operator ==(Object other) {
if (identical(this, other)) return true;
return other is WebscoketState &&
return other is WebsocketState &&
other.socket == socket &&
other.isConnected == isConnected;
}
@ -45,12 +46,11 @@ class WebscoketState {
int get hashCode => socket.hashCode ^ isConnected.hashCode;
}
class WebsocketNotifier extends StateNotifier<WebscoketState> {
class WebsocketNotifier extends StateNotifier<WebsocketState> {
WebsocketNotifier(this.ref)
: super(WebscoketState(socket: null, isConnected: false)) {
debugPrint("Init websocket instance");
}
: super(WebsocketState(socket: null, isConnected: false));
final log = Logger('WebsocketNotifier');
final Ref ref;
connect() {
@ -60,8 +60,8 @@ class WebsocketNotifier extends StateNotifier<WebscoketState> {
var accessToken = Hive.box(userInfoBox).get(accessTokenKey);
var endpoint = Hive.box(userInfoBox).get(serverEndpointKey);
try {
debugPrint("[WEBSOCKET] Attempting to connect to ws");
// Configure socket transports must be sepecified
log.info("Attempting to connect to websocket");
// Configure socket transports must be specified
Socket socket = io(
endpoint.toString().replaceAll('/api', ''),
OptionBuilder()
@ -76,18 +76,18 @@ class WebsocketNotifier extends StateNotifier<WebscoketState> {
);
socket.onConnect((_) {
debugPrint("[WEBSOCKET] Established Websocket Connection");
state = WebscoketState(isConnected: true, socket: socket);
log.info("Established Websocket Connection");
state = WebsocketState(isConnected: true, socket: socket);
});
socket.onDisconnect((_) {
debugPrint("[WEBSOCKET] Disconnect to Websocket Connection");
state = WebscoketState(isConnected: false, socket: null);
log.info("Disconnect to Websocket Connection");
state = WebsocketState(isConnected: false, socket: null);
});
socket.on('error', (errorMessage) {
debugPrint("Webcoket Error - $errorMessage");
state = WebscoketState(isConnected: false, socket: null);
log.severe("Websocket Error - $errorMessage");
state = WebsocketState(isConnected: false, socket: null);
});
socket.on('on_upload_success', (data) {
@ -105,21 +105,22 @@ class WebsocketNotifier extends StateNotifier<WebscoketState> {
}
disconnect() {
debugPrint("[WEBSOCKET] Attempting to disconnect");
log.info("Attempting to disconnect from websocket");
var socket = state.socket?.disconnect();
if (socket?.disconnected == true) {
state = WebscoketState(isConnected: false, socket: null);
state = WebsocketState(isConnected: false, socket: null);
}
}
stopListenToEvent(String eventName) {
debugPrint("[Websocket] Stop listening to event $eventName");
log.info("Stop listening to event $eventName");
state.socket?.off(eventName);
}
listenUploadEvent() {
debugPrint("[Websocket] Start listening to event on_upload_success");
log.info("Start listening to event on_upload_success");
state.socket?.on('on_upload_success', (data) {
var jsonString = jsonDecode(data.toString());
AssetResponseDto? newAsset = AssetResponseDto.fromJson(jsonString);
@ -132,6 +133,6 @@ class WebsocketNotifier extends StateNotifier<WebscoketState> {
}
final websocketProvider =
StateNotifierProvider<WebsocketNotifier, WebscoketState>((ref) {
StateNotifierProvider<WebsocketNotifier, WebsocketState>((ref) {
return WebsocketNotifier(ref);
});

View file

@ -0,0 +1,87 @@
import 'dart:io';
import 'package:flutter/widgets.dart';
import 'package:hive/hive.dart';
import 'package:immich_mobile/constants/hive_box.dart';
import 'package:immich_mobile/shared/models/immich_logger_message.model.dart';
import 'package:logging/logging.dart';
import 'package:path_provider/path_provider.dart';
import 'package:share_plus/share_plus.dart';
/// [ImmichLogger] is a custom logger that is built on top of the [logging] package.
/// The logs are written to a Hive box and onto console, using `debugPrint` method.
///
/// The logs are deleted when exceeding the `maxLogEntries` (default 200) property
/// in the class.
///
/// Logs can be shared by calling the `shareLogs` method, which will open a share dialog
/// and generate a csv file.
class ImmichLogger {
final maxLogEntries = 200;
final Box<ImmichLoggerMessage> _box = Hive.box(immichLoggerBox);
List<ImmichLoggerMessage> get messages =>
_box.values.toList().reversed.toList();
ImmichLogger() {
_removeOverflowMessages();
}
init() {
Logger.root.level = Level.INFO;
Logger.root.onRecord.listen(_writeLogToHiveBox);
}
_removeOverflowMessages() {
if (_box.length > maxLogEntries) {
var numberOfEntryToBeDeleted = _box.length - maxLogEntries;
for (var i = 0; i < numberOfEntryToBeDeleted; i++) {
_box.deleteAt(0);
}
}
}
_writeLogToHiveBox(LogRecord record) {
final Box<ImmichLoggerMessage> box = Hive.box(immichLoggerBox);
var formattedMessage = record.message;
debugPrint('[${record.level.name}] [${record.time}] ${record.message}');
box.add(
ImmichLoggerMessage(
message: formattedMessage,
level: record.level.name,
createdAt: record.time,
context1: record.loggerName,
context2: record.stackTrace
?.toString(), // Something more useful here? (e.g. stacktrace - I cannot get it to format nicely though)
),
);
}
void clearLogs() {
_box.clear();
}
shareLogs() async {
var tempDir = await getTemporaryDirectory();
var filePath = '${tempDir.path}/${DateTime.now().toIso8601String()}.csv';
var logFile = await File(filePath).create();
// Write header
logFile.writeAsStringSync("created_at,context_1,context_2,message,type\n");
// Write messages
for (var message in messages) {
logFile.writeAsStringSync(
"${message.createdAt},${message.context1 ?? ""},${message.context2 ?? ""},${message.message},${message.level.toString()}\n",
mode: FileMode.append,
);
}
// Share file
Share.shareFiles(
[filePath],
subject: "Immich logs ${DateTime.now().toIso8601String()}",
sharePositionOrigin: Rect.zero,
);
}
}

View file

@ -0,0 +1,153 @@
import 'package:auto_route/auto_route.dart';
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/shared/services/immich_logger.service.dart';
import 'package:intl/intl.dart';
class AppLogPage extends HookConsumerWidget {
const AppLogPage({
Key? key,
}) : super(key: key);
@override
Widget build(BuildContext context, WidgetRef ref) {
final immichLogger = ImmichLogger();
final logMessages = useState(immichLogger.messages);
Widget buildLeadingIcon(String level) {
switch (level) {
case "INFO":
return Container(
width: 10,
height: 10,
decoration: BoxDecoration(
color: Theme.of(context).primaryColor,
borderRadius: BorderRadius.circular(5),
),
);
case "SEVERE":
return Container(
width: 10,
height: 10,
decoration: BoxDecoration(
color: Colors.redAccent,
borderRadius: BorderRadius.circular(5),
),
);
case "WARNING":
return Container(
width: 10,
height: 10,
decoration: BoxDecoration(
color: Colors.orangeAccent,
borderRadius: BorderRadius.circular(5),
),
);
default:
return Container(
width: 10,
height: 10,
decoration: BoxDecoration(
color: Theme.of(context).primaryColor,
borderRadius: BorderRadius.circular(5),
),
);
}
}
getTileColor(String level) {
switch (level) {
case "INFO":
return Colors.transparent;
case "SEVERE":
return Colors.redAccent.withOpacity(0.075);
case "WARNING":
return Colors.orangeAccent.withOpacity(0.075);
default:
return Theme.of(context).primaryColor.withOpacity(0.1);
}
}
return Scaffold(
appBar: AppBar(
title: Text(
"Logs - ${logMessages.value.length}",
style: const TextStyle(
fontWeight: FontWeight.bold,
fontSize: 16.0,
),
),
scrolledUnderElevation: 1,
elevation: 2,
actions: [
IconButton(
icon: Icon(
Icons.delete_outline_rounded,
color: Theme.of(context).primaryColor,
semanticLabel: "Clear logs",
size: 20.0,
),
onPressed: () {
immichLogger.clearLogs();
logMessages.value = [];
},
),
IconButton(
icon: Icon(
Icons.share_rounded,
color: Theme.of(context).primaryColor,
semanticLabel: "Share logs",
size: 20.0,
),
onPressed: () {
immichLogger.shareLogs();
},
),
],
leading: IconButton(
onPressed: () {
AutoRouter.of(context).pop();
},
icon: const Icon(
Icons.arrow_back_ios_new_rounded,
size: 20.0,
),
),
centerTitle: true,
),
body: ListView.separated(
separatorBuilder: (context, index) {
return Divider(
height: 0,
color: Theme.of(context).brightness == Brightness.dark
? Colors.white70
: Colors.grey[500],
);
},
itemCount: logMessages.value.length,
itemBuilder: (context, index) {
var logMessage = logMessages.value[index];
return ListTile(
visualDensity: VisualDensity.compact,
dense: true,
tileColor: getTileColor(logMessage.level),
minLeadingWidth: 10,
title: Text(
logMessage.message,
style: const TextStyle(fontSize: 14.0, fontFamily: "Inconsolata"),
),
subtitle: Text(
"[${logMessage.context1}] Logged on ${DateFormat("HH:mm:ss.SSS").format(logMessage.createdAt)}",
style: TextStyle(
fontSize: 12.0,
color: Colors.grey[600],
),
),
leading: buildLeadingIcon(logMessage.level),
);
},
),
);
}
}