import 'dart:convert'; import 'dart:io'; import 'package:crypto/crypto.dart'; import 'package:flutter/foundation.dart'; import 'package:http/http.dart' as http; import 'package:path_provider/path_provider.dart'; import 'package:shared_preferences/shared_preferences.dart'; import '../config/app_config.dart'; enum RoutingPackageStatus { notInstalled, downloading, extracting, installed, error, } /// [OfflineRoutingPackageService] - خدمة تنزيل وتثبيت حزمة التوجيه المحلية /// /// English: /// Downloads the Jordan Valhalla routing tar (real road graph + SRTM elevation) /// from the sovereign server, verifies its SHA-256, extracts valhalla_tiles.tar /// (+ admins.sqlite) into the app documents dir and keeps install state. /// /// العربية: /// تُنزّل حزمة توجيه الأردن (غراف الطرق الحقيقي + ارتفاعات SRTM) من السيرفر، /// تتحقق من بصمة SHA-256، تفكّ الأرشيف في مجلد التطبيق، وتتتبع حالة التثبيت — /// لتعمل الملاحة بعدها 100% بدون إنترنت على الشوارع الرسمية. class OfflineRoutingPackageService { static const String _versionKey = 'routing_pkg_version'; static const String _dateKey = 'routing_pkg_date'; static const String _dirKey = 'routing_pkg_dir'; static String get defaultServerUrl => AppConfig.serverUrl; static String get defaultApiKey => AppConfig.apiKey; static final ValueNotifier downloadProgress = ValueNotifier(0.0); static final ValueNotifier statusMessage = ValueNotifier('حزمة التوجيه غير مثبتة'); static final ValueNotifier status = ValueNotifier(RoutingPackageStatus.notInstalled); /// مجلد تثبيت الحزمة على الجهاز (يقرأه ValhallaOfflineEngine) static Future installDir() async { final docs = await getApplicationDocumentsDirectory(); return Directory('${docs.path}/routing/jordan'); } /// المسار النصي لمجلد الحزمة (يُمرَّر للمحرك عبر MethodChannel) static Future installDirPath() async => (await installDir()).path; /// هل الحزمة مثبتة فعلياً (ملف الغراف موجود على القرص)؟ static Future isInstalled() async { try { final dir = await installDir(); if (!dir.existsSync()) return false; return _findFile(dir, 'valhalla_tiles.tar') != null || _findFile(dir, 'jordan_roads.db') != null; } catch (_) { return false; } } /// إصدار الحزمة المثبتة حالياً static Future installedVersion() async { final prefs = await SharedPreferences.getInstance(); return prefs.getString(_versionKey); } /// جلب بيان الحزمة من السيرفر (إصدار/حجم/بصمة) static Future?> fetchManifest({String? serverUrl, String? apiKey}) async { try { final uri = Uri.parse('${serverUrl ?? defaultServerUrl}/api/tactical/routing-package/jordan/manifest'); final resp = await http.get(uri, headers: {'x-api-key': apiKey ?? defaultApiKey}) .timeout(const Duration(seconds: 8)); if (resp.statusCode == 200) { final data = jsonDecodeMap(resp.body); if (data != null && data['available'] == true) return data; } } catch (e) { debugPrint('Routing manifest fetch error: $e'); } return null; } /// محاولة تثبيت تلقائي واحد لكل جلسة — يستدعيها محرك الملاحة عند الحاجة. /// /// English: /// Ensures the routing package exists on device. If missing, attempts ONE /// silent download per session (fails fast offline). Returns true when the /// package is available afterwards. static bool _autoInstallAttemptedThisSession = false; static Future ensureInstalled({ String? serverUrl, String? apiKey, }) async { if (await isInstalled()) return true; if (_autoInstallAttemptedThisSession) return false; _autoInstallAttemptedThisSession = true; debugPrint('RoutingPackage: not installed — attempting one-time auto download...'); return downloadAndInstall(serverUrl: serverUrl, apiKey: apiKey); } /// إعادة تفعيل المحاولة التلقائية (بعد مزامنة يدوية ناجحة مثلاً) static void resetAutoInstallAttempt() => _autoInstallAttemptedThisSession = false; /// التنزيل الكامل: تحقق ← تنزيل مع تقدّم ← تحقق SHA-256 ← فك أرشيف ← تثبيت static Future downloadAndInstall({ String? serverUrl, String? apiKey, Map? manifestOverride, }) async { try { status.value = RoutingPackageStatus.downloading; downloadProgress.value = 0.0; statusMessage.value = 'جاري التحقق من حزمة التوجيه على السيرفر...'; final manifest = manifestOverride ?? await fetchManifest(serverUrl: serverUrl, apiKey: apiKey); if (manifest == null) { status.value = RoutingPackageStatus.error; statusMessage.value = 'حزمة التوجيه غير متوفرة على السيرفر بعد.'; return false; } final expectedSha256 = (manifest['sha256'] as String?)?.toLowerCase(); final version = (manifest['version'] as String?) ?? 'unknown'; final totalBytes = (manifest['sizeBytes'] as num?)?.toInt() ?? 0; // 1. Streaming download with progress statusMessage.value = 'جاري تنزيل غراف طرق الأردن (${_formatBytes(totalBytes)})...'; final uri = Uri.parse('${serverUrl ?? defaultServerUrl}/api/tactical/routing-package/jordan'); final client = http.Client(); final request = http.Request('GET', uri)..headers['x-api-key'] = apiKey ?? defaultApiKey; final response = await client.send(request).timeout(const Duration(minutes: 30)); if (response.statusCode != 200) { status.value = RoutingPackageStatus.error; statusMessage.value = 'فشل تنزيل الحزمة (HTTP ${response.statusCode}).'; return false; } final contentLength = response.contentLength ?? totalBytes; final tmpDir = await getTemporaryDirectory(); final tmpFile = File('${tmpDir.path}/jordan_routing_package.tar'); final sink = tmpFile.openWrite(); int received = 0; await for (final chunk in response.stream) { received += chunk.length; sink.add(chunk); if (contentLength > 0) { downloadProgress.value = (received / contentLength) * 0.85; // 0-85% } } await sink.flush(); await sink.close(); client.close(); // 2. Integrity check statusMessage.value = 'جاري التحقق من سلامة الحزمة (SHA-256)...'; final actualSha256 = (await _sha256OfFile(tmpFile)).toLowerCase(); if (expectedSha256 != null && actualSha256 != expectedSha256) { await tmpFile.delete(); status.value = RoutingPackageStatus.error; statusMessage.value = 'بصمة الحزمة غير مطابقة — يوجد تلف في التنزيل. أعد المحاولة.'; return false; } // 3. Extract into install dir status.value = RoutingPackageStatus.extracting; downloadProgress.value = 0.9; statusMessage.value = 'جاري فك وتثبيت غراف التوجيه على الجهاز...'; final dir = await installDir(); if (dir.existsSync()) dir.deleteSync(recursive: true); dir.createSync(recursive: true); final extracted = await _extractTar(tmpFile, dir); await tmpFile.delete(); if (!extracted) { status.value = RoutingPackageStatus.error; statusMessage.value = 'تعذر فك أرشيف الحزمة.'; return false; } // 4. Persist install state final prefs = await SharedPreferences.getInstance(); await prefs.setString(_versionKey, version); await prefs.setString(_dateKey, DateTime.now().toIso8601String()); await prefs.setString(_dirKey, dir.path); downloadProgress.value = 1.0; status.value = RoutingPackageStatus.installed; statusMessage.value = 'تم تثبيت شبكة الطرق الحقيقية للأردن! التوجيه يعمل الآن بدون إنترنت.'; return true; } catch (e) { debugPrint('Routing package install error: $e'); status.value = RoutingPackageStatus.error; statusMessage.value = 'خطأ في تثبيت حزمة التوجيه: $e'; return false; } } // ── Internals ──────────────────────────────────────────────────────────── static Map? jsonDecodeMap(String body) { try { final value = jsonDecode(body); return value is Map ? value : null; } catch (_) { return null; } } static File? _findFile(Directory dir, String name) { for (final entity in dir.listSync(recursive: true)) { if (entity is File && entity.uri.pathSegments.last == name) return entity; } return null; } /// Extract a tar archive streaming entry-by-entry with minimal memory footprint. static Future _extractTar(File tarFile, Directory destDir) async { RandomAccessFile? raf; try { raf = await tarFile.open(mode: FileMode.read); final headerBuf = Uint8List(512); while (true) { final bytesRead = await raf.readInto(headerBuf, 0, 512); if (bytesRead < 512) break; // Check for EOF (two zero blocks or all zeros in 512-byte block) bool allZeros = true; for (int i = 0; i < 512; i++) { if (headerBuf[i] != 0) { allZeros = false; break; } } if (allZeros) break; // Filename: 0..99 bytes int nameLen = 0; while (nameLen < 100 && headerBuf[nameLen] != 0) { nameLen++; } if (nameLen == 0) continue; final rawName = String.fromCharCodes(headerBuf.sublist(0, nameLen)); final safeName = rawName.replaceAll('\\', '/').split('/').last.trim(); // Size: 124..135 bytes (octal ascii) int sizeLen = 0; final sizeBytes = headerBuf.sublist(124, 136); while (sizeLen < sizeBytes.length && sizeBytes[sizeLen] != 0 && sizeBytes[sizeLen] != 32) { sizeLen++; } final sizeStr = String.fromCharCodes(sizeBytes.sublist(0, sizeLen)).trim(); final fileSize = int.tryParse(sizeStr, radix: 8) ?? 0; // Type flag: '0' (48) or 0 is regular file, '5' (53) is directory final typeFlag = headerBuf[156]; final isDir = typeFlag == 53; if (safeName.isNotEmpty && !isDir && fileSize > 0) { final outFile = File('${destDir.path}/$safeName'); final outSink = outFile.openWrite(); int remaining = fileSize; const chunkSize = 64 * 1024; final buffer = Uint8List(chunkSize); while (remaining > 0) { final toRead = remaining > chunkSize ? chunkSize : remaining; final readCount = await raf.readInto(buffer, 0, toRead); if (readCount <= 0) break; outSink.add(buffer.sublist(0, readCount)); remaining -= readCount; } await outSink.flush(); await outSink.close(); // Skip padding to 512-byte boundary final pad = (512 - (fileSize % 512)) % 512; if (pad > 0) { final curPos = await raf.position(); await raf.setPosition(curPos + pad); } } else if (fileSize > 0) { final totalSkip = fileSize + ((512 - (fileSize % 512)) % 512); final curPos = await raf.position(); await raf.setPosition(curPos + totalSkip); } } return _findFile(destDir, 'valhalla_tiles.tar') != null || _findFile(destDir, 'admins.sqlite') != null || _findFile(destDir, 'jordan_roads.db') != null; } catch (e) { debugPrint('Tar extraction failed: $e'); return false; } finally { try { await raf?.close(); } catch (_) {} } } /// SHA-256 streamed over the file — لا نحمّل الأرشيف كاملاً في الذاكرة static Future _sha256OfFile(File file) async { final sink = _DigestSink(); final converter = sha256.startChunkedConversion(sink); await for (final chunk in file.openRead()) { converter.add(chunk); } converter.close(); return sink.digest?.toString() ?? ''; } static String _formatBytes(int bytes) { if (bytes >= 1024 * 1024 * 1024) return '${(bytes / (1024 * 1024 * 1024)).toStringAsFixed(1)} GB'; if (bytes >= 1024 * 1024) return '${(bytes / (1024 * 1024)).toStringAsFixed(0)} MB'; if (bytes >= 1024) return '${(bytes / 1024).toStringAsFixed(0)} KB'; return '$bytes B'; } } class _DigestSink implements Sink { Digest? digest; @override void add(Digest data) => digest = data; @override void close() {} }