@tduncan / Cortana / commits / 5c5869e

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 +