import 'dart:async'; import 'dart:convert'; import 'dart:io'; import 'package:crypto/crypto.dart' show sha256; import 'package:flutter/foundation.dart'; import 'package:http/http.dart' as http; import 'package:path_provider/path_provider.dart'; import '../config/app_config.dart'; /// 模型目录条目(GET /api/v1/app/update 响应 data.models[])。 /// 服务器发布模型后随版本检查一同下发,App 按目录逐数据集下载/更新。 class ModelCatalogItem { final int datasetId; final String datasetName; final String version; final List labels; final int sizeBytes; final String sha256; final String downloadUrl; final String coverUrl; const ModelCatalogItem({ required this.datasetId, required this.datasetName, required this.version, required this.labels, required this.sizeBytes, required this.sha256, required this.downloadUrl, this.coverUrl = '', }); factory ModelCatalogItem.fromJson(Map j) => ModelCatalogItem( datasetId: (j['datasetId'] as num?)?.toInt() ?? 0, datasetName: j['datasetName'] as String? ?? '', version: j['version'] as String? ?? '', labels: (j['labels'] as List? ?? const []) .map((e) => e.toString()) .toList(), sizeBytes: (j['sizeBytes'] as num?)?.toInt() ?? 0, sha256: j['sha256'] as String? ?? '', downloadUrl: j['downloadUrl'] as String? ?? '', coverUrl: j['coverUrl'] as String? ?? '', ); } /// 已就绪模型(字节 + 标签,供推理 worker 加载) class ModelBundle { final int datasetId; final String datasetName; final String version; final List labels; final Uint8List bytes; const ModelBundle({ required this.datasetId, required this.datasetName, required this.version, required this.labels, required this.bytes, }); } /// 模型热更新管理:启动时拉取模型目录(随 /app/update 公开接口下发,无需登录态), /// 按需下载/校验/持久化各数据集模型,供相机页多模型并行推理。 /// /// 存储:应用私有目录 `models//`(model.tflite + labels.json + meta.json), /// meta 记录 {version, sha256},服务器发布新版本时按版本+摘要重下,不重复下载旧模型。 class ModelManager extends ChangeNotifier { static final ModelManager instance = ModelManager._(); final String baseUrl; final http.Client _client; final Future Function()? _rootDirOverride; List _models = const []; List _catalog = const []; final Set _activeIds = {}; final Set _downloadedIds = {}; final Map _progress = {}; final Map _errors = {}; final Set _cancelRequested = {}; bool _activeLoaded = false; bool _ready = false; bool _refreshing = false; String? _error; Future? _inFlight; /// 模型文件/激活集变更版本戳:下载完成或激活变化 +1, /// UI 据此判断是否需要重建推理 worker(2026-09-01 自动更新引入) int _revision = 0; int get revision => _revision; /// 服务器目录(弹层模型清单展示用) List get catalog => _catalog; /// 激活模型 id 集合(多选叠加) Set get activeDatasetIds => Set.unmodifiable(_activeIds); bool isActive(int datasetId) => _activeIds.contains(datasetId); /// 该数据集模型文件是否已下载到本地(同步判断,内存态) bool isDownloaded(int datasetId) => _downloadedIds.contains(datasetId); /// 下载进度 0..1(无下载/已完成为 null) double? progressOf(int datasetId) => _progress[datasetId]; /// 下载失败原因(失败后可重试) String? errorOf(int datasetId) => _errors[datasetId]; /// 中断进行中的下载:下一个数据块到达时终止(丢弃 .part),卡片恢复「使用」。 /// 取消不记错误,可再次下载。 void cancelDownload(int datasetId) { _cancelRequested.add(datasetId); } ModelManager._({String? baseUrl, http.Client? client}) : this(baseUrl: baseUrl, client: client); /// 可注入 baseUrl / client / 存储根目录(单测用) @visibleForTesting ModelManager({ String? baseUrl, http.Client? client, Future Function()? rootDir, }) : baseUrl = baseUrl ?? AppConfig.apiBaseUrl, _client = client ?? http.Client(), _rootDirOverride = rootDir; /// 已激活且已下载的模型列表(空 = 未下载任何模型,相机页仅预览) List get models => _models; /// 是否成功拉取过目录(即使下载失败也为 true,用于区分"从未联网"与"目录为空") bool get ready => _ready; /// 模型目录拉取失败的错误信息(仅目录级;下载/校验失败见 errorOf) String? get error => _error; bool get refreshing => _refreshing; /// 模型名摘要(诊断行展示):未下载 / 数据集名×n String get modelsLabel { if (_models.isEmpty) return '未下载'; return _models.map((m) => m.datasetName).join(','); } /// 拉取目录并同步本地模型;并发调用共享同一进行中的刷新。 Future refresh() { if (_refreshing) return _inFlight ?? Future.value(); _refreshing = true; _inFlight = _doRefresh().whenComplete(() { _refreshing = false; _inFlight = null; notifyListeners(); }); return _inFlight!; } Future _doRefresh() async { try { await _loadActive(); final res = await _client .get(Uri.parse('$baseUrl/api/v1/app/update')) .timeout(const Duration(seconds: 8)); // 服务器 Content-Type 无 charset,http 包默认按 latin1 解码会乱码 → 显式 utf8 final body = jsonDecode(utf8.decode(res.bodyBytes)) as Map; final data = body['data'] as Map? ?? const {}; final list = data['models'] as List? ?? const []; _catalog = list .map((e) => ModelCatalogItem.fromJson(e as Map)) .toList(); // 只拉目录不下载;扫描本地已下载(meta+文件齐备)供清单展示 final downloaded = {}; for (final item in _catalog) { if (await _isLocal(item)) downloaded.add(item.datasetId); } _downloadedIds ..clear() ..addAll(downloaded); await _prune(_catalog); // 服务器已下线的数据集移出激活集 final catalogIds = _catalog.map((c) => c.datasetId).toSet(); if (_activeIds.any((id) => !catalogIds.contains(id))) { _activeIds.removeWhere((id) => !catalogIds.contains(id)); await _saveActive(); } _models = await _loadBundles(_catalog); _ready = true; _error = null; // 自动更新:已下载/已激活模型发现新版本后台重下(不阻塞目录刷新) unawaited(autoUpdate()); } catch (e) { if (!_ready) _error = '模型目录拉取失败:$e'; // 已就绪过则保留旧目录/旧模型,不覆盖 error(下载级错误优先展示) } } /// 本地是否已有匹配版本的文件(meta 版本+sha256 相符且文件存在) Future _isLocal(ModelCatalogItem item) async { final dir = await _modelDir(item.datasetId); try { final meta = await _readMeta(dir); final file = File('${dir.path}/model.tflite'); return meta != null && meta['version'] == item.version && meta['sha256'] == item.sha256 && await file.exists(); } catch (e) { return false; } } /// 按需下载并激活:流式下载 + sha256 校验 + 落盘(labels/meta); /// 成功自动加入激活集(下载完成即使用)。失败重试一次并记录错误。 Future downloadModel(ModelCatalogItem item, {void Function(int received, int total)? onProgress}) async { // 并发保护:同一数据集已有进行中的下载则直接短路(预置 0 先占位, // 使 onProgress 首次回调前的双击/refresh 交错也被 containsKey 拦下) if (_progress.containsKey(item.datasetId)) return false; _cancelRequested.remove(item.datasetId); _progress[item.datasetId] = 0; final dir = await _modelDir(item.datasetId); final file = File('${dir.path}/model.tflite'); try { for (var attempt = 0; attempt < 2; attempt++) { if (_cancelRequested.contains(item.datasetId)) break; final ok = await _downloadAndVerify(item, dir, file, onProgress: (r, t) { _progress[item.datasetId] = t == 0 ? 0 : r / t; onProgress?.call(r, t); notifyListeners(); }); if (ok) { _progress.remove(item.datasetId); _errors.remove(item.datasetId); _downloadedIds.add(item.datasetId); _revision++; // 新版本字节立即生效:自动更新时已激活模型 setActive 会因状态未变 // 提前返回,不在此重载则重建 worker 仍读到旧模型 _models = await _loadBundles(_catalog); notifyListeners(); await setActive(item.datasetId, true); return true; } if (_cancelRequested.contains(item.datasetId)) break; await file.delete().catchError((_) => file); await File('${dir.path}/model.tflite.part') .delete() .catchError((_) => file); } if (_cancelRequested.contains(item.datasetId)) { // 用户取消:清理残留,不记错误 await file.delete().catchError((_) => file); await File('${dir.path}/model.tflite.part') .delete() .catchError((_) => file); _progress.remove(item.datasetId); notifyListeners(); debugPrint('[ModelManager] 下载已取消: ${item.datasetName}'); return false; } _progress.remove(item.datasetId); _errors[item.datasetId] = '下载失败,请重试'; notifyListeners(); debugPrint('[ModelManager] 下载失败: ${item.datasetName} ${item.version}'); return false; } catch (e) { if (_cancelRequested.contains(item.datasetId)) { await file.delete().catchError((_) => file); await File('${dir.path}/model.tflite.part') .delete() .catchError((_) => file); _progress.remove(item.datasetId); notifyListeners(); debugPrint('[ModelManager] 下载已取消: ${item.datasetName}'); return false; } _progress.remove(item.datasetId); _errors[item.datasetId] = '下载异常:$e'; notifyListeners(); debugPrint('[ModelManager] 下载异常 ${item.datasetName}: $e'); return false; } } Future _downloadAndVerify( ModelCatalogItem item, Directory dir, File file, {void Function(int received, int total)? onProgress}) async { final part = File('${file.path}.part'); final sink = part.openWrite(); var received = 0; try { final res = await _client .send(http.Request('GET', Uri.parse('$baseUrl${item.downloadUrl}'))) .timeout(const Duration(minutes: 3)); if (res.statusCode != 200) { await sink.close(); return false; } final total = res.contentLength ?? item.sizeBytes; await for (final chunk in res.stream) { if (_cancelRequested.contains(item.datasetId)) break; // 用户取消 sink.add(chunk); received += chunk.length; onProgress?.call(received, total); } if (_cancelRequested.contains(item.datasetId)) { await sink.close(); return false; } await sink.close(); final bytes = await part.readAsBytes(); final hex = sha256.convert(bytes).toString(); if (item.sha256.isNotEmpty && hex != item.sha256) { debugPrint('[ModelManager] sha256 不匹配: ${item.datasetName} ' 'want=${item.sha256} got=$hex'); return false; } await part.rename(file.path); await dir.create(recursive: true); await File('${dir.path}/labels.json') .writeAsString(jsonEncode(item.labels)); await File('${dir.path}/meta.json').writeAsString(jsonEncode({ 'version': item.version, 'sha256': item.sha256, })); debugPrint('[ModelManager] 已下载 ${item.datasetName} ' '${bytes.length}B -> ${file.path}'); return true; } catch (e) { await sink.close().catchError((_) {}); debugPrint('[ModelManager] 下载异常 ${item.datasetName}: $e'); return false; } } /// 清理服务器目录中已下线的数据集模型(不再发布则删本地) Future _prune(List catalog) async { final root = await _rootDir(); if (!await root.exists()) return; final keep = catalog.map((c) => '${c.datasetId}').toSet(); await for (final e in root.list()) { if (e is Directory) { // 目录 URI 末尾带 '/',pathSegments 末位为空串 → 过滤后取目录名 final name = e.uri.pathSegments.where((s) => s.isNotEmpty).last; if (!keep.contains(name)) { await e.delete(recursive: true).catchError((_) => e); } } } } /// 设置激活状态(true=使用,false=取消);持久化到 `root/active.json`。 /// 未下载的模型不可激活(下载完成由 downloadModel 自动激活)。 Future setActive(int datasetId, bool active) async { final changed = active ? _activeIds.add(datasetId) : _activeIds.remove(datasetId); if (!changed) return; _revision++; _models = await _loadBundles(_catalog); await _saveActive(); notifyListeners(); } /// 自动更新:已下载/已激活的模型,目录出现新版本时自动重下(保持原激活状态; /// 未下载的模型不自动拉取,避免无谓流量)。下载进度经 downloadModel 通知。 /// 2026-09-01 用户需求:发布新模型后 App 端自动更新,无需手动触发。 Future autoUpdate() async { if (_catalog.isEmpty) return; final tracked = {..._downloadedIds, ..._activeIds}; for (final item in _catalog) { if (!tracked.contains(item.datasetId)) continue; if (await _isLocal(item)) continue; final wasActive = _activeIds.contains(item.datasetId); try { final ok = await downloadModel(item); // 原本未激活:下载完成自动激活后恢复原状态 if (ok && !wasActive) await setActive(item.datasetId, false); } catch (e) { debugPrint('[ModelManager] 自动更新失败: ${item.datasetName} $e'); } } } Future _saveActive() async { try { final root = await _rootDir(); await root.create(recursive: true); await File('${root.path}/active.json') .writeAsString(jsonEncode({'active': _activeIds.toList()})); } catch (e) { debugPrint('[ModelManager] 激活集持久化失败: $e'); } } Future _loadActive() async { if (_activeLoaded) return; _activeLoaded = true; try { final root = await _rootDir(); final f = File('${root.path}/active.json'); if (!await f.exists()) return; final data = jsonDecode(await f.readAsString()) as Map; _activeIds ..clear() ..addAll((data['active'] as List? ?? const []) .map((e) => (e as num).toInt())); } catch (e) { debugPrint('[ModelManager] 激活集读取失败: $e'); } } Future> _loadBundles( List catalog) async { final bundles = []; for (final item in catalog) { if (!_activeIds.contains(item.datasetId)) continue; try { final dir = await _modelDir(item.datasetId); final file = File('${dir.path}/model.tflite'); if (!await file.exists()) continue; final labels = await File('${dir.path}/labels.json').exists() ? (jsonDecode( await File('${dir.path}/labels.json').readAsString()) as List) .map((e) => e.toString()) .toList() : item.labels; bundles.add(ModelBundle( datasetId: item.datasetId, datasetName: item.datasetName, version: item.version, labels: labels, bytes: await file.readAsBytes(), )); } catch (e) { debugPrint('[ModelManager] 读取 ${item.datasetName} 失败: $e'); } } return bundles; } Future?> _readMeta(Directory dir) async { final f = File('${dir.path}/meta.json'); if (!await f.exists()) return null; try { return jsonDecode(await f.readAsString()) as Map; } catch (_) { return null; } } Future _rootDir() async { if (_rootDirOverride != null) return _rootDirOverride(); final support = await getApplicationSupportDirectory(); return Directory('${support.path}/models'); } Future _modelDir(int datasetId) async { final root = await _rootDir(); final dir = Directory('${root.path}/$datasetId'); await dir.create(recursive: true); return dir; } }