mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-04-01 01:20:21 +02:00
Enable strict-casts, strict-inference, and strict-raw-types in analysis_options.yaml. Add custom_lint with riverpod_lint. Fix all resulting type warnings with explicit type parameters and safer casts. Also improves APK update checker to detect device ABIs for correct variant selection and fixes Deezer artist name parsing edge case.
504 lines
14 KiB
Dart
504 lines
14 KiB
Dart
import 'dart:async';
|
|
import 'dart:collection';
|
|
import 'dart:io';
|
|
import 'package:flutter/foundation.dart';
|
|
import 'package:logger/logger.dart';
|
|
import 'package:device_info_plus/device_info_plus.dart';
|
|
import 'package:spotiflac_android/constants/app_info.dart';
|
|
import 'package:spotiflac_android/services/platform_bridge.dart';
|
|
|
|
const int _maxLogMessageLength = 500;
|
|
const String _redactedValue = '[REDACTED]';
|
|
|
|
final RegExp _authorizationBearerPattern = RegExp(
|
|
r'\bAuthorization\b\s*[:=]\s*Bearer\s+[A-Za-z0-9._~+/\-]+=*',
|
|
caseSensitive: false,
|
|
);
|
|
|
|
final RegExp _genericSensitiveKeyValuePattern = RegExp(
|
|
r'\b(access[_\s-]?token|refresh[_\s-]?token|id[_\s-]?token|client[_\s-]?secret|authorization|password|api[_\s-]?key)\b(\s*[:=]\s*)([^\s,;]+)',
|
|
caseSensitive: false,
|
|
);
|
|
|
|
final RegExp _sensitiveQueryPattern = RegExp(
|
|
r'([?&](?:access_token|refresh_token|id_token|token|client_secret|api_key|apikey|password)=)[^&\s]+',
|
|
caseSensitive: false,
|
|
);
|
|
|
|
final RegExp _bearerTokenPattern = RegExp(
|
|
r'\bBearer\s+[A-Za-z0-9._~+/\-]+=*',
|
|
caseSensitive: false,
|
|
);
|
|
|
|
String _truncateLogText(String value, {int maxLength = _maxLogMessageLength}) {
|
|
if (value.length <= maxLength) {
|
|
return value;
|
|
}
|
|
return '${value.substring(0, maxLength)}...[truncated]';
|
|
}
|
|
|
|
String _redactSensitiveText(String value) {
|
|
var redacted = value;
|
|
|
|
redacted = redacted.replaceAllMapped(_authorizationBearerPattern, (_) {
|
|
return 'Authorization: Bearer $_redactedValue';
|
|
});
|
|
|
|
redacted = redacted.replaceAllMapped(_genericSensitiveKeyValuePattern, (
|
|
match,
|
|
) {
|
|
final key = match.group(1) ?? '';
|
|
final delimiter = match.group(2) ?? '=';
|
|
return '$key$delimiter$_redactedValue';
|
|
});
|
|
|
|
redacted = redacted.replaceAllMapped(_sensitiveQueryPattern, (match) {
|
|
final prefix = match.group(1) ?? '';
|
|
return '$prefix$_redactedValue';
|
|
});
|
|
|
|
redacted = redacted.replaceAllMapped(_bearerTokenPattern, (_) {
|
|
return 'Bearer $_redactedValue';
|
|
});
|
|
|
|
return redacted;
|
|
}
|
|
|
|
class LogEntry {
|
|
final DateTime timestamp;
|
|
final String level;
|
|
final String tag;
|
|
final String message;
|
|
final String? error;
|
|
final bool isFromGo;
|
|
|
|
LogEntry({
|
|
required this.timestamp,
|
|
required this.level,
|
|
required this.tag,
|
|
required this.message,
|
|
this.error,
|
|
this.isFromGo = false,
|
|
});
|
|
|
|
String get formattedTime {
|
|
final h = timestamp.hour.toString().padLeft(2, '0');
|
|
final m = timestamp.minute.toString().padLeft(2, '0');
|
|
final s = timestamp.second.toString().padLeft(2, '0');
|
|
final ms = timestamp.millisecond.toString().padLeft(3, '0');
|
|
return '$h:$m:$s.$ms';
|
|
}
|
|
|
|
@override
|
|
String toString() {
|
|
final errorPart = error != null ? ' | $error' : '';
|
|
final goPart = isFromGo ? ' [Go]' : '';
|
|
return '[$formattedTime] [$level]$goPart [$tag] $message$errorPart';
|
|
}
|
|
}
|
|
|
|
class LogBuffer extends ChangeNotifier {
|
|
static final LogBuffer _instance = LogBuffer._internal();
|
|
factory LogBuffer() => _instance;
|
|
LogBuffer._internal();
|
|
|
|
static const int maxEntries = 500;
|
|
static const Duration _goLogPollingInterval = Duration(milliseconds: 800);
|
|
final Queue<LogEntry> _entries = Queue<LogEntry>();
|
|
Timer? _goLogTimer;
|
|
int _lastGoLogIndex = 0;
|
|
bool _isFetchingGoLogs = false;
|
|
|
|
static bool _loggingEnabled = false;
|
|
static bool get loggingEnabled => _loggingEnabled;
|
|
static set loggingEnabled(bool value) {
|
|
_loggingEnabled = value;
|
|
if (value) {
|
|
PlatformBridge.setGoLoggingEnabled(true).catchError((_) {});
|
|
} else {
|
|
PlatformBridge.setGoLoggingEnabled(false).catchError((_) {});
|
|
}
|
|
}
|
|
|
|
List<LogEntry> get entries => _entries.toList();
|
|
int get length => _entries.length;
|
|
|
|
void add(LogEntry entry) {
|
|
if (!_loggingEnabled && entry.level != 'ERROR' && entry.level != 'FATAL') {
|
|
return;
|
|
}
|
|
|
|
final sanitizedMessage = _truncateLogText(
|
|
_redactSensitiveText(entry.message),
|
|
);
|
|
final sanitizedError = entry.error != null
|
|
? _truncateLogText(_redactSensitiveText(entry.error!))
|
|
: null;
|
|
final sanitizedEntry =
|
|
(sanitizedMessage == entry.message && sanitizedError == entry.error)
|
|
? entry
|
|
: LogEntry(
|
|
timestamp: entry.timestamp,
|
|
level: entry.level,
|
|
tag: entry.tag,
|
|
message: sanitizedMessage,
|
|
error: sanitizedError,
|
|
isFromGo: entry.isFromGo,
|
|
);
|
|
|
|
if (_entries.length >= maxEntries) {
|
|
_entries.removeFirst();
|
|
}
|
|
_entries.add(sanitizedEntry);
|
|
notifyListeners();
|
|
}
|
|
|
|
void startGoLogPolling() {
|
|
_goLogTimer?.cancel();
|
|
_goLogTimer = Timer.periodic(_goLogPollingInterval, (_) async {
|
|
if (_isFetchingGoLogs) return;
|
|
_isFetchingGoLogs = true;
|
|
try {
|
|
await _fetchGoLogs();
|
|
} finally {
|
|
_isFetchingGoLogs = false;
|
|
}
|
|
});
|
|
}
|
|
|
|
void stopGoLogPolling() {
|
|
_goLogTimer?.cancel();
|
|
_goLogTimer = null;
|
|
_isFetchingGoLogs = false;
|
|
}
|
|
|
|
Future<void> _fetchGoLogs() async {
|
|
try {
|
|
final result = await PlatformBridge.getGoLogsSince(_lastGoLogIndex);
|
|
final logs = result['logs'] as List<dynamic>? ?? [];
|
|
final nextIndex = result['next_index'] as int? ?? _lastGoLogIndex;
|
|
final keepNonErrorLogs = _loggingEnabled;
|
|
|
|
for (final log in logs.whereType<Map<Object?, Object?>>()) {
|
|
final logMap = Map<String, dynamic>.from(log);
|
|
final level = logMap['level'] as String? ?? 'INFO';
|
|
if (!keepNonErrorLogs && level != 'ERROR' && level != 'FATAL') {
|
|
continue;
|
|
}
|
|
|
|
final timestamp = logMap['timestamp'] as String? ?? '';
|
|
final tag = logMap['tag'] as String? ?? 'Go';
|
|
final message = logMap['message'] as String? ?? '';
|
|
|
|
DateTime parsedTime = DateTime.now();
|
|
if (timestamp.isNotEmpty) {
|
|
try {
|
|
final parts = timestamp.split(':');
|
|
if (parts.length >= 3) {
|
|
final secParts = parts[2].split('.');
|
|
parsedTime = DateTime(
|
|
parsedTime.year,
|
|
parsedTime.month,
|
|
parsedTime.day,
|
|
int.parse(parts[0]),
|
|
int.parse(parts[1]),
|
|
int.parse(secParts[0]),
|
|
secParts.length > 1 ? int.parse(secParts[1]) : 0,
|
|
);
|
|
}
|
|
} catch (_) {}
|
|
}
|
|
|
|
add(
|
|
LogEntry(
|
|
timestamp: parsedTime,
|
|
level: level,
|
|
tag: tag,
|
|
message: message,
|
|
isFromGo: true,
|
|
),
|
|
);
|
|
}
|
|
|
|
_lastGoLogIndex = nextIndex;
|
|
} catch (e) {
|
|
if (kDebugMode) {
|
|
debugPrint('Failed to fetch Go logs: $e');
|
|
}
|
|
}
|
|
}
|
|
|
|
void clear() {
|
|
_entries.clear();
|
|
_lastGoLogIndex = 0;
|
|
PlatformBridge.clearGoLogs().catchError((_) {});
|
|
notifyListeners();
|
|
}
|
|
|
|
String export() {
|
|
final buffer = StringBuffer();
|
|
buffer.writeln('SpotiFLAC Log Export');
|
|
buffer.writeln('Generated: ${DateTime.now().toIso8601String()}');
|
|
buffer.writeln('Entries: ${_entries.length}');
|
|
buffer.writeln('=' * 60);
|
|
buffer.writeln();
|
|
for (final entry in _entries) {
|
|
buffer.writeln(entry.toString());
|
|
}
|
|
return buffer.toString();
|
|
}
|
|
|
|
Future<String> exportWithDeviceInfo() async {
|
|
final buffer = StringBuffer();
|
|
|
|
buffer.writeln('=' * 60);
|
|
buffer.writeln('SPOTIFLAC LOG EXPORT');
|
|
buffer.writeln('=' * 60);
|
|
buffer.writeln();
|
|
|
|
buffer.writeln('--- App Information ---');
|
|
buffer.writeln(
|
|
'App Version: ${AppInfo.version} (Build ${AppInfo.buildNumber})',
|
|
);
|
|
buffer.writeln('Generated: ${DateTime.now().toIso8601String()}');
|
|
buffer.writeln();
|
|
|
|
buffer.writeln('--- Device Information ---');
|
|
try {
|
|
final deviceInfo = DeviceInfoPlugin();
|
|
|
|
if (Platform.isAndroid) {
|
|
final android = await deviceInfo.androidInfo;
|
|
buffer.writeln('Platform: Android');
|
|
buffer.writeln('Device: ${android.manufacturer} ${android.model}');
|
|
buffer.writeln('Brand: ${android.brand}');
|
|
buffer.writeln(
|
|
'Android Version: ${android.version.release} (SDK ${android.version.sdkInt})',
|
|
);
|
|
buffer.writeln('Build ID: ${android.id}');
|
|
if (android.version.securityPatch != null &&
|
|
android.version.securityPatch!.isNotEmpty) {
|
|
buffer.writeln('Security Patch: ${android.version.securityPatch}');
|
|
}
|
|
buffer.writeln('Hardware: ${android.hardware}');
|
|
buffer.writeln('Product: ${android.product}');
|
|
buffer.writeln('Supported ABIs: ${android.supportedAbis.join(', ')}');
|
|
buffer.writeln('Is Physical Device: ${android.isPhysicalDevice}');
|
|
} else if (Platform.isIOS) {
|
|
final ios = await deviceInfo.iosInfo;
|
|
buffer.writeln('Platform: iOS');
|
|
buffer.writeln('Device: ${ios.utsname.machine}');
|
|
buffer.writeln('Model: ${ios.model}');
|
|
buffer.writeln('System Name: ${ios.systemName}');
|
|
buffer.writeln('System Version: ${ios.systemVersion}');
|
|
buffer.writeln('Device Name: ${ios.name}');
|
|
buffer.writeln('Is Physical Device: ${ios.isPhysicalDevice}');
|
|
}
|
|
} catch (e) {
|
|
buffer.writeln('Failed to get device info: $e');
|
|
}
|
|
buffer.writeln();
|
|
|
|
buffer.writeln('--- Log Summary ---');
|
|
buffer.writeln('Total Entries: ${_entries.length}');
|
|
|
|
int errorCount = 0;
|
|
int warnCount = 0;
|
|
int infoCount = 0;
|
|
int debugCount = 0;
|
|
int goCount = 0;
|
|
|
|
for (final entry in _entries) {
|
|
switch (entry.level) {
|
|
case 'ERROR':
|
|
case 'FATAL':
|
|
errorCount++;
|
|
break;
|
|
case 'WARN':
|
|
warnCount++;
|
|
break;
|
|
case 'INFO':
|
|
infoCount++;
|
|
break;
|
|
case 'DEBUG':
|
|
debugCount++;
|
|
break;
|
|
}
|
|
if (entry.isFromGo) goCount++;
|
|
}
|
|
|
|
buffer.writeln('Errors: $errorCount');
|
|
buffer.writeln('Warnings: $warnCount');
|
|
buffer.writeln('Info: $infoCount');
|
|
buffer.writeln('Debug: $debugCount');
|
|
buffer.writeln('From Go Backend: $goCount');
|
|
buffer.writeln();
|
|
|
|
buffer.writeln('=' * 60);
|
|
buffer.writeln('LOG ENTRIES');
|
|
buffer.writeln('=' * 60);
|
|
buffer.writeln();
|
|
|
|
for (final entry in _entries) {
|
|
buffer.writeln(entry.toString());
|
|
}
|
|
|
|
return buffer.toString();
|
|
}
|
|
|
|
List<LogEntry> filter({String? level, String? tag, String? search}) {
|
|
final tagLower = tag?.toLowerCase();
|
|
final searchLower = search?.toLowerCase();
|
|
|
|
return _entries.where((entry) {
|
|
if (level != null && level != 'ALL' && entry.level != level) {
|
|
return false;
|
|
}
|
|
if (tagLower != null && !entry.tag.toLowerCase().contains(tagLower)) {
|
|
return false;
|
|
}
|
|
if (searchLower != null && searchLower.isNotEmpty) {
|
|
return entry.message.toLowerCase().contains(searchLower) ||
|
|
entry.tag.toLowerCase().contains(searchLower) ||
|
|
(entry.error?.toLowerCase().contains(searchLower) ?? false);
|
|
}
|
|
return true;
|
|
}).toList();
|
|
}
|
|
}
|
|
|
|
class BufferedOutput extends LogOutput {
|
|
final String tag;
|
|
|
|
BufferedOutput(this.tag);
|
|
|
|
@override
|
|
void output(OutputEvent event) {
|
|
if (kDebugMode) {
|
|
for (final line in event.lines) {
|
|
debugPrint(_truncateLogText(_redactSensitiveText(line)));
|
|
}
|
|
}
|
|
|
|
final level = _levelToString(event.level);
|
|
final message = _truncateLogText(
|
|
_redactSensitiveText(event.lines.join('\n')),
|
|
);
|
|
|
|
LogBuffer().add(
|
|
LogEntry(
|
|
timestamp: DateTime.now(),
|
|
level: level,
|
|
tag: tag,
|
|
message: message,
|
|
),
|
|
);
|
|
}
|
|
|
|
String _levelToString(Level level) {
|
|
switch (level) {
|
|
case Level.debug:
|
|
return 'DEBUG';
|
|
case Level.info:
|
|
return 'INFO';
|
|
case Level.warning:
|
|
return 'WARN';
|
|
case Level.error:
|
|
return 'ERROR';
|
|
case Level.fatal:
|
|
return 'FATAL';
|
|
default:
|
|
return 'LOG';
|
|
}
|
|
}
|
|
}
|
|
|
|
final log = Logger(
|
|
printer: PrettyPrinter(
|
|
methodCount: 0,
|
|
errorMethodCount: 5,
|
|
lineLength: 80,
|
|
colors: true,
|
|
printEmojis: false,
|
|
dateTimeFormat: DateTimeFormat.none,
|
|
),
|
|
level: Level.debug,
|
|
);
|
|
|
|
class AppLogger {
|
|
final String _tag;
|
|
late final Logger? _logger;
|
|
|
|
AppLogger(this._tag) {
|
|
if (kDebugMode) {
|
|
_logger = Logger(
|
|
printer: SimplePrinter(printTime: false, colors: false),
|
|
output: BufferedOutput(_tag),
|
|
level: Level.debug,
|
|
);
|
|
} else {
|
|
_logger = null;
|
|
}
|
|
}
|
|
|
|
void _addToBuffer(String level, String message, {String? error}) {
|
|
if (!LogBuffer.loggingEnabled && level != 'ERROR' && level != 'FATAL') {
|
|
return;
|
|
}
|
|
|
|
LogBuffer().add(
|
|
LogEntry(
|
|
timestamp: DateTime.now(),
|
|
level: level,
|
|
tag: _tag,
|
|
message: message,
|
|
error: error,
|
|
),
|
|
);
|
|
}
|
|
|
|
void d(String message) {
|
|
if (kDebugMode) {
|
|
_logger?.d(message);
|
|
} else {
|
|
_addToBuffer('DEBUG', message);
|
|
}
|
|
}
|
|
|
|
void i(String message) {
|
|
if (kDebugMode) {
|
|
_logger?.i(message);
|
|
} else {
|
|
_addToBuffer('INFO', message);
|
|
}
|
|
}
|
|
|
|
void w(String message) {
|
|
if (kDebugMode) {
|
|
_logger?.w(message);
|
|
} else {
|
|
_addToBuffer('WARN', message);
|
|
}
|
|
}
|
|
|
|
void e(String message, [Object? error, StackTrace? stackTrace]) {
|
|
if (error != null) {
|
|
_addToBuffer('ERROR', message, error: error.toString());
|
|
if (kDebugMode) {
|
|
debugPrint(
|
|
'[$_tag] ERROR: ${_truncateLogText(_redactSensitiveText(message))} | ${_truncateLogText(_redactSensitiveText(error.toString()))}',
|
|
);
|
|
if (stackTrace != null) {
|
|
debugPrint(stackTrace.toString());
|
|
}
|
|
}
|
|
} else {
|
|
if (kDebugMode) {
|
|
_logger?.e(message);
|
|
} else {
|
|
_addToBuffer('ERROR', message);
|
|
}
|
|
}
|
|
}
|
|
}
|