15 files changed
+619
-45
lib/bootstrap/bootstrap_host.dart
+3
-1
@@ -92,7 +92,9 @@ class _BootstrapHostState extends State<BootstrapHost> {
92
93
_updateStage(BootstrapStage.configuringServer, 'Configuring server...');
94
final servers = await container.read(serversProvider.future);
95
- final hasOnDevice = servers.any((s) => s.type == ServerType.onDevice);
95
+ final hasOnDevice = servers.any(
96
+ (s) => s.type == ServerType.onDevice || s.id == 'on-device',
97
+ );
98
if (!hasOnDevice) {
99
final server = Server(
100
id: 'on-device',
lib/core/storage/entities.dart
+62
-3
@@ -75,13 +75,72 @@ class ServerEntity {
75
);
76
}
77
78
+ static ServerType resolveServerType({
79
+ required int typeIndex,
80
+ required String id,
81
+ required String name,
82
+ required String host,
83
+ required int port,
84
+ String? apiKey,
85
+ }) {
86
+ final trimmedHost = host.trim();
87
+ final lowerId = id.trim().toLowerCase();
88
+ final lowerName = name.trim().toLowerCase();
89
+
90
+ // 1. On-Device server detection:
91
+ // On-device servers always have id == 'on-device' or name == 'On-Device',
92
+ // or host is empty with port 0 without API key.
93
+ // Prior to Ollama Cloud being added (commit 51996bb), onDevice had typeIndex 4.
94
+ // In the updated enum, index 4 is openRouter and index 5 is onDevice.
95
+ if (lowerId == 'on-device' ||
96
+ (lowerName == 'on-device' && trimmedHost.isEmpty && port <= 0) ||
97
+ (trimmedHost.isEmpty &&
98
+ port <= 0 &&
99
+ (apiKey == null || apiKey.trim().isEmpty))) {
100
+ return ServerType.onDevice;
101
+ }
102
+
103
+ // 2. OpenRouter server detection / drift resolution:
104
+ // Prior to Ollama Cloud being added, openRouter had typeIndex 3.
105
+ // In the updated enum, index 3 is ollamaCloud and index 4 is openRouter.
106
+ final isProbablyOpenRouter =
107
+ trimmedHost.contains('openrouter.ai') ||
108
+ lowerName.contains('openrouter') ||
109
+ (apiKey != null && apiKey.trim().startsWith('sk-or'));
110
+ if (isProbablyOpenRouter) {
111
+ return ServerType.openRouter;
112
+ }
113
+
114
+ // 3. Ollama Cloud detection:
115
+ final isProbablyOllamaCloud =
116
+ trimmedHost.contains('ollama.com') ||
117
+ lowerName.contains('ollama cloud');
118
+ if (isProbablyOllamaCloud) {
119
+ return ServerType.ollamaCloud;
120
+ }
121
+
122
+ // 4. Default bounds-checked enum resolution:
123
+ if (typeIndex >= 0 && typeIndex < ServerType.values.length) {
124
+ return ServerType.values[typeIndex];
125
+ }
126
+
127
+ return ServerType.values.first;
128
+ }
129
+
130
Server toDomain() {
131
+ final resolvedType = resolveServerType(
132
+ typeIndex: typeIndex,
133
+ id: id,
134
+ name: name,
135
+ host: host,
136
+ port: port,
137
+ apiKey: apiKey,
138
+ );
139
+
140
return Server(
141
id: id,
142
name: name,
82
- type: typeIndex >= 0 && typeIndex < ServerType.values.length
83
- ? ServerType.values[typeIndex]
84
- : ServerType.values.first,
143
+ type: resolvedType,
144
host: host,
145
port: port,
146
apiKey: apiKey,
lib/core/storage/objectbox_store.dart
+2
-2
@@ -17,7 +17,7 @@ class ObjectBoxStore {
17
late final Box<SavedMessageEntity> savedMessageBox;
18
late final Box<SavedMessageFolderEntity> savedMessageFolderBox;
19
20
- ObjectBoxStore._create(this.store) {
20
+ ObjectBoxStore.fromStore(this.store) {
21
serverBox = Box<ServerEntity>(store);
22
personaBox = Box<PersonaEntity>(store);
23
conversationBox = Box<ConversationEntity>(store);
@@ -37,6 +37,6 @@ class ObjectBoxStore {
37
}
38
39
final store = await openStore(directory: storeDir.path);
40
- return ObjectBoxStore._create(store);
40
+ return ObjectBoxStore.fromStore(store);
41
}
42
}
lib/features/chat/providers/chat_service_providers.dart
+2
-2
@@ -60,7 +60,7 @@ ChatService createChatServiceForServer({
60
bool imageCompressionEnabled = true,
61
ImageCompressionLevel imageCompressionLevel = ImageCompressionLevel.medium,
62
}) {
63
- if (server.type == ServerType.onDevice) {
63
+ if (server.isOnDevice) {
64
if (loadedOnDeviceRuntime == OnDeviceModelRuntime.llamaCpp) {
65
return OnDeviceLlamaChatService(onDeviceLlamaService);
66
}
@@ -70,7 +70,7 @@ ChatService createChatServiceForServer({
70
}
71
72
return ChatService.forServer(
73
- server.type,
73
+ ServerType.onDevice,
74
dio,
75
onDeviceGemma: onDeviceGemmaService,
76
imageCompressionEnabled: imageCompressionEnabled,
lib/features/chat/providers/model_selection_providers.dart
+9
-7
@@ -52,7 +52,7 @@ final activeChatTargetProvider = Provider<ActiveChatTarget>((ref) {
52
53
final selected = ref.watch(selectedModelProvider);
54
final selectedForServer = selected?.serverId == server.id ? selected : null;
55
- if (server.type == ServerType.onDevice) {
55
+ if (server.isOnDevice) {
56
final engine = ref.watch(onDeviceEngineProvider);
57
if (selectedForServer != null &&
58
engine.status == OnDeviceEngineStatus.loaded &&
@@ -200,6 +200,7 @@ final autoSelectFirstLoadedModelProvider = FutureProvider<void>((ref) async {
200
// default there's nothing to auto-select for them (avoids an extra model
201
// list fetch for users who never set one).
202
if (!hasDefaultForServer &&
203
+ !activeServer.isOnDevice &&
204
(activeServer.type == ServerType.openRouter ||
205
activeServer.type == ServerType.requesty ||
206
activeServer.type == ServerType.openAICompatible)) {
@@ -208,7 +209,7 @@ final autoSelectFirstLoadedModelProvider = FutureProvider<void>((ref) async {
209
210
try {
211
final Set<String> loadedModels;
211
- if (activeServer.type == ServerType.onDevice) {
212
+ if (activeServer.isOnDevice) {
213
final engine = ref.watch(onDeviceEngineProvider);
214
loadedModels = engine.loadedModelId != null
215
? {engine.loadedModelId!}
@@ -232,7 +233,7 @@ final autoSelectFirstLoadedModelProvider = FutureProvider<void>((ref) async {
233
.where((m) => m.id == defaultModelId)
234
.firstOrNull;
235
if (defaultModel != null) {
235
- if (activeServer.type == ServerType.onDevice) {
236
+ if (activeServer.isOnDevice) {
237
// On-device inference runs one engine instance at a time, so only
238
// auto-select the default when it's the model the engine already
239
// has loaded; otherwise fall through to the loaded-model logic.
@@ -260,14 +261,15 @@ final autoSelectFirstLoadedModelProvider = FutureProvider<void>((ref) async {
261
262
// Cloud providers fall back to no selection when the configured default
263
// isn't among the available models.
263
- if (activeServer.type == ServerType.openRouter ||
264
- activeServer.type == ServerType.requesty ||
265
- activeServer.type == ServerType.openAICompatible) {
264
+ if (!activeServer.isOnDevice &&
265
+ (activeServer.type == ServerType.openRouter ||
266
+ activeServer.type == ServerType.requesty ||
267
+ activeServer.type == ServerType.openAICompatible)) {
268
return;
269
}
270
271
if (loadedModels.isEmpty) {
270
- if (activeServer.type == ServerType.onDevice) {
272
+ if (activeServer.isOnDevice) {
273
final downloadedAsync = await ref.read(downloadedModelsProvider.future);
274
if (!ref.mounted) return;
275
if (hasDefaultForServer) {
lib/features/models/views/model_picker_sheet.dart
+10
-2
@@ -82,7 +82,14 @@ class _ModelPickerSheetState extends ConsumerState<ModelPickerSheet> {
82
.where((s) => s.id == activeServer?.id)
83
.firstOrNull;
84
final isOnDevice =
85
- currentServer != null && currentServer.type == ServerType.onDevice;
85
+ (activeServer != null &&
86
+ (activeServer.type == ServerType.onDevice ||
87
+ activeServer.id == 'on-device' ||
88
+ activeServer.name.trim().toLowerCase() == 'on-device')) ||
89
+ (currentServer != null &&
90
+ (currentServer.type == ServerType.onDevice ||
91
+ currentServer.id == 'on-device' ||
92
+ currentServer.name.trim().toLowerCase() == 'on-device'));
93
final isLmStudio =
94
currentServer != null && currentServer.type == ServerType.lmStudio;
95
@@ -192,7 +199,8 @@ class _ModelPickerSheetState extends ConsumerState<ModelPickerSheet> {
199
final l10n = AppLocalizations.of(context)!;
200
201
try {
195
- if (activeServer.type == ServerType.onDevice) {
202
+ if (activeServer.type == ServerType.onDevice ||
203
+ activeServer.id == 'on-device') {
204
await ref.read(onDeviceEngineProvider.notifier).unloadModel();
205
} else {
206
final loadedInstances = await ref.read(
lib/features/on_device/providers/on_device_providers.dart
+2
-4
@@ -497,12 +497,10 @@ class OnDeviceEngineNotifier extends Notifier<OnDeviceEngineState> {
497
);
498
499
final activeServer = ref.read(activeServerProvider);
500
- if (activeServer?.type != ServerType.onDevice) {
500
+ if (activeServer?.isOnDevice != true) {
501
final servers = await ref.read(serversProvider.future);
502
if (!ref.mounted) return;
503
- final onDeviceServer = servers
504
- .where((s) => s.type == ServerType.onDevice)
505
- .firstOrNull;
503
+ final onDeviceServer = servers.where((s) => s.isOnDevice).firstOrNull;
504
if (onDeviceServer != null && ref.mounted) {
505
Future.microtask(() {
506
if (ref.mounted) {
lib/features/servers/data/models/server.dart
+1
-1
@@ -302,7 +302,7 @@ class Server {
302
}
303
}
304
305
- bool get isOnDevice => type == ServerType.onDevice;
305
+ bool get isOnDevice => type == ServerType.onDevice || id == 'on-device';
306
bool get isOllamaCloud => type == ServerType.ollamaCloud;
307
bool get isOllamaFamily =>
308
type == ServerType.ollama || type == ServerType.ollamaCloud;
lib/features/servers/data/server_api_service.dart
+8
-5
@@ -12,7 +12,7 @@ class ServerApiService {
12
ServerApiService(this._dio);
13
14
Future<bool> testConnection(Server server) async {
15
- if (server.type == ServerType.onDevice) {
15
+ if (server.isOnDevice) {
16
return true;
17
}
18
@@ -67,6 +67,9 @@ class ServerApiService {
67
}
68
69
Future<List<ModelInfo>> fetchModels(Server server) async {
70
+ if (server.isOnDevice) {
71
+ return [];
72
+ }
73
try {
74
Response response;
75
try {
@@ -122,7 +125,7 @@ class ServerApiService {
125
if (server.type == ServerType.openRouter ||
126
server.type == ServerType.requesty ||
127
server.type == ServerType.openAICompatible ||
125
- server.type == ServerType.onDevice) {
128
+ server.isOnDevice) {
129
return {};
130
}
131
@@ -144,7 +147,7 @@ class ServerApiService {
147
Future<void> loadModel(Server server, String modelId) async {
148
if (server.type == ServerType.openRouter ||
149
server.type == ServerType.requesty ||
147
- server.type == ServerType.onDevice) {
150
+ server.isOnDevice) {
151
return;
152
}
153
@@ -182,7 +185,7 @@ class ServerApiService {
185
}) async {
186
if (server.type == ServerType.openRouter ||
187
server.type == ServerType.requesty ||
185
- server.type == ServerType.onDevice) {
188
+ server.isOnDevice) {
189
return null;
190
}
191
@@ -265,7 +268,7 @@ class ServerApiService {
268
}) async {
269
if (server.type == ServerType.openRouter ||
270
server.type == ServerType.requesty ||
268
- server.type == ServerType.onDevice) {
271
+ server.isOnDevice) {
272
return;
273
}
274
lib/features/servers/providers/server_providers.dart
+61
-6
@@ -114,7 +114,7 @@ final loadedModelsProvider = FutureProvider.family<Set<String>, Server>((
114
) async {
115
ref.watch(loadedModelsRefreshProvider);
116
117
- if (server.type == ServerType.onDevice) {
117
+ if (server.type == ServerType.onDevice || server.id == 'on-device') {
118
final engineState = ref.watch(onDeviceEngineProvider);
119
return engineState.loadedModelId != null
120
? {engineState.loadedModelId!}
@@ -147,7 +147,7 @@ final availableModelsProvider = FutureProvider.family<List<dynamic>, String>((
147
);
148
final apiService = ref.watch(serverApiServiceProvider);
149
150
- if (server.type == ServerType.onDevice) {
150
+ if (server.type == ServerType.onDevice || server.id == 'on-device') {
151
final models = ref
152
.watch(onDeviceModelsProvider)
153
.map(
@@ -207,13 +207,67 @@ class ServersNotifier extends AsyncNotifier<List<Server>> {
207
if (!ref.mounted) return [];
208
final db = ref.read(databaseProvider);
209
final entities = db.serverBox.getAll();
210
- return entities.map((e) => e.toDomain()).toList();
210
+
211
+ // Group entities by id to detect and remove duplicate records.
212
+ final entitiesById = <String, List<ServerEntity>>{};
213
+ for (final entity in entities) {
214
+ entitiesById.putIfAbsent(entity.id, () => []).add(entity);
215
+ }
216
+
217
+ final sanitizedEntities = <ServerEntity>[];
218
+ for (final entry in entitiesById.entries) {
219
+ final list = entry.value;
220
+ if (list.length == 1) {
221
+ sanitizedEntities.add(list.first);
222
+ } else {
223
+ // Prefer isDefault, then entity whose typeIndex matches the resolved domain type,
224
+ // then lowest internalId.
225
+ list.sort((a, b) {
226
+ if (a.isDefault != b.isDefault) {
227
+ return a.isDefault ? -1 : 1;
228
+ }
229
+ final aResolved = a.toDomain().type.index == a.typeIndex;
230
+ final bResolved = b.toDomain().type.index == b.typeIndex;
231
+ if (aResolved != bResolved) {
232
+ return aResolved ? -1 : 1;
233
+ }
234
+ return a.internalId.compareTo(b.internalId);
235
+ });
236
+
237
+ final keep = list.first;
238
+ sanitizedEntities.add(keep);
239
+
240
+ for (var i = 1; i < list.length; i++) {
241
+ db.serverBox.remove(list[i].internalId);
242
+ }
243
+ }
244
+ }
245
+
246
+ // Persist any healed typeIndex back to ObjectBox.
247
+ for (final entity in sanitizedEntities) {
248
+ final domain = entity.toDomain();
249
+ if (entity.typeIndex != domain.type.index) {
250
+ entity.typeIndex = domain.type.index;
251
+ db.serverBox.put(entity);
252
+ }
253
+ }
254
+
255
+ return sanitizedEntities.map((e) => e.toDomain()).toList();
256
}
257
258
Future<void> addServer(Server server) async {
259
if (!ref.mounted) return;
260
final db = ref.read(databaseProvider);
216
- db.serverBox.put(ServerEntity.fromDomain(server));
261
+ final existing = db.serverBox
262
+ .getAll()
263
+ .where((e) => e.id == server.id)
264
+ .firstOrNull;
265
+
266
+ final entity = ServerEntity.fromDomain(server);
267
+ if (existing != null) {
268
+ entity.internalId = existing.internalId;
269
+ }
270
+ db.serverBox.put(entity);
271
invalidateAvailableModelsCache(server.id);
272
final data = await _loadAll();
273
if (ref.mounted) {
@@ -327,7 +381,7 @@ class ServersNotifier extends AsyncNotifier<List<Server>> {
381
if (index == -1) return ConnectionStatus.disconnected;
382
final server = servers[index];
383
330
- if (server.type == ServerType.onDevice) {
384
+ if (server.type == ServerType.onDevice || server.id == 'on-device') {
385
await updateServerStatus(serverId, ConnectionStatus.connected);
386
return ConnectionStatus.connected;
387
}
@@ -367,7 +421,8 @@ class ConnectionStatusNotifier extends Notifier<ConnectionStatus> {
421
return ConnectionStatus.disconnected;
422
}
423
370
- if (activeServer.type == ServerType.onDevice) {
424
+ if (activeServer.type == ServerType.onDevice ||
425
+ activeServer.id == 'on-device') {
426
return ConnectionStatus.connected;
427
}
428
lib/features/servers/views/components/server_card.dart
+1
-1
@@ -65,7 +65,7 @@ class ServerCard extends StatelessWidget {
65
if (server.type == ServerType.ollamaCloud) {
66
return l10n.server_address_ollama_cloud;
67
}
68
- if (server.type == ServerType.onDevice) {
68
+ if (server.isOnDevice) {
69
return l10n.server_address_on_device;
70
}
71
return server.displayAddress;
lib/features/servers/views/server_list_screen.dart
+1
-3
@@ -3,7 +3,6 @@ import 'package:flutter/material.dart';
3
import 'package:flutter_riverpod/flutter_riverpod.dart';
4
import 'package:go_router/go_router.dart';
5
import 'package:localmind/l10n/app_localizations.dart';
6
-import '../../../core/models/enums.dart';
6
import '../../../core/providers/service_providers.dart';
7
import '../../../core/routes/app_routes.dart';
8
import '../../../core/utils/system_insets.dart';
@@ -101,8 +100,7 @@ class _ServerListScreenState extends ConsumerState<ServerListScreen> {
100
itemCount: serverList.length,
101
itemBuilder: (context, index) {
102
final server = serverList[index];
104
- final isOnDevice =
105
- server.type == ServerType.onDevice;
103
+ final isOnDevice = server.isOnDevice;
104
return Padding(
105
padding: const EdgeInsets.only(bottom: 12),
106
child: ServerCard(
test/features/chat/providers/active_chat_target_provider_test.dart
+10
-8
@@ -49,14 +49,16 @@ void main() {
49
);
50
addTearDown(container.dispose);
51
52
- container.read(selectedModelProvider.notifier).setModel(
53
- ModelInfo(
54
- id: 'apple-foundation-models',
55
- name: 'Apple Foundation Models',
56
- serverType: ServerType.onDevice,
57
- serverId: 'on-device',
58
- ),
59
- );
52
+ container
53
+ .read(selectedModelProvider.notifier)
54
+ .setModel(
55
+ ModelInfo(
56
+ id: 'apple-foundation-models',
57
+ name: 'Apple Foundation Models',
58
+ serverType: ServerType.onDevice,
59
+ serverId: 'on-device',
60
+ ),
61
+ );
62
63
final target = container.read(activeChatTargetProvider);
64
test/features/servers/server_entity_healing_test.dart
new
+256
@@ -0,0 +1,256 @@
1
+import 'package:flutter_test/flutter_test.dart';
2
+import 'package:localmind/core/models/enums.dart';
3
+import 'package:localmind/core/storage/entities.dart';
4
+import 'package:localmind/features/servers/data/models/server.dart';
5
+
6
+void main() {
7
+ group('ServerEntity.resolveServerType', () {
8
+ test(
9
+ 'resolves on-device server when typeIndex was 4 before Ollama Cloud was inserted',
10
+ () {
11
+ // Prior to Ollama Cloud (commit 51996bb), onDevice had index 4.
12
+ // After inserting Ollama Cloud at index 3, index 4 became openRouter.
13
+ // Existing on-device records in ObjectBox have typeIndex: 4.
14
+ final type = ServerEntity.resolveServerType(
15
+ typeIndex: 4,
16
+ id: 'on-device',
17
+ name: 'On-Device',
18
+ host: '',
19
+ port: 0,
20
+ );
21
+
22
+ expect(type, ServerType.onDevice);
23
+ },
24
+ );
25
+
26
+ test('resolves on-device server by id even if typeIndex is unexpected', () {
27
+ final type = ServerEntity.resolveServerType(
28
+ typeIndex: 99,
29
+ id: 'on-device',
30
+ name: 'Custom On Device',
31
+ host: '',
32
+ port: 0,
33
+ );
34
+
35
+ expect(type, ServerType.onDevice);
36
+ });
37
+
38
+ test(
39
+ 'resolves on-device server created with timestamp id from onboarding',
40
+ () {
41
+ final type = ServerEntity.resolveServerType(
42
+ typeIndex: 4,
43
+ id: '1720000000000',
44
+ name: 'On-Device',
45
+ host: '',
46
+ port: 0,
47
+ );
48
+
49
+ expect(type, ServerType.onDevice);
50
+ },
51
+ );
52
+
53
+ test(
54
+ 'resolves on-device server with empty host and port 0 without API key',
55
+ () {
56
+ final type = ServerEntity.resolveServerType(
57
+ typeIndex: 4,
58
+ id: 'custom-local',
59
+ name: 'Local inference',
60
+ host: '',
61
+ port: 0,
62
+ );
63
+
64
+ expect(type, ServerType.onDevice);
65
+ },
66
+ );
67
+
68
+ test(
69
+ 'resolves openRouter server when typeIndex was 3 before Ollama Cloud was inserted',
70
+ () {
71
+ // Prior to Ollama Cloud, openRouter had index 3.
72
+ // When Ollama Cloud was inserted at index 3, existing OpenRouter records
73
+ // were deserialized as ollamaCloud.
74
+ final type = ServerEntity.resolveServerType(
75
+ typeIndex: 3,
76
+ id: 'openrouter-1',
77
+ name: 'OpenRouter',
78
+ host: 'https://openrouter.ai/api/v1',
79
+ port: 443,
80
+ apiKey: 'sk-or-v1-testkey',
81
+ );
82
+
83
+ expect(type, ServerType.openRouter);
84
+ },
85
+ );
86
+
87
+ test(
88
+ 'resolves openRouter server by sk-or apiKey even with generic host',
89
+ () {
90
+ final type = ServerEntity.resolveServerType(
91
+ typeIndex: 3,
92
+ id: 'openrouter-2',
93
+ name: 'Cloud AI',
94
+ host: 'https://openrouter.ai',
95
+ port: 443,
96
+ apiKey: 'sk-or-testkey',
97
+ );
98
+
99
+ expect(type, ServerType.openRouter);
100
+ },
101
+ );
102
+
103
+ test('resolves ollamaCloud correctly when host points to ollama.com', () {
104
+ final type = ServerEntity.resolveServerType(
105
+ typeIndex: 3,
106
+ id: 'ollama-cloud-1',
107
+ name: 'Ollama Cloud',
108
+ host: 'https://ollama.com',
109
+ port: 443,
110
+ );
111
+
112
+ expect(type, ServerType.ollamaCloud);
113
+ });
114
+
115
+ test('preserves standard server types by valid typeIndex', () {
116
+ expect(
117
+ ServerEntity.resolveServerType(
118
+ typeIndex: 0,
119
+ id: 'lm-studio',
120
+ name: 'LM Studio',
121
+ host: 'localhost',
122
+ port: 1234,
123
+ ),
124
+ ServerType.lmStudio,
125
+ );
126
+
127
+ expect(
128
+ ServerEntity.resolveServerType(
129
+ typeIndex: 1,
130
+ id: 'oai-compat',
131
+ name: 'Custom OpenAI',
132
+ host: 'http://localhost:8000',
133
+ port: 8000,
134
+ ),
135
+ ServerType.openAICompatible,
136
+ );
137
+
138
+ expect(
139
+ ServerEntity.resolveServerType(
140
+ typeIndex: 2,
141
+ id: 'ollama-local',
142
+ name: 'Ollama',
143
+ host: 'http://localhost:11434',
144
+ port: 11434,
145
+ ),
146
+ ServerType.ollama,
147
+ );
148
+
149
+ expect(
150
+ ServerEntity.resolveServerType(
151
+ typeIndex: 5,
152
+ id: 'on-device-new',
153
+ name: 'On-Device',
154
+ host: '',
155
+ port: 0,
156
+ ),
157
+ ServerType.onDevice,
158
+ );
159
+
160
+ expect(
161
+ ServerEntity.resolveServerType(
162
+ typeIndex: 6,
163
+ id: 'requesty-1',
164
+ name: 'Requesty',
165
+ host: 'router.requesty.ai',
166
+ port: 443,
167
+ ),
168
+ ServerType.requesty,
169
+ );
170
+ });
171
+ });
172
+
173
+ group('ServerEntity.toDomain healing', () {
174
+ test('heals corrupted on-device entity to ServerType.onDevice', () {
175
+ final now = DateTime(2026, 1, 1);
176
+ final entity = ServerEntity(
177
+ id: 'on-device',
178
+ name: 'On-Device',
179
+ typeIndex: 4, // Was onDevice, shifted to openRouter
180
+ host: '',
181
+ port: 0,
182
+ createdAt: now,
183
+ lastConnectedAt: now,
184
+ statusIndex: 1,
185
+ );
186
+
187
+ final domain = entity.toDomain();
188
+ expect(domain.id, 'on-device');
189
+ expect(domain.type, ServerType.onDevice);
190
+ expect(domain.isOnDevice, isTrue);
191
+ });
192
+
193
+ test('heals shifted OpenRouter entity to ServerType.openRouter', () {
194
+ final now = DateTime(2026, 1, 1);
195
+ final entity = ServerEntity(
196
+ id: 'or-server',
197
+ name: 'OpenRouter Cloud',
198
+ typeIndex: 3, // Was openRouter, shifted to ollamaCloud
199
+ host: 'https://openrouter.ai/api/v1',
200
+ port: 443,
201
+ apiKey: 'sk-or-v1-abcdef',
202
+ createdAt: now,
203
+ lastConnectedAt: now,
204
+ statusIndex: 1,
205
+ );
206
+
207
+ final domain = entity.toDomain();
208
+ expect(domain.type, ServerType.openRouter);
209
+ expect(domain.isOnDevice, isFalse);
210
+ });
211
+ });
212
+
213
+ group('Server.isOnDevice', () {
214
+ test('returns true for ServerType.onDevice', () {
215
+ final server = Server(
216
+ id: 'custom-id',
217
+ name: 'On-Device',
218
+ type: ServerType.onDevice,
219
+ host: '',
220
+ port: 0,
221
+ createdAt: DateTime(2026, 1, 1),
222
+ lastConnectedAt: DateTime(2026, 1, 1),
223
+ );
224
+
225
+ expect(server.isOnDevice, isTrue);
226
+ });
227
+
228
+ test('returns true for id on-device even if type was corrupted', () {
229
+ final server = Server(
230
+ id: 'on-device',
231
+ name: 'On-Device',
232
+ type: ServerType.openRouter,
233
+ host: '',
234
+ port: 0,
235
+ createdAt: DateTime(2026, 1, 1),
236
+ lastConnectedAt: DateTime(2026, 1, 1),
237
+ );
238
+
239
+ expect(server.isOnDevice, isTrue);
240
+ });
241
+
242
+ test('returns false for remote servers', () {
243
+ final server = Server(
244
+ id: 'cloud-server',
245
+ name: 'OpenRouter',
246
+ type: ServerType.openRouter,
247
+ host: 'https://openrouter.ai',
248
+ port: 443,
249
+ createdAt: DateTime(2026, 1, 1),
250
+ lastConnectedAt: DateTime(2026, 1, 1),
251
+ );
252
+
253
+ expect(server.isOnDevice, isFalse);
254
+ });
255
+ });
256
+}
test/features/servers/server_providers_healing_test.dart
new
+191
@@ -0,0 +1,191 @@
1
+import 'package:flutter_riverpod/flutter_riverpod.dart';
2
+import 'package:flutter_test/flutter_test.dart';
3
+import 'package:localmind/core/models/enums.dart';
4
+import 'package:localmind/core/providers/storage_providers.dart';
5
+import 'package:localmind/core/storage/entities.dart';
6
+import 'package:localmind/core/storage/objectbox_store.dart';
7
+import 'package:localmind/features/servers/data/models/server.dart';
8
+import 'package:localmind/features/servers/providers/server_providers.dart';
9
+import 'package:localmind/objectbox.g.dart';
10
+
11
+void main() {
12
+ late _FakeServerBox fakeServerBox;
13
+ late _FakeObjectBoxStore fakeStore;
14
+
15
+ setUp(() {
16
+ fakeServerBox = _FakeServerBox();
17
+ fakeStore = _FakeObjectBoxStore(fakeServerBox);
18
+ });
19
+
20
+ test(
21
+ 'ServersNotifier._loadAll deduplicates servers with duplicate id and heals typeIndex in ObjectBox',
22
+ () async {
23
+ final now = DateTime(2026, 1, 1);
24
+
25
+ // Seed duplicate on-device entities:
26
+ // Entity 1: corrupted typeIndex 4 (shifted from onDevice to openRouter)
27
+ final corruptedEntity = ServerEntity(
28
+ internalId: 1,
29
+ id: 'on-device',
30
+ name: 'On-Device',
31
+ typeIndex: 4,
32
+ host: '',
33
+ port: 0,
34
+ createdAt: now,
35
+ lastConnectedAt: now,
36
+ statusIndex: 1,
37
+ );
38
+ // Entity 2: duplicate on-device inserted by bootstrap with typeIndex 5
39
+ final duplicateEntity = ServerEntity(
40
+ internalId: 2,
41
+ id: 'on-device',
42
+ name: 'On-Device',
43
+ typeIndex: 5,
44
+ host: '',
45
+ port: 0,
46
+ createdAt: now,
47
+ lastConnectedAt: now,
48
+ statusIndex: 1,
49
+ );
50
+
51
+ fakeServerBox.put(corruptedEntity);
52
+ fakeServerBox.put(duplicateEntity);
53
+ expect(fakeServerBox.count(), 2);
54
+
55
+ final container = ProviderContainer(
56
+ overrides: [databaseProvider.overrideWithValue(fakeStore)],
57
+ );
58
+ addTearDown(container.dispose);
59
+
60
+ final servers = await container.read(serversProvider.future);
61
+
62
+ // Exactly one on-device server should be returned
63
+ expect(servers.length, 1);
64
+ final onDevice = servers.first;
65
+ expect(onDevice.id, 'on-device');
66
+ expect(onDevice.type, ServerType.onDevice);
67
+ expect(onDevice.isOnDevice, isTrue);
68
+
69
+ // The database itself should now only have 1 entity, with healed typeIndex 5
70
+ expect(fakeServerBox.count(), 1);
71
+ final persisted = fakeServerBox.getAll().first;
72
+ expect(persisted.id, 'on-device');
73
+ expect(persisted.typeIndex, ServerType.onDevice.index);
74
+ },
75
+ );
76
+
77
+ test(
78
+ 'ServersNotifier._loadAll heals shifted typeIndex 4 when single on-device record exists',
79
+ () async {
80
+ final now = DateTime(2026, 1, 1);
81
+ final corruptedEntity = ServerEntity(
82
+ internalId: 1,
83
+ id: 'on-device',
84
+ name: 'On-Device',
85
+ typeIndex: 4, // Was onDevice, shifted to openRouter
86
+ host: '',
87
+ port: 0,
88
+ createdAt: now,
89
+ lastConnectedAt: now,
90
+ statusIndex: 1,
91
+ );
92
+
93
+ fakeServerBox.put(corruptedEntity);
94
+
95
+ final container = ProviderContainer(
96
+ overrides: [databaseProvider.overrideWithValue(fakeStore)],
97
+ );
98
+ addTearDown(container.dispose);
99
+
100
+ final servers = await container.read(serversProvider.future);
101
+
102
+ expect(servers.length, 1);
103
+ expect(servers.first.type, ServerType.onDevice);
104
+ expect(fakeServerBox.getAll().first.typeIndex, ServerType.onDevice.index);
105
+ },
106
+ );
107
+
108
+ test(
109
+ 'ServersNotifier.addServer is idempotent and does not create duplicates for existing id',
110
+ () async {
111
+ final now = DateTime(2026, 1, 1);
112
+ final server = Server(
113
+ id: 'on-device',
114
+ name: 'On-Device',
115
+ type: ServerType.onDevice,
116
+ host: '',
117
+ port: 0,
118
+ createdAt: now,
119
+ lastConnectedAt: now,
120
+ status: ConnectionStatus.connected,
121
+ );
122
+
123
+ final container = ProviderContainer(
124
+ overrides: [databaseProvider.overrideWithValue(fakeStore)],
125
+ );
126
+ addTearDown(container.dispose);
127
+
128
+ // Call addServer twice
129
+ await container.read(serversProvider.notifier).addServer(server);
130
+ await container.read(serversProvider.notifier).addServer(server);
131
+
132
+ final servers = await container.read(serversProvider.future);
133
+ expect(servers.length, 1);
134
+ expect(fakeServerBox.count(), 1);
135
+ },
136
+ );
137
+}
138
+
139
+class _FakeServerBox extends Fake implements Box<ServerEntity> {
140
+ final List<ServerEntity> _entities;
141
+ int _nextId = 1;
142
+
143
+ _FakeServerBox([List<ServerEntity>? initial])
144
+ : _entities = List<ServerEntity>.from(initial ?? []) {
145
+ for (final e in _entities) {
146
+ if (e.internalId == 0) {
147
+ e.internalId = _nextId++;
148
+ } else if (e.internalId >= _nextId) {
149
+ _nextId = e.internalId + 1;
150
+ }
151
+ }
152
+ }
153
+
154
+ @override
155
+ List<ServerEntity> getAll() => List<ServerEntity>.from(_entities);
156
+
157
+ @override
158
+ int put(ServerEntity entity, {PutMode mode = PutMode.put}) {
159
+ if (entity.internalId == 0) {
160
+ entity.internalId = _nextId++;
161
+ _entities.add(entity);
162
+ } else {
163
+ final idx = _entities.indexWhere(
164
+ (e) => e.internalId == entity.internalId,
165
+ );
166
+ if (idx >= 0) {
167
+ _entities[idx] = entity;
168
+ } else {
169
+ _entities.add(entity);
170
+ }
171
+ }
172
+ return entity.internalId;
173
+ }
174
+
175
+ @override
176
+ bool remove(int id) {
177
+ final before = _entities.length;
178
+ _entities.removeWhere((e) => e.internalId == id);
179
+ return _entities.length < before;
180
+ }
181
+
182
+ @override
183
+ int count({int limit = 0}) => _entities.length;
184
+}
185
+
186
+class _FakeObjectBoxStore extends Fake implements ObjectBoxStore {
187
+ _FakeObjectBoxStore(this.serverBox);
188
+
189
+ @override
190
+ final Box<ServerEntity> serverBox;
191
+}