import 'dart:async'; import 'dart:io'; import 'package:crypto/crypto.dart'; import 'package:http/http.dart' as http; import 'package:path/path.dart' as p; import 'package:path_provider/path_provider.dart'; import '../db/daos/meta_dao.dart'; /// meta_kv keys (#215 README §6). class AiMetaKeys { static const optIn = 'ai_opt_in'; static const modelPath = 'ai_model_path'; static const modelSha = 'ai_model_sha256'; static const downloadState = 'ai_download_state'; static const downloadBytes = 'ai_download_bytes'; static const all = [ optIn, modelPath, modelSha, downloadState, downloadBytes, ]; } enum ModelAvailability { ready, missing, corrupt, downloading } enum DownloadState { idle, downloading, paused, completed, failed } class DownloadProgress { final int bytesReceived; final int totalBytes; // -1 if unknown final DownloadState state; final String? errorMessage; const DownloadProgress({ required this.bytesReceived, required this.totalBytes, required this.state, this.errorMessage, }); } /// File-system / HTTP abstraction so tests can inject a fake. abstract class StorageAdapter { Future supportDir(); Future rangeGet(Uri url, int from); } class _ProdStorage implements StorageAdapter { final http.Client client; _ProdStorage(this.client); @override Future supportDir() => getApplicationSupportDirectory(); @override Future rangeGet(Uri url, int from) async { final req = http.Request('GET', url); if (from > 0) req.headers['Range'] = 'bytes=$from-'; return client.send(req); } } /// Default config for the Gemma 4 E2B Q4_0 model. OQ-1: real URL + SHA /// to be pinned in Developer phase after `flutter_gemma` docs review. class ModelConfig { final Uri url; final String expectedSha256; final String filename; const ModelConfig({ required this.url, required this.expectedSha256, this.filename = 'gemma4_e2b_q4.bin', }); } /// Owns the model file: download (resumable) → SHA-256 verify → availability /// query → purge. All paths graceful: on failure surfaces as `corrupt` / /// `failed` rather than throwing through the call chain. class ModelLifecycle { final MetaDao meta; final ModelConfig config; final StorageAdapter _storage; ModelLifecycle({ required this.meta, required this.config, StorageAdapter? storage, http.Client? httpClient, }) : _storage = storage ?? _ProdStorage(httpClient ?? http.Client()); Future _modelPath() async { final dir = await _storage.supportDir(); return p.join(dir.path, config.filename); } Future checkAvailability() async { try { final optIn = await meta.find(AiMetaKeys.optIn); if (optIn != 'true') return ModelAvailability.missing; final state = await meta.find(AiMetaKeys.downloadState); if (state == 'downloading' || state == 'paused') { return ModelAvailability.downloading; } final pathStr = await meta.find(AiMetaKeys.modelPath); if (pathStr == null) return ModelAvailability.missing; final file = File(pathStr); if (!file.existsSync()) return ModelAvailability.missing; final expected = await meta.find(AiMetaKeys.modelSha); if (expected == null) return ModelAvailability.corrupt; final actual = await _hashFile(file); if (actual != expected) return ModelAvailability.corrupt; return ModelAvailability.ready; } catch (_) { return ModelAvailability.corrupt; } } Future _hashFile(File file) async { final digest = await sha256.bind(file.openRead()).first; return digest.toString(); } /// Streams resumable download progress. Mutates `meta_kv` as it goes. /// Errors are emitted as `DownloadProgress(state: failed)` rather than /// thrown — UI listens to the stream. Stream download() async* { final path = await _modelPath(); final tempPath = '$path.tmp'; final tempFile = File(tempPath); int existing = tempFile.existsSync() ? await tempFile.length() : 0; await meta.put(AiMetaKeys.downloadState, 'downloading'); await meta.put(AiMetaKeys.downloadBytes, existing.toString()); yield DownloadProgress( bytesReceived: existing, totalBytes: -1, state: DownloadState.downloading, ); http.StreamedResponse response; try { response = await _storage.rangeGet(config.url, existing); } catch (e) { await meta.put(AiMetaKeys.downloadState, 'paused'); yield DownloadProgress( bytesReceived: existing, totalBytes: -1, state: DownloadState.failed, errorMessage: 'network: $e', ); return; } final status = response.statusCode; if (status != 200 && status != 206) { // 416 etc. — restart from 0. if (tempFile.existsSync()) await tempFile.delete(); existing = 0; await meta.put(AiMetaKeys.downloadBytes, '0'); yield DownloadProgress( bytesReceived: 0, totalBytes: -1, state: DownloadState.failed, errorMessage: 'http $status', ); return; } final contentLength = response.contentLength ?? 0; final total = status == 206 ? existing + contentLength : contentLength; final sink = tempFile.openWrite(mode: FileMode.append); int received = existing; try { await for (final chunk in response.stream) { sink.add(chunk); received += chunk.length; await meta.put(AiMetaKeys.downloadBytes, received.toString()); yield DownloadProgress( bytesReceived: received, totalBytes: total, state: DownloadState.downloading, ); } await sink.flush(); await sink.close(); } catch (e) { await sink.close(); await meta.put(AiMetaKeys.downloadState, 'paused'); yield DownloadProgress( bytesReceived: received, totalBytes: total, state: DownloadState.failed, errorMessage: 'stream: $e', ); return; } // Verify SHA-256. final finalFile = File(path); await tempFile.rename(path); final sha = await _hashFile(finalFile); if (sha != config.expectedSha256) { await finalFile.delete(); await meta.put(AiMetaKeys.downloadState, 'failed'); yield DownloadProgress( bytesReceived: received, totalBytes: total, state: DownloadState.failed, errorMessage: 'sha mismatch', ); return; } await meta.put(AiMetaKeys.modelPath, path); await meta.put(AiMetaKeys.modelSha, sha); await meta.put(AiMetaKeys.downloadState, 'completed'); yield DownloadProgress( bytesReceived: received, totalBytes: total, state: DownloadState.completed, ); } /// opt-out: delete model file + clear all ai_* meta keys (except opt_in /// which the caller toggles). Returns freed bytes (0 if nothing existed). /// Idempotent. Future purge() async { int freed = 0; final pathStr = await meta.find(AiMetaKeys.modelPath); if (pathStr != null) { final f = File(pathStr); if (f.existsSync()) { freed += await f.length(); await f.delete(); } } final tempPath = '${await _modelPath()}.tmp'; final temp = File(tempPath); if (temp.existsSync()) { freed += await temp.length(); await temp.delete(); } for (final k in [ AiMetaKeys.modelPath, AiMetaKeys.modelSha, AiMetaKeys.downloadState, AiMetaKeys.downloadBytes, ]) { await meta.remove(k); } return freed; } }