Files
maps-saas/packages/tactical_app/lib/services/offline_routing_package_service.dart
T

343 lines
13 KiB
Dart

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<double> downloadProgress = ValueNotifier(0.0);
static final ValueNotifier<String> statusMessage = ValueNotifier('حزمة التوجيه غير مثبتة');
static final ValueNotifier<RoutingPackageStatus> status =
ValueNotifier(RoutingPackageStatus.notInstalled);
/// مجلد تثبيت الحزمة على الجهاز (يقرأه ValhallaOfflineEngine)
static Future<Directory> installDir() async {
final docs = await getApplicationDocumentsDirectory();
return Directory('${docs.path}/routing/jordan');
}
/// المسار النصي لمجلد الحزمة (يُمرَّر للمحرك عبر MethodChannel)
static Future<String> installDirPath() async => (await installDir()).path;
/// هل الحزمة مثبتة فعلياً (ملف الغراف موجود على القرص)؟
static Future<bool> 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<String?> installedVersion() async {
final prefs = await SharedPreferences.getInstance();
return prefs.getString(_versionKey);
}
/// جلب بيان الحزمة من السيرفر (إصدار/حجم/بصمة)
static Future<Map<String, dynamic>?> 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<bool> 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<bool> downloadAndInstall({
String? serverUrl,
String? apiKey,
Map<String, dynamic>? 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<String, dynamic>? jsonDecodeMap(String body) {
try {
final value = jsonDecode(body);
return value is Map<String, dynamic> ? 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<bool> _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<String> _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? digest;
@override
void add(Digest data) => digest = data;
@override
void close() {}
}