fix: resolve crashes in TTS preview, model picker lifecycle, and API error parsing (#60, #61, #62, #63)
- TTS (#62): Assign non-null MediaItem tag to preview AudioSource chunks to prevent just_audio_background type cast crash. - Models (#61): Add mounted guards across async gaps in ModelList and ModelPickerSheet to avoid unmounted ref/context access when dismissed mid-load. - Servers (#60, #63): Enhance _extractApiErrorMessage to parse top-level error maps (e.g. OpenRouter 401), string errors, and nested code maps into clean human-readable messages. - Model Info (#60, #63): Wrap model metadata lookup in try-catch to safely fallback to basic model identifier when server requests fail. - Tests: Add regression test suite in test/features/issue_crash_fixes_test.dart. Closes #60, Closes #61, Closes #62, Closes #63
abdulmominsakib committed
Aug 16, 2026 at 12:20 UTC
5c5869ee02802f5e81c6cd56a22879f71d7b02d5
6 files changed
+432
-23
lib/features/chat/views/components/model_info_sheet.dart
+9
-5
@@ -18,12 +18,16 @@ Future<void> showModelInfoSheet(
18
final server = ref.read(activeServerProvider);
19
ModelInfo? model;
20
if (server != null) {
21
- final models = await ref.read(availableModelsProvider(server.id).future);
22
- for (final m in models) {
23
- if (m is ModelInfo && m.id == modelId) {
24
- model = m;
25
- break;
21
+ try {
22
+ final models = await ref.read(availableModelsProvider(server.id).future);
23
+ for (final m in models) {
24
+ if (m is ModelInfo && m.id == modelId) {
25
+ model = m;
26
+ break;
27
+ }
28
}
29
+ } catch (_) {
30
+ // Model info fetch failed, fallback to basic identifier display
31
}
32
}
33
lib/features/models/components/model_list.dart
+9
-1
@@ -410,22 +410,27 @@ class _ModelListState extends ConsumerState<ModelList> {
410
final instances = await ref.read(
411
loadedModelsProvider(activeServer).future,
412
);
413
+ if (!mounted) return;
414
await apiService.unloadAllInstances(
415
activeServer,
416
instances,
417
);
418
}
419
420
+ if (!mounted) return;
421
await apiService.loadModelWithInstanceId(
422
activeServer,
423
model.id,
424
contextLength: contextLength,
425
);
426
427
+ if (!mounted) return;
428
ref.invalidate(loadedModelsProvider(activeServer));
429
ref.read(modelLoadingProvider.notifier).setLoaded();
430
} catch (e) {
428
- ref.read(modelLoadingProvider.notifier).setLoaded();
431
+ if (mounted) {
432
+ ref.read(modelLoadingProvider.notifier).setLoaded();
433
+ }
434
if (context.mounted) {
435
ScaffoldMessenger.of(context).showSnackBar(
436
SnackBar(
@@ -438,6 +443,7 @@ class _ModelListState extends ConsumerState<ModelList> {
443
}
444
}
445
446
+ if (!mounted) return;
447
ref.read(selectedModelProvider.notifier).setModel(model);
448
if (widget.closeOnSelect && context.mounted) {
449
Navigator.pop(context);
@@ -452,11 +458,13 @@ class _ModelListState extends ConsumerState<ModelList> {
458
final instances = await ref.read(
459
loadedModelsProvider(activeServer).future,
460
);
461
+ if (!mounted) return;
462
await apiService.unloadInstancesForModelKey(
463
activeServer,
464
model.id,
465
instances,
466
);
467
+ if (!mounted) return;
468
ref.invalidate(loadedModelsProvider(activeServer));
469
470
final selectedModel = ref.read(selectedModelProvider);
lib/features/models/views/model_picker_sheet.dart
+2
-3
@@ -197,10 +197,9 @@ class _ModelPickerSheetState extends ConsumerState<ModelPickerSheet> {
197
await apiService.unloadAllInstances(activeServer, loadedInstances);
198
}
199
200
- ref.invalidate(loadedModelsProvider(activeServer));
201
- ref.read(selectedModelProvider.notifier).clear();
202
-
200
if (context.mounted) {
201
+ ref.invalidate(loadedModelsProvider(activeServer));
202
+ ref.read(selectedModelProvider.notifier).clear();
203
ScaffoldMessenger.of(
204
context,
205
).showSnackBar(SnackBar(content: Text(l10n.all_models_unloaded)));
lib/features/servers/data/server_api_service.dart
+48
-4
@@ -1,3 +1,4 @@
1
+import 'dart:convert';
2
import 'package:dio/dio.dart';
3
4
import '../../../core/logger/app_logger.dart';
@@ -103,6 +104,12 @@ class ServerApiService {
104
}
105
return models;
106
} catch (e) {
107
+ if (e is DioException) {
108
+ final apiMsg = _extractApiErrorMessage(e.response?.data);
109
+ if (apiMsg != null) {
110
+ throw Exception('Failed to fetch models: $apiMsg');
111
+ }
112
+ }
113
throw Exception('Failed to fetch models: $e');
114
}
115
}
@@ -698,14 +705,51 @@ class ServerApiService {
705
}
706
707
String? _extractApiErrorMessage(dynamic data) {
701
- if (data is! Map) return null;
702
- final error = data['error'];
708
+ if (data == null) return null;
709
+ dynamic map = data;
710
+ if (data is String) {
711
+ final trimmed = data.trim();
712
+ if (trimmed.startsWith('{')) {
713
+ try {
714
+ map = jsonDecode(trimmed);
715
+ } catch (_) {
716
+ return trimmed.isNotEmpty ? trimmed : null;
717
+ }
718
+ } else {
719
+ return trimmed.isNotEmpty ? trimmed : null;
720
+ }
721
+ }
722
+ if (map is! Map) return null;
723
+
724
+ final error = map['error'];
725
if (error is Map) {
726
final type = error['type']?.toString();
727
final message = error['message']?.toString();
706
- if (type != null && message != null) return '$type: $message';
707
- return message ?? type;
728
+ final code = error['code']?.toString();
729
+ if (message != null && message.trim().isNotEmpty) {
730
+ if (type != null && type.trim().isNotEmpty) return '$type: $message';
731
+ if (code != null && code.trim().isNotEmpty) return 'Error $code: $message';
732
+ return message;
733
+ }
734
+ return type ?? code;
735
+ } else if (error is String && error.trim().isNotEmpty) {
736
+ return error;
737
+ }
738
+
739
+ final message = map['message']?.toString();
740
+ final type = map['type']?.toString();
741
+ final detail = map['detail']?.toString();
742
+ if (message != null && message.trim().isNotEmpty) {
743
+ if (type != null && type.trim().isNotEmpty) return '$type: $message';
744
+ return message;
745
}
746
+ if (detail != null && detail.trim().isNotEmpty) {
747
+ return detail;
748
+ }
749
+ if (type != null && type.trim().isNotEmpty) {
750
+ return type;
751
+ }
752
+
753
return null;
754
}
755
}
lib/features/tts/providers/tts_providers.dart
+8
-10
@@ -877,16 +877,14 @@ class TtsNotifier extends Notifier<TtsState> {
877
878
final source = AudioSource.file(
879
tempFile.path,
880
- tag: _isPreview
881
- ? null
882
- : MediaItem(
883
- id: 'tts_chunk_${_currentSessionId}_$chunkIndex',
884
- album: 'LocalMind TTS',
885
- title: _chunks[chunkIndex],
886
- artist: _currentEngine == EngineId.kitten
887
- ? 'Kitten TTS'
888
- : 'Piper TTS',
889
- ),
880
+ tag: MediaItem(
881
+ id: 'tts_chunk_${_currentSessionId}_$chunkIndex',
882
+ album: _isPreview ? 'Voice Preview' : 'LocalMind TTS',
883
+ title: _chunks[chunkIndex],
884
+ artist: _currentEngine == EngineId.kitten
885
+ ? 'Kitten TTS'
886
+ : 'Piper TTS',
887
+ ),
888
);
889
890
_playlistBuffer[chunkIndex] = source;
test/features/issue_crash_fixes_test.dart
new
+356
@@ -0,0 +1,356 @@
1
+import 'dart:async';
2
+import 'dart:io';
3
+import 'dart:typed_data';
4
+
5
+import 'package:dio/dio.dart';
6
+import 'package:flutter/material.dart';
7
+import 'package:flutter_riverpod/flutter_riverpod.dart';
8
+import 'package:flutter_test/flutter_test.dart';
9
+import 'package:just_audio/just_audio.dart';
10
+import 'package:just_audio_background/just_audio_background.dart';
11
+import 'package:localmind/core/models/enums.dart';
12
+import 'package:localmind/core/providers/app_providers.dart';
13
+import 'package:localmind/core/providers/service_providers.dart';
14
+import 'package:localmind/core/providers/storage_providers.dart';
15
+import 'package:localmind/core/theme/app_theme.dart';
16
+import 'package:localmind/features/chat/data/models/chat_parameters.dart';
17
+import 'package:localmind/features/chat/providers/chat_params_providers.dart';
18
+import 'package:localmind/features/chat/views/components/model_info_sheet.dart';
19
+import 'package:localmind/features/models/components/model_list.dart';
20
+import 'package:localmind/features/models/data/models/model_info.dart';
21
+import 'package:localmind/features/servers/data/models/server.dart';
22
+import 'package:localmind/features/servers/data/server_api_service.dart';
23
+import 'package:localmind/features/servers/providers/server_providers.dart';
24
+import 'package:localmind/features/settings/data/models/app_settings.dart';
25
+import 'package:localmind/l10n/app_localizations.dart';
26
+import 'package:shadcn_ui/shadcn_ui.dart';
27
+import 'package:shared_preferences/shared_preferences.dart';
28
+
29
+void main() {
30
+ TestWidgetsFlutterBinding.ensureInitialized();
31
+
32
+ final now = DateTime.now();
33
+
34
+ group('Issue #62: TTS AudioSource MediaItem Tag', () {
35
+ test('AudioSource tag is a valid MediaItem even during preview', () {
36
+ final tempDir = Directory.systemTemp;
37
+ final tempFile = File('${tempDir.path}/tts_test_preview.wav');
38
+ tempFile.writeAsBytesSync(Uint8List(100));
39
+ addTearDown(() {
40
+ if (tempFile.existsSync()) tempFile.deleteSync();
41
+ });
42
+
43
+ final isPreview = true;
44
+ final source = AudioSource.file(
45
+ tempFile.path,
46
+ tag: MediaItem(
47
+ id: 'tts_chunk_test_0',
48
+ album: isPreview ? 'Voice Preview' : 'LocalMind TTS',
49
+ title: 'Hello world',
50
+ artist: 'Piper TTS',
51
+ ),
52
+ );
53
+
54
+ // Verify tag is non-null and is a MediaItem (avoiding "type 'Null' is not a subtype of type 'MediaItem'")
55
+ expect(source.tag, isNotNull);
56
+ expect(source.tag, isA<MediaItem>());
57
+ final mediaItem = source.tag as MediaItem;
58
+ expect(mediaItem.album, 'Voice Preview');
59
+ expect(mediaItem.title, 'Hello world');
60
+ });
61
+ });
62
+
63
+ group('Issue #60 & #63: Server API Error Handling', () {
64
+ test('fetchModels throws clean formatted error on 401 unauthorized with top-level message', () async {
65
+ final dio = Dio();
66
+ dio.httpClientAdapter = _MockHttpClientAdapter((options) {
67
+ return ResponseBody.fromString(
68
+ '{"message":"Invalid API key or unauthorized.","type":"authentication_error"}',
69
+ 401,
70
+ headers: {
71
+ Headers.contentTypeHeader: [Headers.jsonContentType],
72
+ },
73
+ );
74
+ });
75
+
76
+ final apiService = ServerApiService(dio);
77
+ final server = Server(
78
+ id: 'test-server',
79
+ name: 'OpenRouter',
80
+ host: 'openrouter.ai',
81
+ port: 443,
82
+ type: ServerType.openRouter,
83
+ apiKey: 'invalid-key',
84
+ createdAt: now,
85
+ lastConnectedAt: now,
86
+ );
87
+
88
+ expect(
89
+ () => apiService.fetchModels(server),
90
+ throwsA(
91
+ predicate((e) =>
92
+ e is Exception &&
93
+ e.toString().contains('Failed to fetch models: authentication_error: Invalid API key or unauthorized.')),
94
+ ),
95
+ );
96
+ });
97
+
98
+ test('fetchModels throws clean formatted error on 500 error payload', () async {
99
+ final dio = Dio();
100
+ dio.httpClientAdapter = _MockHttpClientAdapter((options) {
101
+ return ResponseBody.fromString(
102
+ '{"error":{"code":500,"message":"Internal engine crash"}}',
103
+ 500,
104
+ headers: {
105
+ Headers.contentTypeHeader: [Headers.jsonContentType],
106
+ },
107
+ );
108
+ });
109
+
110
+ final apiService = ServerApiService(dio);
111
+ final server = Server(
112
+ id: 'test-server-500',
113
+ name: 'Local Server',
114
+ host: 'localhost',
115
+ port: 1234,
116
+ type: ServerType.openAICompatible,
117
+ createdAt: now,
118
+ lastConnectedAt: now,
119
+ );
120
+
121
+ expect(
122
+ () => apiService.fetchModels(server),
123
+ throwsA(
124
+ predicate((e) =>
125
+ e is Exception &&
126
+ e.toString().contains('Failed to fetch models: Error 500: Internal engine crash')),
127
+ ),
128
+ );
129
+ });
130
+
131
+ test('loadModelWithInstanceId throws clean formatted error on top-level API error map', () async {
132
+ final dio = Dio();
133
+ dio.httpClientAdapter = _MockHttpClientAdapter((options) {
134
+ return ResponseBody.fromString(
135
+ '{"message":"Model not found on disk","type":"not_found"}',
136
+ 404,
137
+ headers: {
138
+ Headers.contentTypeHeader: [Headers.jsonContentType],
139
+ },
140
+ );
141
+ });
142
+
143
+ final apiService = ServerApiService(dio);
144
+ final server = Server(
145
+ id: 'test-server',
146
+ name: 'LM Studio',
147
+ host: 'localhost',
148
+ port: 1234,
149
+ type: ServerType.lmStudio,
150
+ createdAt: now,
151
+ lastConnectedAt: now,
152
+ );
153
+
154
+ expect(
155
+ () => apiService.loadModelWithInstanceId(server, 'missing-model'),
156
+ throwsA(
157
+ predicate((e) =>
158
+ e is Exception &&
159
+ e.toString().contains('not_found: Model not found on disk')),
160
+ ),
161
+ );
162
+ });
163
+
164
+ testWidgets('showModelInfoSheet gracefully handles availableModelsProvider error', (tester) async {
165
+ SharedPreferences.setMockInitialValues({});
166
+ final prefs = await SharedPreferences.getInstance();
167
+
168
+ final server = Server(
169
+ id: 'error-server',
170
+ name: 'Error Server',
171
+ host: 'localhost',
172
+ port: 1234,
173
+ type: ServerType.openAICompatible,
174
+ createdAt: now,
175
+ lastConnectedAt: now,
176
+ );
177
+
178
+ final container = ProviderContainer(
179
+ overrides: [
180
+ sharedPreferencesProvider.overrideWithValue(prefs),
181
+ chatParamsProvider.overrideWithValue(ChatParameters.defaults()),
182
+ activeServerProvider.overrideWith(() => _StubActiveServerNotifier(server)),
183
+ serversProvider.overrideWith(() => _StubServersNotifier(server)),
184
+ availableModelsProvider(server.id).overrideWith(
185
+ (ref) async => throw Exception('Server connection failed'),
186
+ ),
187
+ ],
188
+ );
189
+ addTearDown(container.dispose);
190
+
191
+ late BuildContext capturedContext;
192
+ late WidgetRef capturedRef;
193
+
194
+ await tester.pumpWidget(
195
+ UncontrolledProviderScope(
196
+ container: container,
197
+ child: MaterialApp(
198
+ localizationsDelegates: AppLocalizations.localizationsDelegates,
199
+ supportedLocales: AppLocalizations.supportedLocales,
200
+ home: ShadTheme(
201
+ data: AppTheme.lightShadTheme,
202
+ child: Scaffold(
203
+ body: Consumer(
204
+ builder: (context, ref, _) {
205
+ capturedContext = context;
206
+ capturedRef = ref;
207
+ return const SizedBox();
208
+ },
209
+ ),
210
+ ),
211
+ ),
212
+ ),
213
+ ),
214
+ );
215
+ await tester.pumpAndSettle();
216
+
217
+ // Trigger showModelInfoSheet and ensure no uncaught exception is thrown
218
+ showModelInfoSheet(capturedContext, capturedRef, 'test-model-id');
219
+ await tester.pump();
220
+ await tester.pump(const Duration(milliseconds: 300));
221
+
222
+ expect(tester.takeException(), isNull);
223
+ });
224
+ });
225
+
226
+ group('Issue #61: ModelList Unmounted Ref Access Guard', () {
227
+ testWidgets('does not throw when unmounted while model is loading', (tester) async {
228
+ SharedPreferences.setMockInitialValues({});
229
+ final prefs = await SharedPreferences.getInstance();
230
+
231
+ final server = Server(
232
+ id: 'test-server-unload',
233
+ name: 'Test Server',
234
+ host: 'localhost',
235
+ port: 1234,
236
+ type: ServerType.lmStudio,
237
+ createdAt: now,
238
+ lastConnectedAt: now,
239
+ );
240
+
241
+ final testModel = ModelInfo(
242
+ id: 'qwen-model',
243
+ name: 'Qwen Model',
244
+ serverId: server.id,
245
+ serverType: ServerType.lmStudio,
246
+ );
247
+
248
+ final mockDio = Dio();
249
+ mockDio.httpClientAdapter = _MockHttpClientAdapter((options) {
250
+ return ResponseBody.fromString('{"status":"ok"}', 200);
251
+ });
252
+ final mockApiService = ServerApiService(mockDio);
253
+
254
+ final container = ProviderContainer(
255
+ overrides: [
256
+ sharedPreferencesProvider.overrideWithValue(prefs),
257
+ settingsProvider.overrideWith(_TestSettingsNotifier.new),
258
+ chatParamsProvider.overrideWithValue(ChatParameters.defaults()),
259
+ serverApiServiceProvider.overrideWithValue(mockApiService),
260
+ activeServerProvider.overrideWith(() => _StubActiveServerNotifier(server)),
261
+ serversProvider.overrideWith(() => _StubServersNotifier(server)),
262
+ connectionStatusProvider.overrideWith(_StubConnectedStatusNotifier.new),
263
+ availableModelsProvider(server.id).overrideWith((ref) => Future.value([testModel])),
264
+ loadedModelsProvider(server).overrideWith((ref) => Future.value(<String>{})),
265
+ ],
266
+ );
267
+ addTearDown(container.dispose);
268
+
269
+ await tester.pumpWidget(
270
+ UncontrolledProviderScope(
271
+ container: container,
272
+ child: MaterialApp(
273
+ localizationsDelegates: AppLocalizations.localizationsDelegates,
274
+ supportedLocales: AppLocalizations.supportedLocales,
275
+ home: Scaffold(
276
+ body: ModelList(
277
+ serverId: server.id,
278
+ selectedModelId: null,
279
+ isDark: false,
280
+ ),
281
+ ),
282
+ ),
283
+ ),
284
+ );
285
+ await tester.pumpAndSettle();
286
+
287
+ expect(find.text('Qwen Model'), findsOneWidget);
288
+
289
+ // Tapping tile triggers onTap async load
290
+ await tester.tap(find.text('Qwen Model'));
291
+
292
+ // Immediately replace widget with empty container to unmount ModelList
293
+ await tester.pumpWidget(
294
+ UncontrolledProviderScope(
295
+ container: container,
296
+ child: const MaterialApp(
297
+ home: Scaffold(body: SizedBox()),
298
+ ),
299
+ ),
300
+ );
301
+ await tester.pumpAndSettle();
302
+
303
+ // No unhandled exceptions thrown
304
+ expect(tester.takeException(), isNull);
305
+ });
306
+ });
307
+}
308
+
309
+class _StubActiveServerNotifier extends ActiveServerNotifier {
310
+ _StubActiveServerNotifier(this.server);
311
+
312
+ final Server server;
313
+
314
+ @override
315
+ Server? build() => server;
316
+}
317
+
318
+class _StubServersNotifier extends ServersNotifier {
319
+ _StubServersNotifier(this.server);
320
+
321
+ final Server server;
322
+
323
+ @override
324
+ Future<List<Server>> build() async => [server];
325
+}
326
+
327
+class _StubConnectedStatusNotifier extends ConnectionStatusNotifier {
328
+ @override
329
+ ConnectionStatus build() => ConnectionStatus.connected;
330
+}
331
+
332
+class _MockHttpClientAdapter implements HttpClientAdapter {
333
+ final ResponseBody Function(RequestOptions options) handler;
334
+
335
+ _MockHttpClientAdapter(this.handler);
336
+
337
+ @override
338
+ Future<ResponseBody> fetch(
339
+ RequestOptions options,
340
+ Stream<Uint8List>? requestStream,
341
+ Future<void>? cancelFuture,
342
+ ) async {
343
+ return handler(options);
344
+ }
345
+
346
+ @override
347
+ void close({bool force = false}) {}
348
+}
349
+
350
+class _TestSettingsNotifier extends SettingsNotifier {
351
+ @override
352
+ AppSettings build() => AppSettings(
353
+ unloadModelsBeforeLoad: false,
354
+ );
355
+}
356
+