|
|
|
import 'dart:convert';
|
|
|
|
import 'dart:io';
|
|
|
|
import 'dart:typed_data';
|
|
|
|
|
|
|
|
import 'package:path_provider/path_provider.dart';
|
|
|
|
|
|
|
|
import 'app_logger.dart';
|
|
|
|
|
|
|
|
/// Persists structured diagnostic events (region detection, login/user-prefs lifecycle, etc.)
|
|
|
|
/// to a local JSON file using path_provider, which can be shared from Developer Options.
|
|
|
|
class AppDiagnosticLogStore {
|
|
|
|
AppDiagnosticLogStore({Directory? rootDirectory})
|
|
|
|
: _rootDirectory = rootDirectory;
|
|
|
|
|
|
|
|
static final AppDiagnosticLogStore instance = AppDiagnosticLogStore();
|
|
|
|
|
|
|
|
static const _fileName = 'app_diagnostic_events.json';
|
|
|
|
static const _maxEventCount = 500;
|
|
|
|
|
|
|
|
final Directory? _rootDirectory;
|
|
|
|
Future<void> _writeQueue = Future.value();
|
|
|
|
|
|
|
|
/// Appends a diagnostic event to the local file.
|
|
|
|
Future<void> log({
|
|
|
|
required String category,
|
|
|
|
required String action,
|
|
|
|
Map<String, Object?>? details,
|
|
|
|
}) {
|
|
|
|
_writeQueue = _writeQueue.then((_) async {
|
|
|
|
try {
|
|
|
|
final now = DateTime.now();
|
|
|
|
final event = <String, Object?>{
|
|
|
|
'category': category,
|
|
|
|
'action': action,
|
|
|
|
'timestamp': now.toIso8601String(),
|
|
|
|
'timestamp_readable': _readableTime(now),
|
|
|
|
'timestamp_unix': now.millisecondsSinceEpoch / 1000,
|
|
|
|
if (details != null && details.isNotEmpty)
|
|
|
|
'details': _sanitizeMap(details),
|
|
|
|
};
|
|
|
|
|
|
|
|
try {
|
|
|
|
AppLogger.d('[$category] $action details=$details');
|
|
|
|
} catch (_) {}
|
|
|
|
|
|
|
|
final file = await _file();
|
|
|
|
await file.parent.create(recursive: true);
|
|
|
|
final events = await _readEvents(file);
|
|
|
|
events.add(event);
|
|
|
|
|
|
|
|
final limited = events.length > _maxEventCount
|
|
|
|
? events.sublist(events.length - _maxEventCount)
|
|
|
|
: events;
|
|
|
|
|
|
|
|
const encoder = JsonEncoder.withIndent(' ');
|
|
|
|
await file.writeAsString(encoder.convert(limited), flush: true);
|
|
|
|
} catch (error, stackTrace) {
|
|
|
|
try {
|
|
|
|
AppLogger.e('AppDiagnosticLogStore log failed', error, stackTrace);
|
|
|
|
} catch (_) {}
|
|
|
|
}
|
|
|
|
});
|
|
|
|
return _writeQueue;
|
|
|
|
}
|
|
|
|
|
|
|
|
/// Reads the log file as [Uint8List] bytes for sharing via platform APIs.
|
|
|
|
Future<Uint8List> readBytes() async {
|
|
|
|
final file = await _file();
|
|
|
|
if (!await file.exists()) {
|
|
|
|
const emptyContent = '[\n {\n "message": "No diagnostic logs found"\n }\n]';
|
|
|
|
return Uint8List.fromList(utf8.encode(emptyContent));
|
|
|
|
}
|
|
|
|
return await file.readAsBytes();
|
|
|
|
}
|
|
|
|
|
|
|
|
/// Reads the log file content as a formatted string.
|
|
|
|
Future<String> readText() async {
|
|
|
|
final file = await _file();
|
|
|
|
if (!await file.exists()) {
|
|
|
|
return '暂无诊断日志';
|
|
|
|
}
|
|
|
|
final text = await file.readAsString();
|
|
|
|
return text.trim().isEmpty ? '暂无诊断日志' : text;
|
|
|
|
}
|
|
|
|
|
|
|
|
/// Clears the log file.
|
|
|
|
Future<void> clear() async {
|
|
|
|
final file = await _file();
|
|
|
|
if (await file.exists()) {
|
|
|
|
await file.delete();
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
Future<File> _file() async {
|
|
|
|
final root = _rootDirectory ?? await getApplicationDocumentsDirectory();
|
|
|
|
return File('${root.path}/$_fileName');
|
|
|
|
}
|
|
|
|
|
|
|
|
Future<List<Map<String, Object?>>> _readEvents(File file) async {
|
|
|
|
if (!await file.exists()) {
|
|
|
|
return <Map<String, Object?>>[];
|
|
|
|
}
|
|
|
|
try {
|
|
|
|
final text = await file.readAsString();
|
|
|
|
if (text.trim().isEmpty) {
|
|
|
|
return <Map<String, Object?>>[];
|
|
|
|
}
|
|
|
|
final json = jsonDecode(text);
|
|
|
|
if (json is List) {
|
|
|
|
return json
|
|
|
|
.whereType<Map>()
|
|
|
|
.map((e) => _sanitizeMap(e.cast<String, Object?>()))
|
|
|
|
.toList();
|
|
|
|
}
|
|
|
|
} catch (_) {}
|
|
|
|
return <Map<String, Object?>>[];
|
|
|
|
}
|
|
|
|
|
|
|
|
static Map<String, Object?> _sanitizeMap(Map<String, Object?> map) {
|
|
|
|
return map.map((key, value) => MapEntry(key, _sanitizeValue(value)));
|
|
|
|
}
|
|
|
|
|
|
|
|
static Object? _sanitizeValue(Object? value) {
|
|
|
|
return switch (value) {
|
|
|
|
null => null,
|
|
|
|
String() => value,
|
|
|
|
num() => value,
|
|
|
|
bool() => value,
|
|
|
|
DateTime() => value.toIso8601String(),
|
|
|
|
List() => value.map(_sanitizeValue).toList(),
|
|
|
|
Map() => value.map(
|
|
|
|
(key, val) => MapEntry(key.toString(), _sanitizeValue(val)),
|
|
|
|
),
|
|
|
|
_ => value.toString(),
|
|
|
|
};
|
|
|
|
}
|
|
|
|
|
|
|
|
static String _readableTime(DateTime time) {
|
|
|
|
return '${time.year.toString().padLeft(4, '0')}-'
|
|
|
|
'${time.month.toString().padLeft(2, '0')}-'
|
|
|
|
'${time.day.toString().padLeft(2, '0')} '
|
|
|
|
'${time.hour.toString().padLeft(2, '0')}:'
|
|
|
|
'${time.minute.toString().padLeft(2, '0')}:'
|
|
|
|
'${time.second.toString().padLeft(2, '0')}';
|
|
|
|
}
|
|
|
|
} |
...
|
...
|
|