fix(ui): sanitize user-facing errors

This commit is contained in:
zarzet
2026-07-29 01:43:04 +07:00
parent b1c18b95e7
commit 662558e3e6
31 changed files with 451 additions and 100 deletions
+6
View File
@@ -1,8 +1,14 @@
import 'package:flutter/material.dart';
import 'package:spotiflac_android/l10n/app_localizations.dart';
import 'package:spotiflac_android/utils/user_facing_error.dart';
export 'package:spotiflac_android/l10n/app_localizations.dart';
extension AppLocalizationsX on BuildContext {
AppLocalizations get l10n => AppLocalizations.of(this);
String friendlyError(
Object? error, {
String fallback = defaultUserFacingErrorMessage,
}) => userFacingErrorMessage(error, fallback: fallback);
}
+1 -1
View File
@@ -183,7 +183,7 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen>
} catch (e) {
if (mounted) {
setState(() {
_error = e.toString();
_error = context.friendlyError(e);
_isLoading = false;
});
}
+1 -1
View File
@@ -366,7 +366,7 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen>
} catch (e) {
if (mounted) {
setState(() {
_error = e.toString();
_error = context.friendlyError(e);
_isLoadingDiscography = false;
});
}
+3 -1
View File
@@ -185,7 +185,9 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen>
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(context.l10n.snackbarCannotOpenFile(e.toString())),
content: Text(
context.l10n.snackbarCannotOpenFile(context.friendlyError(e)),
),
),
);
}
+4 -4
View File
@@ -685,7 +685,7 @@ class _LoadingOrErrorScaffold extends StatelessWidget {
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(
error!,
context.friendlyError(error),
style: TextStyle(color: Theme.of(context).colorScheme.error),
),
const SizedBox(height: 16),
@@ -969,7 +969,7 @@ class _ExtensionAlbumScreenState extends ConsumerState<ExtensionAlbumScreen> {
} catch (e) {
if (!mounted) return;
setState(() {
_error = context.l10n.snackbarError(e.toString());
_error = context.l10n.snackbarError(context.friendlyError(e));
_isLoading = false;
});
}
@@ -1115,7 +1115,7 @@ class _ExtensionPlaylistScreenState
} catch (e) {
if (!mounted) return;
setState(() {
_error = context.l10n.snackbarError(e.toString());
_error = context.l10n.snackbarError(context.friendlyError(e));
_isLoading = false;
});
}
@@ -1267,7 +1267,7 @@ class _ExtensionArtistScreenState extends ConsumerState<ExtensionArtistScreen>
} catch (e) {
if (!mounted) return;
setState(() {
_error = context.l10n.snackbarError(e.toString());
_error = context.l10n.snackbarError(context.friendlyError(e));
_isLoading = false;
});
}
+3 -1
View File
@@ -160,7 +160,9 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen>
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(context.l10n.snackbarCannotOpenFile(e.toString())),
content: Text(
context.l10n.snackbarCannotOpenFile(context.friendlyError(e)),
),
),
);
}
+1 -1
View File
@@ -221,7 +221,7 @@ class _MainShellState extends ConsumerState<MainShell>
SnackBar(
content: Text(
context.l10n.snackbarCannotOpenFile(
e.toString(),
context.friendlyError(e),
),
),
),
+6 -2
View File
@@ -687,7 +687,9 @@ class _NowPlayingScreenState extends ConsumerState<NowPlayingScreen> {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(context.l10n.snackbarCannotOpenFile(e.toString())),
content: Text(
context.l10n.snackbarCannotOpenFile(context.friendlyError(e)),
),
),
);
}
@@ -717,7 +719,9 @@ class _NowPlayingScreenState extends ConsumerState<NowPlayingScreen> {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
context.l10n.nowPlayingShuffleLibraryFailed(e.toString()),
context.l10n.nowPlayingShuffleLibraryFailed(
context.friendlyError(e),
),
),
),
);
+1 -1
View File
@@ -163,7 +163,7 @@ class _PlaylistScreenState extends ConsumerState<PlaylistScreen>
} catch (e) {
if (!mounted) return;
setState(() {
_error = e.toString();
_error = context.friendlyError(e);
_isLoading = false;
});
}
+3 -1
View File
@@ -1863,7 +1863,9 @@ class _QueueTabState extends ConsumerState<QueueTab> {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
context.l10n.snackbarFolderPickerFailed(e.toString()),
context.l10n.snackbarFolderPickerFailed(
context.friendlyError(e),
),
),
),
);
+4 -1
View File
@@ -332,7 +332,10 @@ extension _QueueTabItemWidgets on _QueueTabState {
if (raw == downloadFolderAccessLostErrorMessage) {
return context.l10n.downloadErrorFolderAccessLost;
}
return raw;
return context.friendlyError(
raw,
fallback: context.l10n.updateDownloadFailed,
);
}
Widget _buildDownloadFailureMessage(
+6 -2
View File
@@ -24,7 +24,9 @@ extension _QueueTabNavigation on _QueueTabState {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(context.l10n.snackbarCannotOpenFile(e.toString())),
content: Text(
context.l10n.snackbarCannotOpenFile(context.friendlyError(e)),
),
),
);
}
@@ -60,7 +62,9 @@ extension _QueueTabNavigation on _QueueTabState {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(context.l10n.snackbarCannotOpenFile(e.toString())),
content: Text(
context.l10n.snackbarCannotOpenFile(context.friendlyError(e)),
),
),
);
}
+2 -2
View File
@@ -397,7 +397,7 @@ class _RepoTabState extends ConsumerState<RepoTab> {
const SizedBox(width: 8),
Expanded(
child: Text(
error,
context.friendlyError(error),
style: Theme.of(context).textTheme.bodySmall
?.copyWith(color: colorScheme.onErrorContainer),
),
@@ -539,7 +539,7 @@ class _RepoTabState extends ConsumerState<RepoTab> {
),
const SizedBox(height: 8),
Text(
error,
context.friendlyError(error),
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: colorScheme.onSurfaceVariant,
),
+3 -1
View File
@@ -246,7 +246,9 @@ class AppSettingsPage extends ConsumerWidget {
if (context.mounted) {
Navigator.pop(context);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(context.l10n.snackbarError(e.toString()))),
SnackBar(
content: Text(context.l10n.snackbarError(context.friendlyError(e))),
),
);
}
}
@@ -55,9 +55,11 @@ class _CacheManagementPageState extends ConsumerState<CacheManagementPage> {
} catch (e) {
if (!mounted) return;
setState(() => _isLoading = false);
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text(context.l10n.snackbarError(e.toString()))));
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(context.l10n.snackbarError(context.friendlyError(e))),
),
);
}
}
@@ -293,9 +295,11 @@ class _CacheManagementPageState extends ConsumerState<CacheManagementPage> {
}
} catch (e) {
if (!mounted) return;
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text(context.l10n.snackbarError(e.toString()))));
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(context.l10n.snackbarError(context.friendlyError(e))),
),
);
} finally {
if (mounted) {
setState(() => _busyAction = null);
@@ -204,7 +204,10 @@ class _ExtensionDetailPageState extends ConsumerState<ExtensionDetailPage> {
if (hasError && extension.errorMessage != null)
_InfoRow(
label: context.l10n.extensionError,
value: extension.errorMessage!,
value: context.friendlyError(
extension.errorMessage,
fallback: context.l10n.extensionsErrorLoading,
),
isError: true,
),
],
@@ -805,7 +808,10 @@ class _HealthCheckItem extends StatelessWidget {
if (check.required) context.l10n.extensionHealthRequired,
];
final message = check.error?.trim().isNotEmpty == true
? check.error!
? context.friendlyError(
check.error,
fallback: context.l10n.extensionHealthServiceUnknown,
)
: check.message;
return Column(
@@ -1115,9 +1121,16 @@ class _SettingItemState extends State<_SettingItem> {
payload['error'] as String? ??
result['error'] as String? ??
context.l10n.extensionActionFailed;
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text(error)));
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
context.friendlyError(
error,
fallback: context.l10n.extensionActionFailed,
),
),
),
);
} else {
if (widget.onActionPayload != null) {
await widget.onActionPayload!(payload);
@@ -1150,7 +1163,9 @@ class _SettingItemState extends State<_SettingItem> {
} catch (e) {
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(context.l10n.snackbarError(e.toString()))),
SnackBar(
content: Text(context.l10n.snackbarError(context.friendlyError(e))),
),
);
}
} finally {
+9 -34
View File
@@ -23,17 +23,6 @@ class ExtensionsPage extends ConsumerStatefulWidget {
}
class _ExtensionsPageState extends ConsumerState<ExtensionsPage> {
static final RegExp _platformExceptionPattern = RegExp(
r'PlatformException\([^,]+,\s*([^,]+(?:,[^,]+)?),',
);
static final RegExp _platformExceptionSimplePattern = RegExp(
r'PlatformException\([^,]+,\s*(.+?),\s*null',
);
static final RegExp _trailingNullsPattern = RegExp(
r',\s*null\s*,\s*null\)?$',
);
static final RegExp _leadingCommaPattern = RegExp(r'^\s*,\s*');
@override
void initState() {
super.initState();
@@ -94,7 +83,7 @@ class _ExtensionsPageState extends ConsumerState<ExtensionsPage> {
const SizedBox(width: 12),
Expanded(
child: Text(
extState.error!,
context.friendlyError(extState.error),
style: TextStyle(
color: colorScheme.onErrorContainer,
),
@@ -308,26 +297,10 @@ class _ExtensionsPageState extends ConsumerState<ExtensionsPage> {
}
String _getFriendlyErrorMessage(String? error) {
if (error == null) return context.l10n.snackbarFailedToInstall;
String message = error;
if (message.contains('PlatformException')) {
final match = _platformExceptionPattern.firstMatch(message);
if (match != null) {
message = match.group(1)?.trim() ?? message;
} else {
final simpleMatch = _platformExceptionSimplePattern.firstMatch(message);
if (simpleMatch != null) {
message = simpleMatch.group(1)?.trim() ?? message;
}
}
}
message = message.replaceAll(_trailingNullsPattern, '');
message = message.replaceAll(_leadingCommaPattern, '');
return message;
return context.friendlyError(
error,
fallback: context.l10n.snackbarFailedToInstall,
);
}
}
@@ -414,8 +387,10 @@ class _ExtensionItem extends StatelessWidget {
const SizedBox(height: 2),
Text(
hasError
? extension.errorMessage ??
context.l10n.extensionsErrorLoading
? context.friendlyError(
extension.errorMessage,
fallback: context.l10n.extensionsErrorLoading,
)
: serviceHealthStatus == null
? 'v${extension.version}'
: 'v${extension.version} · ${_extensionHealthLabel(context, serviceHealthStatus)}',
@@ -639,7 +639,9 @@ class _FilesSettingsPageState extends ConsumerState<FilesSettingsPage> {
ScaffoldMessenger.of(ctx).showSnackBar(
SnackBar(
content: Text(
ctx.l10n.snackbarFolderPickerFailed(e.toString()),
ctx.l10n.snackbarFolderPickerFailed(
ctx.friendlyError(e),
),
),
backgroundColor: Theme.of(ctx).colorScheme.error,
duration: const Duration(seconds: 4),
@@ -822,8 +824,9 @@ class _FilesSettingsPageState extends ConsumerState<FilesSettingsPage> {
leading: Icon(option.$4),
title: Text(option.$2),
subtitle: Text(option.$3),
trailing:
current == option.$1 ? const Icon(Icons.check) : null,
trailing: current == option.$1
? const Icon(Icons.check)
: null,
onTap: () {
ref
.read(settingsProvider.notifier)
@@ -974,7 +977,6 @@ class _FolderOption extends StatelessWidget {
}
}
/// Bottom sheet for editing a filename format. Owns its controller and disposes
/// it in [dispose] to avoid use-after-dispose during the close animation.
class _FilenameFormatEditorSheet extends StatefulWidget {
@@ -1101,9 +1103,9 @@ class _FilenameFormatEditorSheetState
),
Text(
widget.title ?? context.l10n.filenameFormat,
style: Theme.of(
context,
).textTheme.headlineSmall?.copyWith(fontWeight: FontWeight.bold),
style: Theme.of(context).textTheme.headlineSmall?.copyWith(
fontWeight: FontWeight.bold,
),
textAlign: TextAlign.center,
),
const SizedBox(height: 8),
@@ -133,7 +133,9 @@ class _LibrarySettingsPageState extends ConsumerState<LibrarySettingsPage> {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
context.l10n.snackbarFolderPickerFailed(e.toString()),
context.l10n.snackbarFolderPickerFailed(
context.friendlyError(e),
),
),
),
);
+7 -3
View File
@@ -258,7 +258,7 @@ class _SetupScreenState extends ConsumerState<SetupScreen> {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
context.l10n.snackbarCannotOpenFile(e.toString()),
context.l10n.snackbarCannotOpenFile(context.friendlyError(e)),
),
),
);
@@ -371,7 +371,9 @@ class _SetupScreenState extends ConsumerState<SetupScreen> {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
context.l10n.snackbarFolderPickerFailed(e.toString()),
context.l10n.snackbarFolderPickerFailed(
context.friendlyError(e),
),
),
backgroundColor: Theme.of(context).colorScheme.error,
duration: const Duration(seconds: 4),
@@ -479,7 +481,9 @@ class _SetupScreenState extends ConsumerState<SetupScreen> {
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(context.l10n.snackbarError(e.toString()))),
SnackBar(
content: Text(context.l10n.snackbarError(context.friendlyError(e))),
),
);
}
} finally {
+3 -1
View File
@@ -211,7 +211,9 @@ extension _TrackMetadataFileActions on _TrackMetadataScreenState {
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(context.l10n.snackbarCannotOpenFile(e.toString())),
content: Text(
context.l10n.snackbarCannotOpenFile(context.friendlyError(e)),
),
),
);
}
+5 -1
View File
@@ -1124,7 +1124,11 @@ extension _TrackMetadataConvertAndCueSplit on _TrackMetadataScreenState {
if (mounted) {
_setState(() => _isConverting = false);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(context.l10n.trackSaveFailed(e.toString()))),
SnackBar(
content: Text(
context.l10n.trackSaveFailed(context.friendlyError(e)),
),
),
);
}
} finally {
+16 -5
View File
@@ -434,7 +434,7 @@ class _EditMetadataSheetState extends State<_EditMetadataSheet> {
});
} catch (e) {
if (!mounted) return;
_showSheetSnackBar(context.l10n.snackbarError(e.toString()));
_showSheetSnackBar(context.l10n.snackbarError(context.friendlyError(e)));
}
}
@@ -1191,7 +1191,9 @@ class _EditMetadataSheetState extends State<_EditMetadataSheet> {
});
} catch (e) {
if (mounted) {
_showSheetSnackBar(context.l10n.snackbarError(e.toString()));
_showSheetSnackBar(
context.l10n.snackbarError(context.friendlyError(e)),
);
}
} finally {
if (mounted) setState(() => _fetching = false);
@@ -1261,7 +1263,9 @@ class _EditMetadataSheetState extends State<_EditMetadataSheet> {
);
} catch (e) {
if (mounted) {
_showSheetSnackBar(context.l10n.snackbarError(e.toString()));
_showSheetSnackBar(
context.l10n.snackbarError(context.friendlyError(e)),
);
}
} finally {
if (mounted) setState(() => _fetching = false);
@@ -1346,7 +1350,12 @@ class _EditMetadataSheetState extends State<_EditMetadataSheet> {
if (result['error'] != null) {
if (mounted) {
_showSheetSnackBar('${result['error']}');
_showSheetSnackBar(
context.friendlyError(
result['error'],
fallback: context.l10n.metadataSaveFailedFfmpeg,
),
);
}
setState(() => _saving = false);
return;
@@ -1509,7 +1518,9 @@ class _EditMetadataSheetState extends State<_EditMetadataSheet> {
}
} catch (e) {
if (mounted) {
_showSheetSnackBar(context.l10n.snackbarError(e.toString()));
_showSheetSnackBar(
context.l10n.snackbarError(context.friendlyError(e)),
);
}
} finally {
if (mounted) setState(() => _saving = false);
+38 -10
View File
@@ -486,7 +486,10 @@ extension _TrackMetadataLyricsAndSaving on _TrackMetadataScreenState {
success = true;
}
} else {
error = result['error']?.toString() ?? l10nFailedToEmbedLyrics;
error = userFacingErrorMessage(
result['error'],
fallback: l10nFailedToEmbedLyrics,
);
}
} else if (isMp3 || isOpus || isM4A) {
final metadata = _buildFallbackMetadata();
@@ -579,7 +582,9 @@ extension _TrackMetadataLyricsAndSaving on _TrackMetadataScreenState {
if (mounted) {
_setState(() => _isEmbedding = false);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(context.l10n.snackbarError(e.toString()))),
SnackBar(
content: Text(context.l10n.snackbarError(context.friendlyError(e))),
),
);
}
} finally {
@@ -677,7 +682,9 @@ extension _TrackMetadataLyricsAndSaving on _TrackMetadataScreenState {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
context.l10n.trackSaveFailed(result['error'].toString()),
context.l10n.trackSaveFailed(
context.friendlyError(result['error']),
),
),
),
);
@@ -771,7 +778,9 @@ extension _TrackMetadataLyricsAndSaving on _TrackMetadataScreenState {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
context.l10n.trackSaveFailed(result['error'].toString()),
context.l10n.trackSaveFailed(
context.friendlyError(result['error']),
),
),
),
);
@@ -784,7 +793,11 @@ extension _TrackMetadataLyricsAndSaving on _TrackMetadataScreenState {
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(context.l10n.trackSaveFailed(e.toString()))),
SnackBar(
content: Text(
context.l10n.trackSaveFailed(context.friendlyError(e)),
),
),
);
}
}
@@ -823,7 +836,9 @@ extension _TrackMetadataLyricsAndSaving on _TrackMetadataScreenState {
..showSnackBar(
SnackBar(
content: Text(
context.l10n.trackSaveFailed(result['error'].toString()),
context.l10n.trackSaveFailed(
context.friendlyError(result['error']),
),
),
),
);
@@ -908,7 +923,9 @@ extension _TrackMetadataLyricsAndSaving on _TrackMetadataScreenState {
..showSnackBar(
SnackBar(
content: Text(
context.l10n.trackSaveFailed(result['error'].toString()),
context.l10n.trackSaveFailed(
context.friendlyError(result['error']),
),
),
),
);
@@ -925,7 +942,11 @@ extension _TrackMetadataLyricsAndSaving on _TrackMetadataScreenState {
ScaffoldMessenger.of(context)
..hideCurrentSnackBar()
..showSnackBar(
SnackBar(content: Text(context.l10n.trackSaveFailed(e.toString()))),
SnackBar(
content: Text(
context.l10n.trackSaveFailed(context.friendlyError(e)),
),
),
);
}
}
@@ -1131,7 +1152,10 @@ extension _TrackMetadataLyricsAndSaving on _TrackMetadataScreenState {
}
} else {
if (mounted) {
final error = result['error']?.toString() ?? 'Unknown error';
final error = context.friendlyError(
result['error'],
fallback: context.l10n.metadataSaveFailedFfmpeg,
);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(context.l10n.trackSaveFailed(error))),
);
@@ -1140,7 +1164,11 @@ extension _TrackMetadataLyricsAndSaving on _TrackMetadataScreenState {
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(context.l10n.trackSaveFailed(e.toString()))),
SnackBar(
content: Text(
context.l10n.trackSaveFailed(context.friendlyError(e)),
),
),
);
}
}
+1
View File
@@ -31,6 +31,7 @@ import 'package:spotiflac_android/utils/mime_utils.dart';
import 'package:spotiflac_android/utils/image_cache_utils.dart';
import 'package:spotiflac_android/utils/string_utils.dart';
import 'package:spotiflac_android/utils/user_facing_error.dart';
import 'package:spotiflac_android/utils/int_utils.dart';
import 'package:spotiflac_android/utils/nav_bar_inset.dart';
import 'package:spotiflac_android/utils/re_enrich_release_policy.dart';
+5 -1
View File
@@ -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)}',
),
),
);
}
}
+7 -4
View File
@@ -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;
+188
View File
@@ -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';
}
+1 -1
View File
@@ -584,7 +584,7 @@ class _AudioAnalysisCardState extends State<AudioAnalysisCard> {
} catch (e) {
if (mounted) {
setState(() {
_error = e.toString();
_error = context.friendlyError(e);
_analyzing = false;
});
}
+5 -1
View File
@@ -11,6 +11,7 @@ class ErrorCard extends StatelessWidget {
@override
Widget build(BuildContext context) {
final displayError = context.friendlyError(error);
final isRateLimit =
error.contains('429') ||
error.toLowerCase().contains('rate limit') ||
@@ -66,7 +67,10 @@ class ErrorCard extends StatelessWidget {
Icon(Icons.error_outline, color: colorScheme.error),
const SizedBox(width: 12),
Expanded(
child: Text(error, style: TextStyle(color: colorScheme.error)),
child: Text(
displayError,
style: TextStyle(color: colorScheme.error),
),
),
],
),
+79
View File
@@ -0,0 +1,79 @@
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:spotiflac_android/utils/user_facing_error.dart';
void main() {
group('userFacingErrorMessage', () {
test('hides an empty generic PlatformException', () {
expect(
userFacingErrorMessage(
PlatformException(code: 'ERROR'),
fallback: 'Could not save metadata',
),
'Could not save metadata',
);
expect(
userFacingErrorMessage(
'PlatformException(ERROR, null, null, null)',
fallback: 'Could not save metadata',
),
'Could not save metadata',
);
});
test('keeps a meaningful PlatformException message', () {
expect(
userFacingErrorMessage(
PlatformException(
code: 'write_failed',
message: 'The selected file is not writable',
),
),
'The selected file is not writable',
);
expect(
userFacingErrorMessage(
'PlatformException(ERROR, The selected file is not writable, null, null)',
),
'The selected file is not writable',
);
});
test('turns a meaningful platform code into readable text', () {
expect(
userFacingErrorMessage(PlatformException(code: 'permission_denied')),
'Permission denied',
);
});
test('removes exception wrappers, paths, and stack traces', () {
expect(
userFacingErrorMessage(
"Exception: FileSystemException: Cannot open file, path = '/secret/song.flac'\n#0 internalCall",
),
'Cannot open file',
);
});
test('maps connectivity and internal implementation errors', () {
expect(
userFacingErrorMessage('SocketException: Failed host lookup: api.test'),
'Unable to connect. Check your internet connection.',
);
expect(
userFacingErrorMessage(
'MissingPluginException(No implementation found for method save)',
fallback: 'Could not save metadata',
),
'Could not save metadata',
);
});
test('preserves an ordinary backend error', () {
expect(
userFacingErrorMessage('Song not found on any service'),
'Song not found on any service',
);
});
});
}