343 lines
13 KiB
Dart
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() {}
|
|
}
|