mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-08-02 09:08:35 +02:00
fix(ui): sanitize user-facing errors
This commit is contained in:
@@ -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);
|
||||
}
|
||||
|
||||
@@ -183,7 +183,7 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen>
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_error = e.toString();
|
||||
_error = context.friendlyError(e);
|
||||
_isLoading = false;
|
||||
});
|
||||
}
|
||||
|
||||
@@ -366,7 +366,7 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen>
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_error = e.toString();
|
||||
_error = context.friendlyError(e);
|
||||
_isLoadingDiscography = false;
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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)),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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)),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -221,7 +221,7 @@ class _MainShellState extends ConsumerState<MainShell>
|
||||
SnackBar(
|
||||
content: Text(
|
||||
context.l10n.snackbarCannotOpenFile(
|
||||
e.toString(),
|
||||
context.friendlyError(e),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -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),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -163,7 +163,7 @@ class _PlaylistScreenState extends ConsumerState<PlaylistScreen>
|
||||
} catch (e) {
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_error = e.toString();
|
||||
_error = context.friendlyError(e);
|
||||
_isLoading = false;
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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)),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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)),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
@@ -584,7 +584,7 @@ class _AudioAnalysisCardState extends State<AudioAnalysisCard> {
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_error = e.toString();
|
||||
_error = context.friendlyError(e);
|
||||
_analyzing = false;
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
@@ -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',
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user