chore: add build artifacts, map assets, and routing data
This commit is contained in:
@@ -0,0 +1,342 @@
|
||||
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() {}
|
||||
}
|
||||
Reference in New Issue
Block a user