import 'dart:convert'; import 'dart:io'; import 'dart:typed_data'; import 'package:mnemo_cards_backend/packs/pack_manager.dart' show PackManagerUtils; /// Utilities for storing and serving voice audio files. /// /// **Invariant**: `voice_models.voice_url` should store a **path-like reference** /// (usually a file name inside `data/voice/`) or a remote URL, never raw base64. class VoiceStorage { const VoiceStorage(); static final _base64Regex = RegExp(r'^[A-Za-z0-9+/]*={0,2}$'); static bool isRemoteUrl(String value) { final v = value.trim(); return v.startsWith('http://') || v.startsWith('https://'); } /// Accepts: /// - `file.mp3` /// - `voice/file.mp3` /// - `/voice/file.mp3` /// /// Returns sanitized file name (without any directories) or `null`. static String? sanitizeVoiceFileName(String value) { final normalized = value.trim().replaceAll('\\', '/').replaceFirst(RegExp('^/'), ''); if (normalized.isEmpty) return null; if (normalized.contains('..')) return null; final withoutPrefix = normalized.startsWith('voice/') ? normalized.substring('voice/'.length) : normalized; // We only allow a plain file name here. if (withoutPrefix.contains('/')) return null; return withoutPrefix.isEmpty ? null : withoutPrefix; } static ParsedVoiceAudio? tryParseBase64Audio(String value) { final v = value.trim(); if (v.isEmpty) return null; if (v.startsWith('data:')) { final commaIndex = v.indexOf(','); if (commaIndex <= 0) return null; final header = v.substring(0, commaIndex); final payload = v.substring(commaIndex + 1); if (!header.contains(';base64')) return null; final bytes = _decodeBase64(payload); if (bytes == null) return null; final contentType = _contentTypeFromDataUrlHeader(header) ?? _detectContentType(bytes) ?? 'audio/mpeg'; final ext = _extensionFromContentType(contentType) ?? 'mp3'; return ParsedVoiceAudio(bytes: bytes, contentType: contentType, ext: ext); } // Plain base64 (no data URL header) if (v.length < 50) return null; if (!_base64Regex.hasMatch(v)) return null; final bytes = _decodeBase64(v); if (bytes == null) return null; final contentType = _detectContentType(bytes) ?? 'audio/mpeg'; final ext = _extensionFromContentType(contentType) ?? 'mp3'; return ParsedVoiceAudio(bytes: bytes, contentType: contentType, ext: ext); } static Uint8List? _decodeBase64(String input) { try { // normalize() fixes missing padding (=) and whitespace issues. final normalized = base64.normalize(input.trim()); return base64Decode(normalized); } catch (_) { return null; } } static String? _contentTypeFromDataUrlHeader(String header) { // Example: data:audio/mpeg;base64 final match = RegExp(r'^data:([^;]+);base64$').firstMatch(header); return match?.group(1); } static String? _detectContentType(Uint8List bytes) { if (bytes.length >= 12) { // MP3: "ID3" tag or frame sync (0xFF 0xFB / 0xF3 / 0xF2) if (bytes[0] == 0x49 && bytes[1] == 0x44 && bytes[2] == 0x33) { return 'audio/mpeg'; } if (bytes[0] == 0xFF && (bytes[1] == 0xFB || bytes[1] == 0xF3 || bytes[1] == 0xF2)) { return 'audio/mpeg'; } // WAV: "RIFF....WAVE" if (bytes[0] == 0x52 && bytes[1] == 0x49 && bytes[2] == 0x46 && bytes[3] == 0x46 && bytes[8] == 0x57 && bytes[9] == 0x41 && bytes[10] == 0x56 && bytes[11] == 0x45) { return 'audio/wav'; } // OGG: "OggS" if (bytes[0] == 0x4F && bytes[1] == 0x67 && bytes[2] == 0x67 && bytes[3] == 0x53) { return 'audio/ogg'; } // FLAC: "fLaC" if (bytes[0] == 0x66 && bytes[1] == 0x4C && bytes[2] == 0x61 && bytes[3] == 0x43) { return 'audio/flac'; } } return null; } static String? _extensionFromContentType(String contentType) { return switch (contentType) { 'audio/mpeg' => 'mp3', 'audio/mp3' => 'mp3', 'audio/wav' => 'wav', 'audio/x-wav' => 'wav', 'audio/ogg' => 'ogg', 'audio/flac' => 'flac', _ => null, }; } static String _defaultFileName({ required String voiceId, required String ext, }) { return '$voiceId.$ext'; } static Directory _assetsDirectory(Directory? override) { return override ?? PackManagerUtils.assetsDirectory; } static Future _ensureVoiceDirExists(Directory assetsDirectory) async { final dir = Directory('${assetsDirectory.path}/voice'); if (!dir.existsSync()) { await dir.create(recursive: true); } } /// Stores `voiceValue` (base64 or data URL) into `data/voice/` and returns /// the file name that should be stored in DB. static Future persistFromBase64({ required String voiceId, required String voiceValue, Directory? assetsDirectory, }) async { final parsed = tryParseBase64Audio(voiceValue); if (parsed == null) return null; final assetsDir = _assetsDirectory(assetsDirectory); await _ensureVoiceDirExists(assetsDir); final fileName = _defaultFileName(voiceId: voiceId, ext: parsed.ext); final file = File('${assetsDir.path}/voice/$fileName'); await file.writeAsBytes(parsed.bytes, flush: true); return StoredVoiceAudio(fileName: fileName, contentType: parsed.contentType); } /// Tries to resolve a local voice file from a DB value. /// /// Returns the resolved file if it exists, otherwise `null`. static Future tryResolveLocalFile({ required String voiceValue, Directory? assetsDirectory, }) async { final assetsDir = _assetsDirectory(assetsDirectory); final v = voiceValue.trim(); if (v.isEmpty) return null; final sanitized = sanitizeVoiceFileName(v); if (sanitized == null) return null; final file = File('${assetsDir.path}/voice/$sanitized'); if (!file.existsSync()) return null; final bytes = await file.readAsBytes(); final contentType = _detectContentType(bytes) ?? _contentTypeFromFileName( sanitized, ); return ResolvedVoiceAudio( fileName: sanitized, bytes: bytes, contentType: contentType, ); } static String _contentTypeFromFileName(String fileName) { final lower = fileName.toLowerCase(); if (lower.endsWith('.mp3')) return 'audio/mpeg'; if (lower.endsWith('.wav')) return 'audio/wav'; if (lower.endsWith('.ogg')) return 'audio/ogg'; if (lower.endsWith('.flac')) return 'audio/flac'; return 'application/octet-stream'; } } class ParsedVoiceAudio { final Uint8List bytes; final String contentType; final String ext; const ParsedVoiceAudio({ required this.bytes, required this.contentType, required this.ext, }); } class StoredVoiceAudio { final String fileName; final String contentType; const StoredVoiceAudio({ required this.fileName, required this.contentType, }); } class ResolvedVoiceAudio { final String fileName; final Uint8List bytes; final String contentType; const ResolvedVoiceAudio({ required this.fileName, required this.bytes, required this.contentType, }); }