mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-08-03 01:28:37 +02:00
fix(ui): sanitize user-facing errors
This commit is contained in:
@@ -329,7 +329,11 @@ Future<void> _completeSessionGrantFromClipboard(
|
||||
} catch (e) {
|
||||
if (!context.mounted) return;
|
||||
messenger?.showSnackBar(
|
||||
SnackBar(content: Text('Verification callback failed: $e')),
|
||||
SnackBar(
|
||||
content: Text(
|
||||
'Verification callback failed: ${context.friendlyError(e)}',
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22,9 +22,8 @@ Future<bool> playLocalIfAvailable(
|
||||
final historyNotifier = ref.read(downloadHistoryProvider.notifier);
|
||||
|
||||
try {
|
||||
DownloadHistoryItem? historyItem = await historyNotifier.getBySpotifyIdAsync(
|
||||
track.id,
|
||||
);
|
||||
DownloadHistoryItem? historyItem = await historyNotifier
|
||||
.getBySpotifyIdAsync(track.id);
|
||||
final isrc = track.isrc?.trim();
|
||||
historyItem ??= (isrc != null && isrc.isNotEmpty)
|
||||
? await historyNotifier.getByIsrcAsync(isrc)
|
||||
@@ -74,7 +73,11 @@ Future<bool> playLocalIfAvailable(
|
||||
} catch (e) {
|
||||
if (context.mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text(context.l10n.snackbarCannotOpenFile('$e'))),
|
||||
SnackBar(
|
||||
content: Text(
|
||||
context.l10n.snackbarCannotOpenFile(context.friendlyError(e)),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
return true;
|
||||
|
||||
@@ -0,0 +1,188 @@
|
||||
import 'package:flutter/services.dart';
|
||||
|
||||
const defaultUserFacingErrorMessage =
|
||||
'The operation could not be completed. Please try again.';
|
||||
|
||||
/// Converts an internal exception or backend error into text that is safe to
|
||||
/// render in the UI. The original error should still be sent to the app logger
|
||||
/// before this function is called when diagnostic detail is needed.
|
||||
String userFacingErrorMessage(
|
||||
Object? error, {
|
||||
String fallback = defaultUserFacingErrorMessage,
|
||||
}) {
|
||||
final safeFallback = fallback.trim().isEmpty
|
||||
? defaultUserFacingErrorMessage
|
||||
: fallback.trim();
|
||||
|
||||
if (error == null) return safeFallback;
|
||||
|
||||
if (error is PlatformException) {
|
||||
final message = error.message?.trim();
|
||||
if (_isMeaningful(message)) {
|
||||
return _sanitizeText(message!, fallback: safeFallback);
|
||||
}
|
||||
return _messageFromCode(error.code, fallback: safeFallback);
|
||||
}
|
||||
|
||||
return _sanitizeText(error.toString(), fallback: safeFallback);
|
||||
}
|
||||
|
||||
String _sanitizeText(String raw, {required String fallback}) {
|
||||
var message = raw.trim();
|
||||
if (!_isMeaningful(message)) return fallback;
|
||||
|
||||
message = _stripLeadingWrappers(message);
|
||||
|
||||
final platformMatch = RegExp(
|
||||
r'^PlatformException\(\s*([^,]+)\s*,\s*(.*)\)$',
|
||||
caseSensitive: false,
|
||||
dotAll: true,
|
||||
).firstMatch(message);
|
||||
if (platformMatch != null) {
|
||||
final code = platformMatch.group(1)?.trim() ?? '';
|
||||
final body = platformMatch.group(2)?.trim() ?? '';
|
||||
final parsedMessage = _platformMessage(body);
|
||||
if (_isMeaningful(parsedMessage)) {
|
||||
return _sanitizeText(parsedMessage!, fallback: fallback);
|
||||
}
|
||||
return _messageFromCode(code, fallback: fallback);
|
||||
}
|
||||
|
||||
final lower = message.toLowerCase();
|
||||
if (lower.contains('socketexception') ||
|
||||
lower.contains('failed host lookup') ||
|
||||
lower.contains('connection refused') ||
|
||||
lower.contains('network is unreachable')) {
|
||||
return 'Unable to connect. Check your internet connection.';
|
||||
}
|
||||
if (lower.contains('timeoutexception') ||
|
||||
lower.contains('timed out') ||
|
||||
lower.contains('timeout')) {
|
||||
return 'The request timed out. Please try again.';
|
||||
}
|
||||
if (lower.contains('missingpluginexception') ||
|
||||
lower.contains('no implementation found for method') ||
|
||||
lower.contains('channel-error') ||
|
||||
lower.contains('null check operator used on a null value') ||
|
||||
lower.contains('lateinitializationerror') ||
|
||||
lower.contains('nosuchmethoderror') ||
|
||||
lower.contains('is not a subtype of') ||
|
||||
lower.startsWith('instance of ')) {
|
||||
return fallback;
|
||||
}
|
||||
|
||||
final stackStart = message.indexOf(
|
||||
RegExp(r'\n\s*(?:#\d+|at\s+\S+|<asynchronous suspension>)'),
|
||||
);
|
||||
if (stackStart >= 0) message = message.substring(0, stackStart);
|
||||
|
||||
message = message
|
||||
.replaceFirst(RegExp(r'\s*\(OS Error:.*$', dotAll: true), '')
|
||||
.replaceFirst(
|
||||
RegExp(
|
||||
r',\s*(?:path|source|target)\s*=\s*.*$',
|
||||
caseSensitive: false,
|
||||
dotAll: true,
|
||||
),
|
||||
'',
|
||||
)
|
||||
.replaceFirst(
|
||||
RegExp(r',\s*errno\s*=\s*\d+.*$', caseSensitive: false),
|
||||
'',
|
||||
);
|
||||
|
||||
message = _stripLeadingWrappers(
|
||||
message,
|
||||
).replaceAll(RegExp(r'\s+'), ' ').trim();
|
||||
message = message.replaceFirst(RegExp(r'^[,;:\s]+'), '').trim();
|
||||
|
||||
if (!_isMeaningful(message) ||
|
||||
message.toLowerCase().contains('platformexception')) {
|
||||
return fallback;
|
||||
}
|
||||
|
||||
const maxLength = 240;
|
||||
if (message.length > maxLength) {
|
||||
final shortened = message.substring(0, maxLength);
|
||||
final lastSpace = shortened.lastIndexOf(' ');
|
||||
message =
|
||||
'${shortened.substring(0, lastSpace > 160 ? lastSpace : maxLength)}…';
|
||||
}
|
||||
return message;
|
||||
}
|
||||
|
||||
String? _platformMessage(String body) {
|
||||
final withDetailsAndStack = RegExp(
|
||||
r'^(.*),\s*(?:null|\{.*\}|\[.*\]),\s*(?:null|.*)$',
|
||||
caseSensitive: false,
|
||||
dotAll: true,
|
||||
).firstMatch(body);
|
||||
var candidate = withDetailsAndStack?.group(1)?.trim();
|
||||
|
||||
candidate ??= RegExp(
|
||||
r'^(.*),\s*null\s*,\s*null$',
|
||||
caseSensitive: false,
|
||||
dotAll: true,
|
||||
).firstMatch(body)?.group(1)?.trim();
|
||||
candidate ??= RegExp(
|
||||
r'^(.*),\s*null$',
|
||||
caseSensitive: false,
|
||||
dotAll: true,
|
||||
).firstMatch(body)?.group(1)?.trim();
|
||||
candidate ??= body.trim();
|
||||
|
||||
return _isMeaningful(candidate) ? candidate : null;
|
||||
}
|
||||
|
||||
String _messageFromCode(String code, {required String fallback}) {
|
||||
var normalized = code.trim().replaceAll(RegExp(r'''^["']|["']$'''), '');
|
||||
final lower = normalized.toLowerCase().replaceAll('-', '_');
|
||||
const genericCodes = {
|
||||
'',
|
||||
'error',
|
||||
'err',
|
||||
'failed',
|
||||
'failure',
|
||||
'unknown',
|
||||
'exception',
|
||||
'internal',
|
||||
'internal_error',
|
||||
'platform_error',
|
||||
'channel_error',
|
||||
'unavailable',
|
||||
'-1',
|
||||
};
|
||||
if (genericCodes.contains(lower) || RegExp(r'^\d+$').hasMatch(lower)) {
|
||||
return fallback;
|
||||
}
|
||||
if (lower.contains('cancel')) return 'The operation was cancelled.';
|
||||
|
||||
normalized = lower
|
||||
.replaceFirst(RegExp(r'^(?:error|err)_'), '')
|
||||
.replaceAll(RegExp(r'[_-]+'), ' ')
|
||||
.trim();
|
||||
if (normalized.isEmpty) return fallback;
|
||||
return '${normalized[0].toUpperCase()}${normalized.substring(1)}';
|
||||
}
|
||||
|
||||
String _stripLeadingWrappers(String value) {
|
||||
var message = value.trim();
|
||||
final wrapper = RegExp(
|
||||
r'^(?:Exception|Error|StateError|ArgumentError|FormatException|FileSystemException|HttpException|HandshakeException|Bad state|Invalid argument\(s\))\s*:\s*',
|
||||
caseSensitive: false,
|
||||
);
|
||||
while (wrapper.hasMatch(message)) {
|
||||
message = message.replaceFirst(wrapper, '').trim();
|
||||
}
|
||||
return message;
|
||||
}
|
||||
|
||||
bool _isMeaningful(String? value) {
|
||||
if (value == null) return false;
|
||||
final normalized = value.trim().toLowerCase();
|
||||
return normalized.isNotEmpty &&
|
||||
normalized != 'null' &&
|
||||
normalized != 'none' &&
|
||||
normalized != 'unknown' &&
|
||||
normalized != 'error';
|
||||
}
|
||||
Reference in New Issue
Block a user