From 0db59647eb2a2612822b57fcfee29e187568c126 Mon Sep 17 00:00:00 2001 From: Hamza-Ayed Date: Fri, 7 Aug 2026 19:01:00 +0300 Subject: [PATCH] Update: 2026-08-07 19:01:00 --- backend/tools/env_keys.php | 233 ++++++++++++++ siro_driver/lib/constant/links.dart | 9 + .../driver_insurance_controller.dart | 156 ++++++++++ siro_driver/lib/translations_ar.json | 21 +- siro_driver/lib/translations_en.json | 21 +- .../Captin/home_captain/drawer_captain.dart | 6 + .../home/journal/driver_insurance_page.dart | 285 ++++++++++++++++++ sms_relay/app/build.gradle.kts | 64 ++++ sms_relay/app/proguard-rules.pro | 21 ++ sms_relay/app/src/main/AndroidManifest.xml | 67 ++++ .../app/src/main/java/com/siro/relay/Api.kt | 130 ++++++++ .../main/java/com/siro/relay/BootReceiver.kt | 20 ++ .../main/java/com/siro/relay/MainActivity.kt | 98 ++++++ .../main/java/com/siro/relay/RelayService.kt | 184 +++++++++++ .../main/java/com/siro/relay/SmsReceiver.kt | 49 +++ .../res/drawable/ic_launcher_background.xml | 170 +++++++++++ .../res/drawable/ic_launcher_foreground.xml | 30 ++ .../res/mipmap-anydpi-v26/ic_launcher.xml | 6 + .../mipmap-anydpi-v26/ic_launcher_round.xml | 6 + sms_relay/app/src/main/res/values/colors.xml | 10 + sms_relay/app/src/main/res/values/strings.xml | 10 + sms_relay/app/src/main/res/values/themes.xml | 4 + .../main/res/xml/network_security_config.xml | 12 + sms_relay/build.gradle.kts | 6 + sms_relay/gradle.properties | 23 ++ sms_relay/gradle/libs.versions.toml | 32 ++ sms_relay/gradle/wrapper/gradle-wrapper.jar | Bin 0 -> 59203 bytes .../gradle/wrapper/gradle-wrapper.properties | 6 + sms_relay/gradlew | 185 ++++++++++++ sms_relay/gradlew.bat | 89 ++++++ sms_relay/settings.gradle.kts | 23 ++ 31 files changed, 1974 insertions(+), 2 deletions(-) create mode 100644 backend/tools/env_keys.php create mode 100644 siro_driver/lib/controller/insurance/driver_insurance_controller.dart create mode 100644 siro_driver/lib/views/home/journal/driver_insurance_page.dart create mode 100644 sms_relay/app/build.gradle.kts create mode 100644 sms_relay/app/proguard-rules.pro create mode 100644 sms_relay/app/src/main/AndroidManifest.xml create mode 100644 sms_relay/app/src/main/java/com/siro/relay/Api.kt create mode 100644 sms_relay/app/src/main/java/com/siro/relay/BootReceiver.kt create mode 100644 sms_relay/app/src/main/java/com/siro/relay/MainActivity.kt create mode 100644 sms_relay/app/src/main/java/com/siro/relay/RelayService.kt create mode 100644 sms_relay/app/src/main/java/com/siro/relay/SmsReceiver.kt create mode 100644 sms_relay/app/src/main/res/drawable/ic_launcher_background.xml create mode 100644 sms_relay/app/src/main/res/drawable/ic_launcher_foreground.xml create mode 100644 sms_relay/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml create mode 100644 sms_relay/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml create mode 100644 sms_relay/app/src/main/res/values/colors.xml create mode 100644 sms_relay/app/src/main/res/values/strings.xml create mode 100644 sms_relay/app/src/main/res/values/themes.xml create mode 100644 sms_relay/app/src/main/res/xml/network_security_config.xml create mode 100644 sms_relay/build.gradle.kts create mode 100644 sms_relay/gradle.properties create mode 100644 sms_relay/gradle/libs.versions.toml create mode 100644 sms_relay/gradle/wrapper/gradle-wrapper.jar create mode 100644 sms_relay/gradle/wrapper/gradle-wrapper.properties create mode 100755 sms_relay/gradlew create mode 100644 sms_relay/gradlew.bat create mode 100644 sms_relay/settings.gradle.kts diff --git a/backend/tools/env_keys.php b/backend/tools/env_keys.php new file mode 100644 index 00000000..fff7a620 --- /dev/null +++ b/backend/tools/env_keys.php @@ -0,0 +1,233 @@ + 32, // خادم ↔ خادم داخلي + 'WEBHOOK_SECRET_KEY' => 32, // هاتف الرسائل ↔ الويب‌هوك + 'INTERNAL_SOCKET_KEY' => 32, // الباك إند ↔ خوادم السوكيت + 'BOT_SECRET_KEY' => 24, // بوت الأسعار + 'FCM_INTERNAL_API_KEY' => 32, + 'MIGRATION_ADMIN_KEY' => 24, + 'PAYMENT_KEY' => 32, +]; + +/** + * ‏تدويرها يبطل كل الجلسات القائمة: كل سائق وكل راكب سيُخرَج ويُطالَب + * ‏بتسجيل دخول جديد. ليس عطلاً لكنه حدث تشغيلي — لا يُفعل وقت الذروة. + */ +const SESSION_SCOPED = [ + 'JWT_SECRET_KEY' => 64, + 'SECRET_KEY_HMAC' => 32, +]; + +/** + * ‏‏⛔ مجمّدة. هذه ليست «مفاتيح يمكن تغييرها من عندنا» رغم أنها ملكنا: + * + * ENC_KEY / ENCRYPTION_KEY_PATH + * ‏تُشفَّر به أسماء الركّاب وهواتفهم وبرديهم ولوحات السيارات + * ‏**المخزَّنة في القاعدة**. تغييره لا يعيد تشفير شيء — يجعل كل + * ‏صف قديم رطانةً لا تُفكّ. لا استرجاع، ولا نسخة احتياطية تنفع + * ‏لأن النسخة نفسها مشفّرة بالمفتاح القديم. + * + * BLIND_INDEX_PEPPER + * ‏أعمدة `*_bidx` محسوبة به ومخزَّنة. تغييره يعني أن كل بحث + * ‏بالهاتف أو البريد يفشل — لا يجد المستخدم حسابه، ويُنشأ له + * ‏حساب مكرر عند أول تسجيل. + * + * TRACKING_SECRET_SALT / FP_PEPPER + * ‏قيم مشتقّة منها مخزَّنة أيضاً. + * + * ‏تدويرها ممكن نظرياً لكنه **ترحيل بيانات** لا تغيير إعداد: فكّ بالقديم + * ‏وإعادة تشفير بالجديد على كل صف، بتوقّف مخطَّط ونسخة احتياطية أولاً. + */ +const FROZEN = [ + 'ENC_KEY', 'ENCRYPTION_KEY_PATH', 'ENCRYPTION_MODE', + 'BLIND_INDEX_PEPPER', 'TRACKING_SECRET_SALT', 'FP_PEPPER', +]; + +/** ‏لا تُولَّد — تُنسخ من لوحة المزوّد. */ +const EXTERNAL = [ + 'GEMINI_API_KEY', 'NABEH_API_KEY', 'NABEH_PASSWORD', 'NABEH_OTP_APP_KEY', + 'ELEVENLABS_API_KEY', 'CREATOMATE_API_KEY', 'SOCIAL_BOT_TOKEN', + 'MAIL_PASSWORD', 'SIRO_SMTP_PASSWORD', 'SMS_PASSWORD_EGYPT', + 'VOICE_CALL_API_KEY', 'RASEEL_DRIVER_ACCESS_TOKEN', + 'FIREBASE_SERVICE_ACCOUNT_PATH', 'SERVICE_ACCOUNT_FILE_PATH', +]; + +/** + * ‏كلمات مرور قواعد البيانات — صنف قائم بذاته. + * + * ‏تغييرها في .env **لا يغيّر شيئاً في MySQL**. متغيّرات صورة mysql + * ‏(ومنها MYSQL_ROOT_PASSWORD) تُقرأ مرة واحدة فقط عند تهيئة مجلّد + * ‏البيانات الفارغ أول مرة. بعد ذلك كلمة المرور تعيش داخل جدول + * ‏mysql.user لا في .env — فتعديل .env وحده يعني تطبيقاً يحمل كلمة + * ‏لا يقبلها الخادم: انقطاع كامل بلا أي تغيير في القاعدة نفسها. + * + * ‏الترتيب الصحيح: ALTER USER في MySQL أولاً، ثم .env، ثم إعادة تشغيل + * ‏حاويات php (لا mysql، ولا إعادة بناء، ولا مساس بالبيانات إطلاقاً). + */ +const DB_CREDENTIALS = [ + 'DB_PRIMARY_PASS_V2', 'DB_PRIMARY_PASS', 'DB_TRACKING_PASS', + 'DB_RIDE_PASS', 'DB_TRANSIT_PASS', 'DB_FOOD_PASS', 'DB_PAYMENT_PASS', + 'MYSQL_ROOT_PASSWORD', 'REDIS_PASSWORD', 'REDIS_MAIN_PASSWORD', + 'REDIS_LOCATION_PASSWORD', 'REDIS_AUTH', 'REDIS_MAIN_AUTH', +]; + +// ═══════════════════════════════════════════════════════════════ +// القراءة +// ═══════════════════════════════════════════════════════════════ + +if (!is_readable($envFile)) { + fwrite(STDERR, "لا يمكن قراءة $envFile\n"); + exit(1); +} + +$lines = file($envFile, FILE_IGNORE_NEW_LINES); +$present = []; +foreach ($lines as $line) { + if (preg_match('/^\s*([A-Z0-9_]+)\s*=\s*(.*)$/', $line, $m)) { + $present[$m[1]] = trim($m[2], "\"' "); + } +} + +/** ‏قيمة نائبة لا سرّاً: موجودة شكلاً وغائبة فعلاً. */ +$isPlaceholder = static function (string $v): bool { + if ($v === '') return true; + return (bool) preg_match('/^( $v) { + $name = is_int($k) ? $v : $k; + $bytes = is_int($k) ? 0 : $v; + + $exists = array_key_exists($name, $present); + $blank = $exists && $isPlaceholder($present[$name]); + + $state = !$exists ? 'مفقود' : ($blank ? 'نائب' : 'مضبوط'); + printf(" %-30s %s\n", $name, $state); + + if ($seedable && ($state !== 'مضبوط') && $toSeed !== null) { + $toSeed[$name] = $bytes ?: 32; + } + } +}; + +$report('أسرار داخلية (تُولَّد وتُدوَّر بحرية)', GENERATED, $present, $isPlaceholder, $toSeed, true); +$report('مرتبطة بالجلسات (تدويرها يُخرج الجميع)', SESSION_SCOPED, $present, $isPlaceholder, $toSeed, true); +$report('⛔ مجمّدة — لا تُدوَّر (بيانات مخزَّنة مشتقّة منها)', FROZEN, $present, $isPlaceholder); +$report('اعتماد قواعد البيانات (يُغيَّر في MySQL أولاً لا هنا)', DB_CREDENTIALS, $present, $isPlaceholder); +$report('طرف ثالث (تُنسخ من لوحة المزوّد)', EXTERNAL, $present, $isPlaceholder); + +echo "\n" . str_repeat('─', 62) . "\n"; + +if ($command !== 'seed') { + echo empty($toSeed) + ? "لا ينقص شيء قابل للتوليد.\n" + : 'قابل للزراعة: ' . count($toSeed) . " مفتاحاً. شغّل: php env_keys.php seed\n"; + exit(0); +} + +// ═══════════════════════════════════════════════════════════════ +// الزراعة +// ═══════════════════════════════════════════════════════════════ + +if (empty($toSeed)) { + echo "لا ينقص شيء قابل للتوليد.\n"; + exit(0); +} + +// ‏لا نلمس مفتاحاً مضبوطاً أبداً: هذه الأداة تملأ الفراغ فقط. التدوير +// ‏قرار واعٍ له تبعات (جلسات، خوادم أخرى تحمل نفس السرّ)، ولا يجوز أن +// ‏يقع كأثر جانبي لتشغيل أداة جرد. +$additions = []; +foreach ($toSeed as $name => $bytes) { + $value = bin2hex(random_bytes($bytes)); + $additions[$name] = $value; + echo "توليد $name (" . ($bytes * 2) . " حرفاً)\n"; +} + +if ($dryRun) { + echo "\n[dry-run] لم يُكتب شيء.\n"; + exit(0); +} + +// ‏نسخة احتياطية قبل أي كتابة. ملف .env تالف يعني توقف كل شيء، وهو +// ‏غير موجود في git بطبيعته فلا مصدر آخر لاستعادته. +$backup = $envFile . '.bak.' . date('Ymd_His'); +if (!copy($envFile, $backup)) { + fwrite(STDERR, "تعذّرت النسخة الاحتياطية — أُلغيت العملية.\n"); + exit(1); +} +@chmod($backup, 0600); +echo "\nنسخة احتياطية: $backup\n"; + +$out = rtrim(file_get_contents($envFile), "\n") . "\n"; + +// ‏نستبدل السطر النائب في مكانه، ونُلحق المفقود في النهاية — الاستبدال +// ‏في المكان يبقي التعليقات المجاورة مفيدة. +foreach ($additions as $name => $value) { + if (array_key_exists($name, $present)) { + $out = preg_replace( + '/^\s*' . preg_quote($name, '/') . '\s*=.*$/m', + "$name=$value", + $out, + 1 + ); + unset($additions[$name]); + } +} + +if (!empty($additions)) { + $out .= "\n# ── زُرعت تلقائياً بـ tools/env_keys.php في " . date('Y-m-d H:i') . " ──\n"; + foreach ($additions as $name => $value) { + $out .= "$name=$value\n"; + } +} + +if (file_put_contents($envFile, $out) === false) { + fwrite(STDERR, "تعذّرت الكتابة. النسخة الاحتياطية سليمة: $backup\n"); + exit(1); +} +@chmod($envFile, 0600); + +echo "\nتمّت الزراعة.\n"; +echo "الخطوة التالية — أعد تشغيل حاويات php لتقرأ الجديد:\n"; +echo " docker compose restart php php_food php_transit\n\n"; +echo "تذكير: WEBHOOK_SECRET_KEY يجب أن يطابق RELAY_SECRET في\n"; +echo "sms_relay/app/build.gradle.kts — وهو يحتاج إعادة بناء للتطبيق.\n"; diff --git a/siro_driver/lib/constant/links.dart b/siro_driver/lib/constant/links.dart index 31265eef..f56ed51e 100755 --- a/siro_driver/lib/constant/links.dart +++ b/siro_driver/lib/constant/links.dart @@ -681,4 +681,13 @@ class AppLink { // ================= Scheduled Rides ================= static String get scheduledList => "$server/ride/scheduled/list.php"; + + // ================= Driver Insurance ================= + // ملاحظة: addHealthInsuranceProvider/getHealthInsuranceProvider أعلاه هما + // المسار القديم على جدول driver_health_assurance. هذه هي نقاط الخطط + // والأقساط الجديدة، وتعمل على جداول insurance_* المنفصلة. + static String get insurancePlans => "$server/driver_assurance/plans.php"; + static String get insuranceSubscribe => + "$server/driver_assurance/subscribe.php"; + static String get insuranceCancel => "$server/driver_assurance/cancel.php"; } diff --git a/siro_driver/lib/controller/insurance/driver_insurance_controller.dart b/siro_driver/lib/controller/insurance/driver_insurance_controller.dart new file mode 100644 index 00000000..a0a970bc --- /dev/null +++ b/siro_driver/lib/controller/insurance/driver_insurance_controller.dart @@ -0,0 +1,156 @@ +import 'dart:convert'; +import 'package:get/get.dart'; + +import '../../constant/links.dart'; +import '../../views/widgets/error_snakbar.dart'; +import '../functions/crud.dart'; + +/// حالة تأمين السائق: الوثيقة النشطة، ودفتر الأقساط، والخطط المتاحة. +/// +/// الخطط والحالة نداءان منفصلان في الخادم (plans.php و get.php)، لكنهما +/// شاشة واحدة عند السائق — فنجلبهما معاً ونعرض ما اكتمل. فشل أحدهما لا +/// يُفرغ الشاشة من الآخر. +class DriverInsuranceController extends GetxController { + final CRUD _crud = CRUD(); + + final isLoading = false.obs; + final isSubmitting = false.obs; + + /// الوثيقة النشطة، أو null إن لم يكن مشتركاً. + final policy = Rxn>(); + + /// ملخّص الدفتر: المستحق والمسدَّد. + final ledger = {}.obs; + + /// آخر القيود — السائق الذي يُخصم من أرباحه يجب أن يرى مقابل ماذا. + final entries = >[].obs; + + /// كل الخطط، لا المؤهَّل لها فقط: إخفاء ما لا يستحقه بعد يجعل التأمين + /// مجهولاً عنده، وإظهاره مع «ينقصك كذا رحلة» يحوّله إلى هدف. + final plans = >[].obs; + + @override + void onInit() { + super.onInit(); + refreshAll(); + } + + Future refreshAll() async { + isLoading.value = true; + await Future.wait([_fetchStatus(), _fetchPlans()]); + isLoading.value = false; + } + + Future _fetchStatus() async { + try { + final data = _extract(await _crud.post( + link: AppLink.getHealthInsuranceProvider, + payload: const {}, + )); + if (data == null) return; + + policy.value = data['policy'] is Map + ? Map.from(data['policy'] as Map) + : null; + ledger.assignAll(data['ledger'] is Map + ? Map.from(data['ledger'] as Map) + : {}); + entries.assignAll(((data['entries'] as List?) ?? []) + .map((e) => Map.from(e as Map)) + .toList()); + } catch (_) { + mySnackbarError('Failed to load insurance status'.tr); + } + } + + Future _fetchPlans() async { + try { + final data = _extract(await _crud.post( + link: AppLink.insurancePlans, + payload: const {}, + )); + if (data == null) return; + + plans.assignAll(((data['plans'] as List?) ?? []) + .map((e) => Map.from(e as Map)) + .toList()); + } catch (_) { + mySnackbarError('Failed to load insurance plans'.tr); + } + } + + /// يشترك في خطة. الخادم يعيد فحص الأهلية بنفسه — الشاشة قد تكون + /// مفتوحة منذ ساعة، فما عرضناه ليس حجة. + Future subscribe(int planId) async { + if (isSubmitting.value) return false; + isSubmitting.value = true; + + try { + final res = await _crud.post( + link: AppLink.insuranceSubscribe, + payload: {'plan_id': planId.toString()}, + ); + final decoded = res is String ? jsonDecode(res) : res; + + if (decoded is Map && decoded['status'] == 'success') { + await refreshAll(); + return true; + } + + // رسالة الخادم تحمل سبب الرفض بالتفصيل (ما ينقص السائق تحديداً)، + // فنعرضها كما هي بدل رسالة عامة لا تدلّه على شيء. + mySnackbarError(_errorOf(decoded) ?? 'Subscription failed'.tr); + return false; + } catch (_) { + mySnackbarError('Subscription failed'.tr); + return false; + } finally { + isSubmitting.value = false; + } + } + + Future cancel({String reason = ''}) async { + if (isSubmitting.value) return false; + isSubmitting.value = true; + + try { + final res = await _crud.post( + link: AppLink.insuranceCancel, + payload: {'reason': reason}, + ); + final decoded = res is String ? jsonDecode(res) : res; + + if (decoded is Map && decoded['status'] == 'success') { + await refreshAll(); + return true; + } + mySnackbarError(_errorOf(decoded) ?? 'Cancellation failed'.tr); + return false; + } catch (_) { + mySnackbarError('Cancellation failed'.tr); + return false; + } finally { + isSubmitting.value = false; + } + } + + bool get hasPolicy => policy.value != null; + + double get pendingTotal => + double.tryParse('${ledger['pending_total'] ?? 0}') ?? 0; + + String get currency => '${ledger['currency'] ?? policy.value?['currency'] ?? ''}'; + + Map? _extract(dynamic res) { + final decoded = res is String ? jsonDecode(res) : res; + if (decoded is! Map || decoded['status'] != 'success') return null; + final data = decoded['message'] ?? decoded['data']; + return data is Map ? Map.from(data) : {}; + } + + String? _errorOf(dynamic decoded) { + if (decoded is! Map) return null; + final m = decoded['message'] ?? decoded['error']; + return m is String && m.isNotEmpty ? m : null; + } +} diff --git a/siro_driver/lib/translations_ar.json b/siro_driver/lib/translations_ar.json index 07a91a03..ba09fb19 100644 --- a/siro_driver/lib/translations_ar.json +++ b/siro_driver/lib/translations_ar.json @@ -949,5 +949,24 @@ "Destination": "وجهة الوصول", "Failed to fetch scheduled rides": "تعذّر جلب الرحلات المجدولة", "Passenger offline — call them": "راكب بلا إنترنت — اتصل به", - "No internet — call": "بلا إنترنت — اتصل" + "No internet — call": "بلا إنترنت — اتصل", + "Insurance": "التأمين", + "Premium": "القسط", + "Monthly": "شهري", + "Daily": "يومي", + "Started on": "بدأت في", + "Policy number": "رقم الوثيقة", + "Premium ledger": "دفتر الأقساط", + "Outstanding": "المستحق", + "Settled": "المسدَّد", + "Cancel policy": "إلغاء الوثيقة", + "Subscribe": "اشترك", + "Not eligible yet": "غير مؤهَّل بعد", + "rides": "رحلة", + "No insurance plans available yet": "لا توجد خطط تأمين متاحة بعد", + "Failed to load insurance status": "تعذّر تحميل حالة التأمين", + "Failed to load insurance plans": "تعذّر تحميل خطط التأمين", + "Subscription failed": "تعذّر الاشتراك", + "Cancellation failed": "تعذّر الإلغاء", + "Cancelling stops future premiums. Outstanding dues remain payable.": "الإلغاء يوقف الأقساط القادمة، ويبقى المستحق السابق واجب السداد." } diff --git a/siro_driver/lib/translations_en.json b/siro_driver/lib/translations_en.json index a42601d7..8f1bcd83 100644 --- a/siro_driver/lib/translations_en.json +++ b/siro_driver/lib/translations_en.json @@ -907,5 +907,24 @@ "Destination": "Destination", "Failed to fetch scheduled rides": "Failed to fetch scheduled rides", "Passenger offline — call them": "Passenger offline — call them", - "No internet — call": "No internet — call" + "No internet — call": "No internet — call", + "Insurance": "Insurance", + "Premium": "Premium", + "Monthly": "Monthly", + "Daily": "Daily", + "Started on": "Started on", + "Policy number": "Policy number", + "Premium ledger": "Premium ledger", + "Outstanding": "Outstanding", + "Settled": "Settled", + "Cancel policy": "Cancel policy", + "Subscribe": "Subscribe", + "Not eligible yet": "Not eligible yet", + "rides": "rides", + "No insurance plans available yet": "No insurance plans available yet", + "Failed to load insurance status": "Failed to load insurance status", + "Failed to load insurance plans": "Failed to load insurance plans", + "Subscription failed": "Subscription failed", + "Cancellation failed": "Cancellation failed", + "Cancelling stops future premiums. Outstanding dues remain payable.": "Cancelling stops future premiums. Outstanding dues remain payable." } diff --git a/siro_driver/lib/views/home/Captin/home_captain/drawer_captain.dart b/siro_driver/lib/views/home/Captin/home_captain/drawer_captain.dart index ba68d471..4cb844a4 100755 --- a/siro_driver/lib/views/home/Captin/home_captain/drawer_captain.dart +++ b/siro_driver/lib/views/home/Captin/home_captain/drawer_captain.dart @@ -29,6 +29,7 @@ import '../../../../constant/colors.dart'; import '../About Us/video_page.dart'; import '../../../food_delivery/food_delivery_home_page.dart'; import '../../journal/driver_scheduled_rides_page.dart'; +import '../../journal/driver_insurance_page.dart'; // 1. إنشاء Class لتعريف بيانات كل عنصر في القائمة class DrawerItem { @@ -87,6 +88,11 @@ class AppDrawer extends StatelessWidget { icon: Icons.edit_calendar_rounded, color: Colors.orangeAccent, onTap: () => Get.to(() => const DriverScheduledRidesPage())), + DrawerItem( + title: 'Insurance'.tr, + icon: Icons.health_and_safety_rounded, + color: Colors.indigo, + onTap: () => Get.to(() => const DriverInsurancePage())), DrawerItem( title: 'Leaderboard'.tr, icon: Icons.leaderboard_rounded, diff --git a/siro_driver/lib/views/home/journal/driver_insurance_page.dart b/siro_driver/lib/views/home/journal/driver_insurance_page.dart new file mode 100644 index 00000000..2b04c3f2 --- /dev/null +++ b/siro_driver/lib/views/home/journal/driver_insurance_page.dart @@ -0,0 +1,285 @@ +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; + +import '../../../constant/colors.dart'; +import '../../../controller/insurance/driver_insurance_controller.dart'; + +/// شاشة التأمين: الوثيقة النشطة ودفترها، أو الخطط المتاحة. +/// +/// الحالتان لا تظهران معاً — من له وثيقة يرى وثيقته، ومن لا وثيقة له +/// يرى الخطط. عرض الاثنين كان سيوحي أن الاشتراك في ثانية ممكن، وهو +/// ليس كذلك: تبديل الخطة يمرّ بإلغاء صريح ليبقى في السجل أثر. +class DriverInsurancePage extends StatelessWidget { + const DriverInsurancePage({super.key}); + + @override + Widget build(BuildContext context) { + final c = Get.put(DriverInsuranceController()); + + return Scaffold( + appBar: AppBar(title: Text('Insurance'.tr), centerTitle: true), + body: Obx(() { + if (c.isLoading.value) { + return const Center(child: CircularProgressIndicator()); + } + + return RefreshIndicator( + onRefresh: c.refreshAll, + child: ListView( + padding: const EdgeInsets.all(16), + children: c.hasPolicy ? _policyView(context, c) : _plansView(context, c), + ), + ); + }), + ); + } + + // ── الوثيقة النشطة ───────────────────────────────────────── + List _policyView(BuildContext context, DriverInsuranceController c) { + final p = c.policy.value!; + + return [ + Card( + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)), + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + const Icon(Icons.verified_user, color: Colors.green), + const SizedBox(width: 8), + Expanded( + child: Text( + '${p['plan'] ?? ''}', + style: const TextStyle( + fontSize: 18, fontWeight: FontWeight.bold), + ), + ), + ], + ), + const SizedBox(height: 4), + Text('${p['provider'] ?? ''}', + style: TextStyle(color: Colors.grey.shade600)), + const Divider(height: 24), + _row('Premium'.tr, + '${p['premium']} ${p['currency']} / ' + '${p['billing_cycle'] == 'monthly' ? 'Monthly'.tr : 'Daily'.tr}'), + _row('Started on'.tr, '${p['started_at'] ?? '—'}'), + if ((p['policy_number'] ?? '').toString().isNotEmpty) + _row('Policy number'.tr, '${p['policy_number']}'), + if ((p['coverage'] ?? '').toString().isNotEmpty) ...[ + const SizedBox(height: 12), + Text('${p['coverage']}', + style: TextStyle(color: Colors.grey.shade700)), + ], + ], + ), + ), + ), + const SizedBox(height: 16), + + // ── دفتر الأقساط ── + // يظهر في نفس الشاشة لا في أخرى: الخصم الذي لا يرى السائق مقابله + // مفاجأة تُفقد الثقة، مهما كان مشروحاً في مكان آخر. + Card( + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)), + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text('Premium ledger'.tr, + style: const TextStyle( + fontSize: 16, fontWeight: FontWeight.bold)), + const SizedBox(height: 12), + _row('Outstanding'.tr, + '${c.pendingTotal.toStringAsFixed(2)} ${c.currency}'), + _row('Settled'.tr, + '${ledgerNum(c.ledger['settled_total'])} ${c.currency}'), + if (c.entries.isNotEmpty) ...[ + const Divider(height: 24), + ...c.entries.take(10).map((e) => Padding( + padding: const EdgeInsets.symmetric(vertical: 4), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text('${e['charge_date']}', + style: TextStyle(color: Colors.grey.shade700)), + Text('${e['amount']} ${e['currency']}', + style: TextStyle( + color: e['status'] == 'settled' + ? Colors.green + : Colors.orange.shade800, + fontWeight: FontWeight.w600, + )), + ], + ), + )), + ], + ], + ), + ), + ), + const SizedBox(height: 24), + + OutlinedButton.icon( + onPressed: c.isSubmitting.value ? null : () => _confirmCancel(context, c), + icon: const Icon(Icons.cancel_outlined, color: Colors.red), + label: Text('Cancel policy'.tr, + style: const TextStyle(color: Colors.red)), + style: OutlinedButton.styleFrom( + side: const BorderSide(color: Colors.red), + minimumSize: const Size.fromHeight(48), + ), + ), + ]; + } + + // ── الخطط المتاحة ────────────────────────────────────────── + List _plansView(BuildContext context, DriverInsuranceController c) { + if (c.plans.isEmpty) { + return [ + const SizedBox(height: 80), + Icon(Icons.health_and_safety_outlined, + size: 80, color: Colors.grey.shade400), + const SizedBox(height: 16), + Text('No insurance plans available yet'.tr, + textAlign: TextAlign.center, + style: const TextStyle(fontSize: 18, color: Colors.grey)), + ]; + } + + return c.plans.map((plan) { + final eligible = plan['eligible'] == true; + final missing = (plan['missing'] as List?) ?? []; + final progress = (plan['progress_rides'] ?? 0) as num; + final required = (plan['required_rides'] ?? 0) as num; + + return Card( + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)), + margin: const EdgeInsets.only(bottom: 16), + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text('${plan['name'] ?? ''}', + style: const TextStyle( + fontSize: 18, fontWeight: FontWeight.bold)), + Text('${plan['provider'] ?? ''}', + style: TextStyle(color: Colors.grey.shade600)), + const SizedBox(height: 8), + Text( + '${plan['premium']} ${plan['currency']} / ' + '${plan['billing_cycle'] == 'monthly' ? 'Monthly'.tr : 'Daily'.tr}', + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.bold, + color: AppColor.primaryColor), + ), + if ((plan['coverage'] ?? '').toString().isNotEmpty) ...[ + const SizedBox(height: 8), + Text('${plan['coverage']}', + style: TextStyle(color: Colors.grey.shade700)), + ], + + // شريط التقدّم نحو الشرط — الرقم وحده لا يحرّك أحداً، + // ورؤية القرب من الهدف تفعل. + if (!eligible && required > 0) ...[ + const SizedBox(height: 12), + LinearProgressIndicator( + value: required == 0 ? 0 : (progress / required).clamp(0, 1), + backgroundColor: Colors.grey.shade300, + ), + const SizedBox(height: 6), + Text('$progress / $required ${'rides'.tr}', + style: TextStyle( + fontSize: 12, color: Colors.grey.shade600)), + ], + + if (!eligible && missing.isNotEmpty) ...[ + const SizedBox(height: 8), + ...missing.map((m) => Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Icon(Icons.lock_outline, + size: 14, color: Colors.orange), + const SizedBox(width: 6), + Expanded( + child: Text('$m', + style: TextStyle( + fontSize: 12, + color: Colors.orange.shade900)), + ), + ], + )), + ], + + const SizedBox(height: 12), + ElevatedButton( + onPressed: (!eligible || c.isSubmitting.value) + ? null + : () => c.subscribe(plan['id'] as int), + style: ElevatedButton.styleFrom( + backgroundColor: AppColor.primaryColor, + foregroundColor: Colors.white, + minimumSize: const Size.fromHeight(44), + ), + child: Text(eligible ? 'Subscribe'.tr : 'Not eligible yet'.tr), + ), + ], + ), + ), + ); + }).toList(); + } + + void _confirmCancel(BuildContext context, DriverInsuranceController c) { + showDialog( + context: context, + builder: (ctx) => AlertDialog( + title: Text('Cancel policy'.tr), + // نصرّح بأن المستحق السابق يبقى: الإلغاء يوقف القادم ولا يمحو + // أياماً استُفيد من تغطيتها فعلاً. + content: Text(c.pendingTotal > 0 + ? '${'Cancelling stops future premiums. Outstanding dues remain payable.'.tr}\n\n' + '${'Outstanding'.tr}: ${c.pendingTotal.toStringAsFixed(2)} ${c.currency}' + : 'Cancelling stops future premiums. Outstanding dues remain payable.'.tr), + actions: [ + TextButton( + onPressed: () => Navigator.pop(ctx), + child: Text('Back'.tr), + ), + TextButton( + onPressed: () { + Navigator.pop(ctx); + c.cancel(); + }, + child: Text('Confirm'.tr, + style: const TextStyle(color: Colors.red)), + ), + ], + ), + ); + } + + static String ledgerNum(dynamic v) => + (double.tryParse('${v ?? 0}') ?? 0).toStringAsFixed(2); + + Widget _row(String label, String value) => Padding( + padding: const EdgeInsets.symmetric(vertical: 4), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text(label, style: TextStyle(color: Colors.grey.shade700)), + Flexible( + child: Text(value, + textAlign: TextAlign.end, + style: const TextStyle(fontWeight: FontWeight.w600)), + ), + ], + ), + ); +} diff --git a/sms_relay/app/build.gradle.kts b/sms_relay/app/build.gradle.kts new file mode 100644 index 00000000..3aad92a6 --- /dev/null +++ b/sms_relay/app/build.gradle.kts @@ -0,0 +1,64 @@ +plugins { + alias(libs.plugins.android.application) + alias(libs.plugins.kotlin.android) + alias(libs.plugins.kotlin.compose) +} + +android { + namespace = "com.siro.relay" + compileSdk = 36 + + defaultConfig { + applicationId = "com.siro.relay" + // ‏24 لا أعلى: الهاتف الذي يحمل الشريحة قد يكون جهازاً رخيصاً + // ‏قديماً مركوناً في المكتب، لا هاتفاً حديثاً. + minSdk = 24 + targetSdk = 36 + versionCode = 1 + versionName = "1.0" + + testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + + // ‏نفس مفتاح WEBHOOK_SECRET_KEY في backend — الويب‌هوك ونقطة + // ‏السحب كلاهما يتحقق منه. غيّره هنا وهناك معاً أو يتوقف كل شيء. + buildConfigField("String", "RELAY_SECRET", "\"CHANGE_ME_WEBHOOK_SECRET\"") + buildConfigField("String", "BACKEND_HOST", "\"https://jordan-siro.intaleqapp.com\"") + + // ‏معرّف الجهاز: يظهر في `claimed_by` بجدول sms_outbox. لو شغّلت + // ‏هاتفين بشريحتين، غيّره في الثاني لتعرف أيّهما أرسل ماذا. + buildConfigField("String", "DEVICE_ID", "\"phone-1\"") + } + + buildTypes { + release { + isMinifyEnabled = false + proguardFiles( + getDefaultProguardFile("proguard-android-optimize.txt"), + "proguard-rules.pro" + ) + } + } + compileOptions { + sourceCompatibility = JavaVersion.VERSION_11 + targetCompatibility = JavaVersion.VERSION_11 + } + kotlinOptions { + jvmTarget = "11" + } + buildFeatures { + compose = true + buildConfig = true + } +} + +dependencies { + implementation(libs.androidx.core.ktx) + implementation(libs.androidx.lifecycle.runtime.ktx) + implementation(libs.androidx.activity.compose) + implementation(platform(libs.androidx.compose.bom)) + implementation(libs.androidx.compose.ui) + implementation(libs.androidx.compose.ui.graphics) + implementation(libs.androidx.compose.ui.tooling.preview) + implementation(libs.androidx.compose.material3) + debugImplementation(libs.androidx.compose.ui.tooling) +} diff --git a/sms_relay/app/proguard-rules.pro b/sms_relay/app/proguard-rules.pro new file mode 100644 index 00000000..481bb434 --- /dev/null +++ b/sms_relay/app/proguard-rules.pro @@ -0,0 +1,21 @@ +# Add project specific ProGuard rules here. +# You can control the set of applied configuration files using the +# proguardFiles setting in build.gradle. +# +# For more details, see +# http://developer.android.com/guide/developing/tools/proguard.html + +# If your project uses WebView with JS, uncomment the following +# and specify the fully qualified class name to the JavaScript interface +# class: +#-keepclassmembers class fqcn.of.javascript.interface.for.webview { +# public *; +#} + +# Uncomment this to preserve the line number information for +# debugging stack traces. +#-keepattributes SourceFile,LineNumberTable + +# If you keep the line number information, uncomment this to +# hide the original source file name. +#-renamesourcefileattribute SourceFile \ No newline at end of file diff --git a/sms_relay/app/src/main/AndroidManifest.xml b/sms_relay/app/src/main/AndroidManifest.xml new file mode 100644 index 00000000..500f24af --- /dev/null +++ b/sms_relay/app/src/main/AndroidManifest.xml @@ -0,0 +1,67 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/sms_relay/app/src/main/java/com/siro/relay/Api.kt b/sms_relay/app/src/main/java/com/siro/relay/Api.kt new file mode 100644 index 00000000..4a7f3d04 --- /dev/null +++ b/sms_relay/app/src/main/java/com/siro/relay/Api.kt @@ -0,0 +1,130 @@ +package com.siro.relay + +import android.util.Log +import org.json.JSONArray +import org.json.JSONObject +import java.net.HttpURLConnection +import java.net.URL + +/** + * ‏عميل HTTP بسيط لنقطتي الباك إند. + * + * ‏HttpURLConnection لا OkHttp عمداً: التطبيق يقوم بنداءين اثنين لا أكثر، + * ‏وإضافة مكتبة شبكة كاملة من أجلهما تضخّم حزمةً ستعمل على هاتف قديم + * ‏مركون بلا صيانة. أقل ما يمكن كسره هو ما لم يُضَف أصلاً. + */ +object Api { + + private const val TAG = "SiroRelay/Api" + + private val WEBHOOK_URL = "${BuildConfig.BACKEND_HOST}/webhook_sms/webhook.php" + private val OUTBOX_URL = "${BuildConfig.BACKEND_HOST}/sms/outbox_pull.php" + + /** رسالة صادرة كما وصلت من الطابور. */ + data class Outgoing(val id: Int, val recipient: String, val body: String) + + /** + * ‏يرفع رسالة واردة إلى الويب‌هوك. + * + * @return ‏true عند النجاح — المتصل يعيد المحاولة عند الفشل. + */ + fun pushIncoming(sender: String, message: String): Boolean { + val payload = JSONObject().apply { + put("sender", sender) + put("message", message) + } + + return try { + val (code, _) = post(WEBHOOK_URL, payload.toString()) + if (code != 200) Log.w(TAG, "الويب‌هوك رد بـ $code") + code == 200 + } catch (e: Exception) { + Log.e(TAG, "تعذّر رفع الوارد: ${e.message}") + false + } + } + + /** ‏يسحب دفعة رسائل للإرسال. قائمة فارغة تعني لا شيء ينتظر. */ + fun pullOutgoing(): List { + val payload = JSONObject().apply { + put("action", "pull") + put("device", BuildConfig.DEVICE_ID) + } + + return try { + val (code, body) = post(OUTBOX_URL, payload.toString()) + if (code != 200) { + Log.w(TAG, "السحب رد بـ $code") + return emptyList() + } + + val arr = JSONObject(body).optJSONArray("messages") ?: return emptyList() + (0 until arr.length()).mapNotNull { i -> + val o = arr.optJSONObject(i) ?: return@mapNotNull null + val to = o.optString("recipient") + val text = o.optString("body") + // ‏صف ناقص لا يُرسل ولا يُسقط الدفعة: نتخطاه ويبقى في + // ‏الطابور حتى تنتهي مهلة الحجز فيُحرَّر ويُراجَع. + if (to.isBlank() || text.isBlank()) null + else Outgoing(o.optInt("id"), to, text) + } + } catch (e: Exception) { + Log.e(TAG, "تعذّر السحب: ${e.message}") + emptyList() + } + } + + /** + * ‏يبلّغ الخادم بنتائج الإرسال. + * + * ‏التبليغ ليس تفصيلاً: الرسالة تبقى محجوزة في الخادم حتى يصله، وبلا + * ‏تبليغ تُحرَّر بعد المهلة وتُرسل ثانيةً — أي رسالتان للراكب نفسه. + */ + fun ack(results: List>): Boolean { + if (results.isEmpty()) return true + + val arr = JSONArray() + results.forEach { (id, sent, error) -> + arr.put(JSONObject().apply { + put("id", id) + put("sent", sent) + if (error != null) put("error", error) + }) + } + + val payload = JSONObject().apply { + put("action", "ack") + put("device", BuildConfig.DEVICE_ID) + put("results", arr) + } + + return try { + val (code, _) = post(OUTBOX_URL, payload.toString()) + code == 200 + } catch (e: Exception) { + Log.e(TAG, "تعذّر التبليغ: ${e.message}") + false + } + } + + private fun post(url: String, json: String): Pair { + val conn = (URL(url).openConnection() as HttpURLConnection).apply { + requestMethod = "POST" + doOutput = true + connectTimeout = 10_000 + readTimeout = 20_000 + setRequestProperty("Content-Type", "application/json; charset=utf-8") + setRequestProperty("Authorization", "Bearer ${BuildConfig.RELAY_SECRET}") + } + + return try { + conn.outputStream.use { it.write(json.toByteArray(Charsets.UTF_8)) } + val code = conn.responseCode + val stream = if (code in 200..299) conn.inputStream else conn.errorStream + val body = stream?.bufferedReader(Charsets.UTF_8)?.use { it.readText() } ?: "" + code to body + } finally { + conn.disconnect() + } + } +} diff --git a/sms_relay/app/src/main/java/com/siro/relay/BootReceiver.kt b/sms_relay/app/src/main/java/com/siro/relay/BootReceiver.kt new file mode 100644 index 00000000..82241f61 --- /dev/null +++ b/sms_relay/app/src/main/java/com/siro/relay/BootReceiver.kt @@ -0,0 +1,20 @@ +package com.siro.relay + +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent + +/** + * ‏يعيد تشغيل الخدمة بعد إقلاع الجهاز. + * + * ‏ليس ترفاً: الهاتف الحامل للشريحة سيبقى مركوناً في المكتب، وانقطاع + * ‏كهرباء واحد يعيد تشغيله. بلا هذا المستقبِل تبقى القناة ميتة حتى + * ‏ينتبه أحد ويفتح التطبيق يدوياً — وقد لا ينتبه أحد لأيام. + */ +class BootReceiver : BroadcastReceiver() { + override fun onReceive(context: Context, intent: Intent) { + if (intent.action == Intent.ACTION_BOOT_COMPLETED) { + RelayService.start(context) + } + } +} diff --git a/sms_relay/app/src/main/java/com/siro/relay/MainActivity.kt b/sms_relay/app/src/main/java/com/siro/relay/MainActivity.kt new file mode 100644 index 00000000..26213de8 --- /dev/null +++ b/sms_relay/app/src/main/java/com/siro/relay/MainActivity.kt @@ -0,0 +1,98 @@ +package com.siro.relay + +import android.Manifest +import android.content.pm.PackageManager +import android.os.Build +import android.os.Bundle +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.foundation.layout.* +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.core.content.ContextCompat + +/** + * ‏شاشة واحدة: تطلب الصلاحيات وتشغّل الخدمة. + * + * ‏لا لوحة إعدادات ولا سجل رسائل عمداً. هذا الجهاز يعمل بلا مشرف، + * ‏وكل زر إضافي فيه احتمال أن يعبث به أحد فيوقف القناة. الإعدادات + * ‏كلها في BuildConfig — تتغيّر ببناء جديد لا بلمسة على الشاشة. + */ +class MainActivity : ComponentActivity() { + + private val required = buildList { + add(Manifest.permission.RECEIVE_SMS) + add(Manifest.permission.READ_SMS) + add(Manifest.permission.SEND_SMS) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + add(Manifest.permission.POST_NOTIFICATIONS) + } + } + + private var granted by mutableStateOf(false) + + private val requestPermissions = registerForActivityResult( + ActivityResultContracts.RequestMultiplePermissions() + ) { result -> + // ‏الإشعارات وحدها ليست حاسمة — رفضها يخفي الإشعار الدائم فقط. + // ‏أمّا صلاحيات الرسائل فبدونها التطبيق بلا وظيفة. + granted = result.filterKeys { it != Manifest.permission.POST_NOTIFICATIONS } + .all { it.value } + if (granted) RelayService.start(this) + } + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + granted = required.all { + ContextCompat.checkSelfPermission(this, it) == PackageManager.PERMISSION_GRANTED + } + if (granted) RelayService.start(this) + + setContent { + MaterialTheme { + Surface(modifier = Modifier.fillMaxSize()) { + Column( + modifier = Modifier.fillMaxSize().padding(24.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center + ) { + Text( + text = getString(R.string.app_name), + style = MaterialTheme.typography.headlineMedium + ) + Spacer(Modifier.height(12.dp)) + Text( + text = getString( + if (granted) R.string.status_running else R.string.status_needs_permission + ), + textAlign = TextAlign.Center, + style = MaterialTheme.typography.bodyLarge + ) + Spacer(Modifier.height(24.dp)) + + if (!granted) { + Button(onClick = { requestPermissions.launch(required.toTypedArray()) }) { + Text(getString(R.string.grant_permissions)) + } + } else { + Text( + text = BuildConfig.BACKEND_HOST, + style = MaterialTheme.typography.bodySmall + ) + Text( + text = "${getString(R.string.device_label)}: ${BuildConfig.DEVICE_ID}", + style = MaterialTheme.typography.bodySmall + ) + } + } + } + } + } + } +} diff --git a/sms_relay/app/src/main/java/com/siro/relay/RelayService.kt b/sms_relay/app/src/main/java/com/siro/relay/RelayService.kt new file mode 100644 index 00000000..8b89e8bc --- /dev/null +++ b/sms_relay/app/src/main/java/com/siro/relay/RelayService.kt @@ -0,0 +1,184 @@ +package com.siro.relay + +import android.app.Notification +import android.app.NotificationChannel +import android.app.NotificationManager +import android.app.PendingIntent +import android.app.Service +import android.content.Context +import android.content.Intent +import android.os.Build +import android.os.IBinder +import android.telephony.SmsManager +import android.util.Log +import java.util.concurrent.atomic.AtomicBoolean +import kotlin.concurrent.thread + +/** + * ‏خدمة أمامية تسحب الردود من الخادم وترسلها عبر شريحة الهاتف. + * + * ‏لماذا سحب لا دفع: الهاتف خلف شبكة الجوّال بلا عنوان ثابت ولا منفذ + * ‏مفتوح، فلا سبيل للخادم أن يبادره. والسحب يجعل انقطاع الهاتف حالة + * ‏طبيعية — تتراكم الرسائل في الطابور وتُرسل حين يعود، بلا فقدان. + * + * ‏ولماذا أمامية: النظام يقتل أي عمل دوري في الخلفية خلال دقائق بعد + * ‏إطفاء الشاشة. الإشعار الدائم ثمن مقبول مقابل قناة يُفترض ألّا تنام. + */ +class RelayService : Service() { + + companion object { + private const val TAG = "SiroRelay/Service" + private const val CHANNEL_ID = "siro_relay" + private const val NOTIF_ID = 4210 + + private const val ACTION_POLL_NOW = "com.siro.relay.POLL_NOW" + + /** ‏دورة السحب الاعتيادية. */ + private const val POLL_INTERVAL_MS = 5_000L + + /** ‏تباعد بين رسالتين متتاليتين — الشريحة لا تحب الدفعات السريعة. */ + private const val SEND_GAP_MS = 1_500L + + fun start(context: Context) { + val intent = Intent(context, RelayService::class.java) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + context.startForegroundService(intent) + } else { + context.startService(intent) + } + } + + /** ‏يوقظ دورة سحب فورية بدل انتظار الموقّت. */ + fun requestImmediatePoll(context: Context) { + start(context) + wakeUp.set(true) + } + + private val wakeUp = AtomicBoolean(false) + } + + @Volatile private var running = false + private var worker: Thread? = null + + override fun onBind(intent: Intent?): IBinder? = null + + override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { + startForeground(NOTIF_ID, buildNotification()) + + // ‏استدعاء ثانٍ للخدمة العاملة لا يبدأ خيطاً ثانياً: خيطان يسحبان + // ‏معاً يعني رسائل تُرسل مرتين. + if (!running) { + running = true + worker = thread(name = "relay-loop") { loop() } + } + + // ‏START_STICKY: لو قتلها النظام تحت ضغط ذاكرة، يعيدها. القناة + // ‏يجب أن تعود وحدها — لا أحد بجانب الهاتف ليفتح التطبيق. + return START_STICKY + } + + override fun onDestroy() { + running = false + worker?.interrupt() + super.onDestroy() + } + + private fun loop() { + while (running) { + try { + pumpOnce() + } catch (e: Exception) { + Log.e(TAG, "خطأ في الدورة: ${e.message}") + } + + // ‏نوم مجزّأ ليستجيب للإيقاظ الفوري بدل انتظار الدورة كاملة. + var slept = 0L + while (running && slept < POLL_INTERVAL_MS) { + if (wakeUp.compareAndSet(true, false)) break + try { + Thread.sleep(250) + } catch (e: InterruptedException) { + return + } + slept += 250 + } + } + } + + private fun pumpOnce() { + val batch = Api.pullOutgoing() + if (batch.isEmpty()) return + + Log.i(TAG, "سُحبت ${batch.size} رسالة") + val results = mutableListOf>() + + for (msg in batch) { + val error = sendSms(msg.recipient, msg.body) + results.add(Triple(msg.id, error == null, error)) + + if (msg !== batch.last()) { + try { + Thread.sleep(SEND_GAP_MS) + } catch (e: InterruptedException) { + break + } + } + } + + // ‏نبلّغ عن كل ما أُرسل فعلاً حتى لو انقطعت الدفعة في منتصفها، + // ‏وإلا أُعيد إرسال ما نجح منها بعد انتهاء مهلة الحجز. + Api.ack(results) + } + + /** @return ‏رسالة الخطأ، أو null عند النجاح. */ + private fun sendSms(to: String, body: String): String? { + return try { + val manager = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + getSystemService(SmsManager::class.java) + } else { + @Suppress("DEPRECATION") + SmsManager.getDefault() + } ?: return "SmsManager unavailable" + + // ‏الرسالة العربية تتجاوز حد الجزء الواحد بسرعة (٧٠ حرفاً في + // ‏ترميز UCS-2 لا ١٦٠). التقسيم إجباري لا اختياري. + val parts = manager.divideMessage(body) + if (parts.size > 1) { + manager.sendMultipartTextMessage(to, null, parts, null, null) + } else { + manager.sendTextMessage(to, null, body, null, null) + } + null + } catch (e: Exception) { + Log.e(TAG, "تعذّر الإرسال إلى $to: ${e.message}") + e.message ?: "send failed" + } + } + + private fun buildNotification(): Notification { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + val channel = NotificationChannel( + CHANNEL_ID, + getString(R.string.channel_name), + // ‏أدنى أهمية: إشعار دائم لا ينبغي أن يصدر صوتاً ولا أن + // ‏يقفز فوق الشاشة كل مرة. + NotificationManager.IMPORTANCE_MIN + ) + getSystemService(NotificationManager::class.java)?.createNotificationChannel(channel) + } + + val tap = PendingIntent.getActivity( + this, 0, + Intent(this, MainActivity::class.java), + PendingIntent.FLAG_IMMUTABLE + ) + + return Notification.Builder(this, CHANNEL_ID) + .setContentTitle(getString(R.string.notif_title)) + .setContentText(getString(R.string.notif_text)) + .setSmallIcon(android.R.drawable.stat_notify_chat) + .setOngoing(true) + .setContentIntent(tap) + .build() + } +} diff --git a/sms_relay/app/src/main/java/com/siro/relay/SmsReceiver.kt b/sms_relay/app/src/main/java/com/siro/relay/SmsReceiver.kt new file mode 100644 index 00000000..f8adcabf --- /dev/null +++ b/sms_relay/app/src/main/java/com/siro/relay/SmsReceiver.kt @@ -0,0 +1,49 @@ +package com.siro.relay + +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import android.provider.Telephony +import android.util.Log +import kotlin.concurrent.thread + +/** + * ‏يلتقط كل رسالة واردة ويرفعها إلى الويب‌هوك. + * + * ‏لا فرز هنا إطلاقاً: التطبيق يرفع كل شيء، والخادم يقرّر أهي طلب رحلة + * ‏أم إشعار دفع أم شيء ثالث. نقل الفرز إلى الهاتف كان سيعني أن كل تعديل + * ‏على قواعد الفهم يستوجب تحديث تطبيق على جهاز لا أحد يقترب منه. + */ +class SmsReceiver : BroadcastReceiver() { + + override fun onReceive(context: Context, intent: Intent) { + if (intent.action != Telephony.Sms.Intents.SMS_RECEIVED_ACTION) return + + val messages = Telephony.Sms.Intents.getMessagesFromIntent(intent) ?: return + if (messages.isEmpty()) return + + // ‏الرسالة الطويلة تصل مقسّمة إلى أجزاء متتالية من نفس المرسِل. + // ‏رفع كل جزء وحده كان سينتج طلبات مبتورة بلا معنى — فنلمّها + // ‏بالمرسِل ونرفع النص كاملاً. + val bySender = messages + .groupBy { it.displayOriginatingAddress ?: it.originatingAddress ?: "" } + .mapValues { (_, parts) -> parts.joinToString("") { it.displayMessageBody ?: "" } } + + // ‏onReceive يعمل على الخيط الرئيسي وله ثوانٍ معدودة قبل أن يقتله + // ‏النظام. النداء الشبكي في خيط منفصل، ونتحمّل أن يُفقد لو مات + // ‏السياق — المرسِل سيعيد الإرسال حين لا يصله رد. + bySender.forEach { (sender, body) -> + if (sender.isBlank() || body.isBlank()) return@forEach + + thread(name = "relay-in") { + val ok = Api.pushIncoming(sender, body) + Log.i("SiroRelay", if (ok) "رُفعت رسالة من $sender" else "فشل رفع رسالة من $sender") + + // ‏نوقظ خدمة السحب فوراً بدل انتظار الدورة التالية: + // ‏الرد على الراكب جاهز في الطابور خلال أجزاء من الثانية + // ‏بعد الرفع، وانتظاره دورةً كاملة تأخير بلا سبب. + if (ok) RelayService.requestImmediatePoll(context) + } + } + } +} diff --git a/sms_relay/app/src/main/res/drawable/ic_launcher_background.xml b/sms_relay/app/src/main/res/drawable/ic_launcher_background.xml new file mode 100644 index 00000000..07d5da9c --- /dev/null +++ b/sms_relay/app/src/main/res/drawable/ic_launcher_background.xml @@ -0,0 +1,170 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/sms_relay/app/src/main/res/drawable/ic_launcher_foreground.xml b/sms_relay/app/src/main/res/drawable/ic_launcher_foreground.xml new file mode 100644 index 00000000..2b068d11 --- /dev/null +++ b/sms_relay/app/src/main/res/drawable/ic_launcher_foreground.xml @@ -0,0 +1,30 @@ + + + + + + + + + + + \ No newline at end of file diff --git a/sms_relay/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/sms_relay/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml new file mode 100644 index 00000000..6f3b755b --- /dev/null +++ b/sms_relay/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/sms_relay/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml b/sms_relay/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml new file mode 100644 index 00000000..6f3b755b --- /dev/null +++ b/sms_relay/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/sms_relay/app/src/main/res/values/colors.xml b/sms_relay/app/src/main/res/values/colors.xml new file mode 100644 index 00000000..f8c6127d --- /dev/null +++ b/sms_relay/app/src/main/res/values/colors.xml @@ -0,0 +1,10 @@ + + + #FFBB86FC + #FF6200EE + #FF3700B3 + #FF03DAC5 + #FF018786 + #FF000000 + #FFFFFFFF + \ No newline at end of file diff --git a/sms_relay/app/src/main/res/values/strings.xml b/sms_relay/app/src/main/res/values/strings.xml new file mode 100644 index 00000000..68666b42 --- /dev/null +++ b/sms_relay/app/src/main/res/values/strings.xml @@ -0,0 +1,10 @@ + + وسيط سيرو + قناة الرسائل + وسيط سيرو يعمل + يستقبل طلبات الركاب ويرسل الردود + القناة تعمل. اترك الهاتف موصولاً بالكهرباء والشبكة. + يحتاج التطبيق صلاحيات الرسائل ليعمل. + منح الصلاحيات + معرّف الجهاز + diff --git a/sms_relay/app/src/main/res/values/themes.xml b/sms_relay/app/src/main/res/values/themes.xml new file mode 100644 index 00000000..5c13f04d --- /dev/null +++ b/sms_relay/app/src/main/res/values/themes.xml @@ -0,0 +1,4 @@ + + +