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); } /// Lightweight ready estimate for warm-up gating (#311). /// /// Skips the SHA-256 re-hash that [checkAvailability] performs — for a /// ~2.4GB model file the hash is wall-clock-noticeable on every screen /// mount. Returns `ready` iff: /// - opt_in is true /// - download_state is not in-progress /// - meta_kv has both ai_model_path and ai_model_sha256 /// - the file exists on disk /// /// Tampering/disk-corruption detection is left to [checkAvailability]'s /// cold path (SettingsScreen). The trade-off is documented in /// `docs/design/311-llm-warmup/README.md` §11 R4. Future quickCheck() 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 expected = await meta.find(AiMetaKeys.modelSha); if (expected == null) return ModelAvailability.corrupt; final file = File(pathStr); if (!file.existsSync()) return ModelAvailability.missing; return ModelAvailability.ready; } catch (_) { return ModelAvailability.corrupt; } } 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. /// /// F2 hardening (#218): per-file try/catch so a single OS-level delete /// failure (locked file, permission flake) does not abort the whole /// purge — meta keys still get cleared and the orphan file becomes a /// background storage concern rather than a stuck "opt-out failed" /// state. The freed-bytes count only reflects successful deletes. Future purge() async { int freed = 0; final pathStr = await meta.find(AiMetaKeys.modelPath); if (pathStr != null) { try { final f = File(pathStr); if (f.existsSync()) { final size = await f.length(); await f.delete(); freed += size; } } catch (_) { // Best-effort; leave orphan file, continue purging meta. } } try { final tempPath = '${await _modelPath()}.tmp'; final temp = File(tempPath); if (temp.existsSync()) { final size = await temp.length(); await temp.delete(); freed += size; } } catch (_) { // Same as above — best-effort cleanup of the .tmp partial. } for (final k in [ AiMetaKeys.modelPath, AiMetaKeys.modelSha, AiMetaKeys.downloadState, AiMetaKeys.downloadBytes, ]) { try { await meta.remove(k); } catch (_) { // Meta is a single sqlite table; failures here are rare. // Swallow so the loop completes even if one key errors. } } return freed; } }