إصلاح سوكيت الراكب وتوحيد مسارات الإشعارات

سوكيت الراكب (سبب عدم ظهور معلومات السائق عند القبول):
- تطبيق الراكب كان يرسل id فقط بلا jwt، و passenger_socket.php يرفض أي
  اتصال بلا jwt ⇒ الراكب لا ينضم لغرفته أبداً ولا يستلم ride_status_change
  ولا driver_location_update. تظهر حالة القبول عبر الـ polling فقط بينما
  driver_info يصل بالسوكيت وحده. (سوكيت السائق يعتبر الـ jwt اختيارياً،
  ومن هنا جاء التباين بين التطبيقين.)
- cancelled_by_driver كان يسقط من switch حالات الراكب فيبقى معلّقاً بعد
  إلغاء السائق.
- حماية socket (late) من القراءة قبل التهيئة عند الانسحاب بلا jwt.

الإشعارات والرسائل (سبب "مرات توصل ومرات لا"):
- جدول tokens يخزّن توكن الراكب مشفّراً، و getRideWaiting.php كان يرجعه
  بلا فك تشفير ⇒ من يقبل من قائمة السوق يحمل blob مشفّراً يستخدمه كـ FCM
  target فيرفضه FCM بـ 400: لا إشعار قبول ولا رسائل. ومن يقبل من الـ
  dispatch/FCM يحمل نصاً صريحاً فتعمل. الفرق كان في طريقة القبول.
- acceptRide.php يحلّ التوكن من القاعدة دائماً ولا يثق بالعميل (أصحّ أمنياً).
- market_new_ride كان لا يحمل passengerId ولا الإحداثيات فتصل "null"؛
  أُضيفت بلا أي PII لأن الحمولة تُبَثّ لكل سائق قريب لا للفائز فقط.
- send_fcm.php: مهلة على OAuth (كان يعلّق حتى مهلة PHP فتُسقط الرسالة
  بصمت)، توحيد ding→default لأندرويد، وحقن title/body/tone في data
  مطابقةً لـ FcmService.
- تطبيق السائق يقرأ title/body من data أولاً مثل الراكب، ولا يعرض فقاعة
  فارغة للرسائل الصامتة.

السوكيت والإعدادات:
- forwardLocationToPassengerSocket كان يقرأ lat/lng والحمولة فيها
  latitude/longitude ⇒ المسافة تخرج ضخمة والـ throttle معطّل تماماً
  فيُعاد التوجيه مع كل نبضة GPS.
- notifyPassengerOnRideServer كان يرجع null بصمت مطلق عند حجب العنوان.
- العنوان الافتراضي لسيرفر الموقع كان nginx/loction_server/driver_socket.php
  وهو ديمون Workerman لا يُخدَم عبر nginx ⇒ صار socket_driver:2021.
  (LOCATION_API_URL بقي على nginx لأن api_get_nearby.php سكربت عادي.)
- ride_server/passenger_socket.php (النسخة التي يشغّلها Docker) كانت ناقصة
  كل كود مواصلاتي الموجود في passenger_server/ ⇒ نُقل مع REDIS_HOST.
- .env.example: ALLOWED_SOCKET_URLS يغطّي أسماء حاويات Docker، وإضافة
  PASSENGER_SOCKET_INTERNAL_URL.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Hamza-Ayed
2026-07-26 15:47:23 +03:00
co-authored by Claude Opus 5
parent e269cb9b70
commit 143146c1b4
11 changed files with 258 additions and 39 deletions
+11 -5
View File
@@ -59,7 +59,7 @@ function isAllowedSocketUrl(string $url): bool {
} }
function sendToLocationServer($action, $data) { function sendToLocationServer($action, $data) {
$url = getenv('LOCATION_SERVER_URL') ?: 'http://nginx/loction_server/driver_socket.php'; $url = getenv('LOCATION_SERVER_URL') ?: 'http://socket_driver:2021';
if (!isAllowedSocketUrl($url)) { if (!isAllowedSocketUrl($url)) {
error_log("[SSRF_BLOCKED] Attempted connection to: $url"); error_log("[SSRF_BLOCKED] Attempted connection to: $url");
return; return;
@@ -258,7 +258,7 @@ function getDistanceBetweenPoints($lat1, $lon1, $lat2, $lon2) {
} }
// --- دالة مساعدة لمخاطبة سيرفر السائقين (Location Socket) --- // --- دالة مساعدة لمخاطبة سيرفر السائقين (Location Socket) ---
function notifyDriversRideTaken($rideId, $winnerDriverId) { function notifyDriversRideTaken($rideId, $winnerDriverId) {
$url = getenv('LOCATION_SERVER_URL') ?: 'http://nginx/loction_server/driver_socket.php'; $url = getenv('LOCATION_SERVER_URL') ?: 'http://socket_driver:2021';
if (!isAllowedSocketUrl($url)) return; if (!isAllowedSocketUrl($url)) return;
$INTERNAL_KEY = function_exists('getInternalSocketKey') ? getInternalSocketKey() : ''; $INTERNAL_KEY = function_exists('getInternalSocketKey') ? getInternalSocketKey() : '';
@@ -280,7 +280,7 @@ function notifyDriversRideTaken($rideId, $winnerDriverId) {
curl_close($ch); curl_close($ch);
} }
function notifyDriversOnLocationServer($drivers_ids_array, $payload, $rideId = null) { function notifyDriversOnLocationServer($drivers_ids_array, $payload, $rideId = null) {
$url = getenv('LOCATION_SERVER_URL') ?: 'http://nginx/loction_server/driver_socket.php'; $url = getenv('LOCATION_SERVER_URL') ?: 'http://socket_driver:2021';
if (!isAllowedSocketUrl($url)) return null; if (!isAllowedSocketUrl($url)) return null;
$INTERNAL_KEY = function_exists('getInternalSocketKey') ? getInternalSocketKey() : ''; $INTERNAL_KEY = function_exists('getInternalSocketKey') ? getInternalSocketKey() : '';
@@ -317,7 +317,13 @@ function notifyDriversOnLocationServer($drivers_ids_array, $payload, $rideId = n
*/ */
function notifyPassengerOnRideServer($passenger_id, $payload) { function notifyPassengerOnRideServer($passenger_id, $payload) {
$url = getenv('PASSENGER_SOCKET_INTERNAL_URL') ?: (getenv('RIDE_SOCKET_URL') ?: 'http://socket_passenger:3031'); $url = getenv('PASSENGER_SOCKET_INTERNAL_URL') ?: (getenv('RIDE_SOCKET_URL') ?: 'http://socket_passenger:3031');
if (!isAllowedSocketUrl($url)) return null; if (!isAllowedSocketUrl($url)) {
// كان يرجع null بصمت تام — عكس sendToLocationServer — فإذا كان
// ALLOWED_SOCKET_URLS لا يغطّي عنوان السوكيت تسقط كل أحداث الراكب
// بلا أي أثر في اللوج.
error_log("[SOCKET_BLOCKED] Passenger socket URL not in ALLOWED_SOCKET_URLS: $url");
return null;
}
$INTERNAL_KEY = function_exists('getInternalSocketKey') ? getInternalSocketKey() : ''; $INTERNAL_KEY = function_exists('getInternalSocketKey') ? getInternalSocketKey() : '';
if (empty($INTERNAL_KEY)) { if (empty($INTERNAL_KEY)) {
@@ -363,7 +369,7 @@ function dispatchRideToDrivers($driversData, $rideId, $payloadTemplate, $startNa
$countDrivers = count($driversData); $countDrivers = count($driversData);
error_log("🚀 [DISPATCH_START] RideID: $rideId | Drivers Count: $countDrivers"); error_log("🚀 [DISPATCH_START] RideID: $rideId | Drivers Count: $countDrivers");
$socketUrl = getenv('LOCATION_SERVER_URL') ?: 'http://nginx/loction_server/driver_socket.php'; $socketUrl = getenv('LOCATION_SERVER_URL') ?: 'http://socket_driver:2021';
if (!isAllowedSocketUrl($socketUrl)) return; if (!isAllowedSocketUrl($socketUrl)) return;
$internalKey = function_exists('getInternalSocketKey') ? getInternalSocketKey() : ''; $internalKey = function_exists('getInternalSocketKey') ? getInternalSocketKey() : '';
+19 -1
View File
@@ -92,6 +92,10 @@ function getAccessToken($credentialsPath) {
'assertion' => $jwt 'assertion' => $jwt
])); ]));
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
// بلا مهلة كان الطلب يقدر يعلّق حتى مهلة PHP نفسها، فتُسقط الرسالة بصمت
// وهذا أحد أسباب "الإشعار مرات يوصل ومرات لا".
curl_setopt($ch, CURLOPT_TIMEOUT, 10);
curl_setopt($ch, CURLOPT_CONNECTTIMEOUT, 5);
$res = curl_exec($ch); $res = curl_exec($ch);
curl_close($ch); curl_close($ch);
@@ -114,6 +118,11 @@ $fcmUrl = "https://fcm.googleapis.com/v1/projects/$projectId/messages:send";
// ============================================================================ // ============================================================================
// بناء هيكل الرسالة // بناء هيكل الرسالة
// ============================================================================ // ============================================================================
// 🔧 توحيد نغمة أندرويد مع FcmService::send — هناك 'ding' تُترجم إلى 'default'،
// وهنا كانت تُمرَّر كما هي فيبحث أندرويد عن ملف صوت اسمه "ding" وقد لا يوجد
// فيصل الإشعار بلا صوت (أو لا يُلفت النظر إطلاقاً).
$androidSound = ($tone === 'ding' || $tone === 'default') ? 'default' : $tone;
$messagePayload = [ $messagePayload = [
'message' => [ 'message' => [
'notification' => [ 'notification' => [
@@ -123,7 +132,7 @@ $messagePayload = [
'android' => [ 'android' => [
'priority' => 'HIGH', 'priority' => 'HIGH',
'notification' => [ 'notification' => [
'sound' => $tone, 'sound' => $androidSound,
'channel_id' => 'high_importance_channel' // تأكد من تطابقه مع Android 'channel_id' => 'high_importance_channel' // تأكد من تطابقه مع Android
] ]
], ],
@@ -149,6 +158,15 @@ if ($isTopic) {
// ============================================================================ // ============================================================================
// 🔥 معالجة Data Payload (يجب أن تكون String: String فقط) // 🔥 معالجة Data Payload (يجب أن تكون String: String فقط)
// ============================================================================ // ============================================================================
// FcmService::send يحقن title/body/tone/category/type داخل data، وتطبيق الراكب
// يقرأ message.data['title'] أولاً. هذا المسار كان لا يحقنها، فأي رسالة تمرّ من
// هنا تظهر بعنوان فارغ عند العميل الذي يعتمد على data. نوحّد السلوك.
$customData = array_merge(is_array($customData) ? $customData : [], [
'title' => (string)$title,
'body' => (string)$body,
'tone' => (string)$tone,
]);
if (!empty($customData)) { if (!empty($customData)) {
$processedData = []; $processedData = [];
foreach ($customData as $key => $val) { foreach ($customData as $key => $val) {
@@ -131,6 +131,21 @@ try {
$ride['first_name'] = $encryptionHelper->decryptData($ride['first_name'] ?? ''); $ride['first_name'] = $encryptionHelper->decryptData($ride['first_name'] ?? '');
$ride['email'] = $encryptionHelper->decryptData($ride['email'] ?? ''); $ride['email'] = $encryptionHelper->decryptData($ride['email'] ?? '');
// جدول tokens يخزّن التوكن مشفّراً (ride/firebase/addToken.php). كان
// يُرجَع هنا كما هو، فيصل تطبيق السائق blob مشفّر يستخدمه كـ FCM target
// في كل رسالة للراكب ⇒ رفض 400 من FCM ولا يصل الراكب شيء.
if (!empty($ride['passengerToken'])) {
$decodedPassengerToken = false;
try {
$decodedPassengerToken = $encryptionHelper->decryptData($ride['passengerToken']);
} catch (\Throwable $eTok) {
$decodedPassengerToken = false;
}
if ($decodedPassengerToken !== false && $decodedPassengerToken !== null && $decodedPassengerToken !== '') {
$ride['passengerToken'] = trim($decodedPassengerToken);
}
}
$ride['start_location'] = $ride['start_lat'] . ',' . $ride['start_lng']; $ride['start_location'] = $ride['start_lat'] . ',' . $ride['start_lng'];
$ride['end_location'] = (!empty($ride['end_lat'])) $ride['end_location'] = (!empty($ride['end_lat']))
? $ride['end_lat'] . ',' . $ride['end_lng'] ? $ride['end_lat'] . ',' . $ride['end_lng']
+32
View File
@@ -198,6 +198,38 @@ try {
$passengerIdValue = $passengerId->fetchColumn(); $passengerIdValue = $passengerId->fetchColumn();
} }
// 🆕 نحلّ توكن الراكب من القاعدة دائماً ولا نعتمد على ما يرسله التطبيق:
// • مسار الـ dispatch/FCM يرسل التوكن سليماً (نص صريح من add_ride.php).
// • مسار "سوق الرحلات" يرسله كما خرج من getRideWaiting.php أي **مشفّراً**
// (جدول tokens يخزّنه مشفّراً — انظر ride/firebase/addToken.php)، فكان
// FCM يرفضه بـ 400 ولا يصل الراكب أي إشعار قبول.
// القيمة القادمة من العميل تبقى fallback أخيراً فقط.
$passengerTokenFromClient = $passengerToken;
$passengerToken = '';
if ($passengerIdValue) {
try {
$stmtTok = $con->prepare("SELECT token FROM tokens WHERE passengerID = ? LIMIT 1");
$stmtTok->execute([$passengerIdValue]);
$rawPassengerToken = $stmtTok->fetchColumn();
if (!empty($rawPassengerToken)) {
$decryptedToken = false;
try {
$decryptedToken = $encryptionHelper->decryptData($rawPassengerToken);
} catch (\Throwable $eTok) {
$decryptedToken = false;
}
$passengerToken = ($decryptedToken !== false && $decryptedToken !== null && $decryptedToken !== '')
? trim($decryptedToken)
: $rawPassengerToken;
}
} catch (PDOException $eTok) {
error_log("[accept_ride] passenger token lookup WARNING: " . $eTok->getMessage());
}
}
if (empty($passengerToken)) {
$passengerToken = $passengerTokenFromClient;
}
if ($passengerIdValue) { if ($passengerIdValue) {
// Socket — real-time update على خريطة الراكب // Socket — real-time update على خريطة الراكب
if (function_exists('notifyPassengerOnRideServer')) { if (function_exists('notifyPassengerOnRideServer')) {
+22 -12
View File
@@ -24,19 +24,29 @@ function broadcastRideToMarket($rideId, $lat, $lng, $payloadData, $extraMarketDa
} }
} }
$INTERNAL_KEY = function_exists('getInternalSocketKey') ? getInternalSocketKey() : ''; $INTERNAL_KEY = function_exists('getInternalSocketKey') ? getInternalSocketKey() : '';
// ⚠️ هذه الحمولة تُبَثّ لكل سائق قريب (وليس للفائز فقط) — لا نضع فيها أي
// بيانات راكب حسّاسة (هاتف/إيميل/FCM token). ما ينقص السائق من تفاصيل
// الراكب يجلبه بعد نجاح القبول، والسيرفر يحلّ التوكن بنفسه في acceptRide.php.
//
// أسماء الحقول تطابق getRideWaiting.php لأن available_rides_page.dart يحشر
// رحلات الـ socket في نفس القائمة ويقرأ منها بنفس المفاتيح — أي حقل ناقص
// يصل للسيرفر لاحقاً كنص "null".
$marketPayload = array_merge([ $marketPayload = array_merge([
'id' => (string)$rideId, 'id' => (string)$rideId,
'start_lat' => $lat, 'start_lat' => $lat,
'start_lng' => $lng, 'start_lng' => $lng,
'end_lat' => $payloadData[3], 'end_lat' => $payloadData[3],
'end_lng' => $payloadData[4], 'end_lng' => $payloadData[4],
'price' => $payloadData[2], 'start_location' => $lat . ',' . $lng,
'carType' => $payloadData[31], 'end_location' => $payloadData[3] . ',' . $payloadData[4],
'startName' => $payloadData[29], 'price' => $payloadData[2],
'endName' => $payloadData[30], 'carType' => $payloadData[31],
'distance' => $payloadData[11], 'startName' => $payloadData[29],
'duration' => $payloadData[15], 'endName' => $payloadData[30],
'passengerRate' => $payloadData[33], 'distance' => $payloadData[11],
'duration' => $payloadData[15],
'passengerRate' => $payloadData[33],
'passengerId' => $payloadData[7],
], $extraMarketData); ], $extraMarketData);
$postData = [ $postData = [
@@ -82,7 +82,7 @@ try {
// 🔥 يُرسل دائماً بغض النظر عن driver_id — إذا كانت الرحلة لم تُقبل بعد // 🔥 يُرسل دائماً بغض النظر عن driver_id — إذا كانت الرحلة لم تُقبل بعد
// (driver_id = 0)، لوكيشن سيرفر يستخدم ride:offered_drivers:{rideId} // (driver_id = 0)، لوكيشن سيرفر يستخدم ride:offered_drivers:{rideId}
// من Redis لإشعار كل السائقين الذين وصلهم عرض هذه الرحلة أصلاً. // من Redis لإشعار كل السائقين الذين وصلهم عرض هذه الرحلة أصلاً.
$socketUrl = getenv('LOCATION_SERVER_URL') ?: 'http://nginx/loction_server/driver_socket.php'; $socketUrl = getenv('LOCATION_SERVER_URL') ?: 'http://socket_driver:2021';
$internalKeyPath = getenv('INTERNAL_SOCKET_KEY_PATH') ?: ''; $internalKeyPath = getenv('INTERNAL_SOCKET_KEY_PATH') ?: '';
$internalKey = ($internalKeyPath && file_exists($internalKeyPath)) ? trim(file_get_contents($internalKeyPath)) : (getenv('INTERNAL_SOCKET_KEY') ?: ''); $internalKey = ($internalKeyPath && file_exists($internalKeyPath)) ? trim(file_get_contents($internalKeyPath)) : (getenv('INTERNAL_SOCKET_KEY') ?: '');
+7 -1
View File
@@ -57,6 +57,9 @@ REDIS_MAIN_PASSWORD=
# --- 4. Internal Sockets (Docker Network Names) --- # --- 4. Internal Sockets (Docker Network Names) ---
LOCATION_SERVER_URL=http://socket_driver:2021 LOCATION_SERVER_URL=http://socket_driver:2021
RIDE_SOCKET_URL=http://socket_passenger:3031 RIDE_SOCKET_URL=http://socket_passenger:3031
# ⚠️ إجباري في Docker: بدونه يستخدم driver_socket.php الافتراضي 127.0.0.1:3031
# وهو داخل حاوية socket_driver يشير لنفسها، فلا يصل الراكب أي تحديث موقع.
PASSENGER_SOCKET_INTERNAL_URL=http://socket_passenger:3031
# --- 5. External Sockets & URLs --- # --- 5. External Sockets & URLs ---
LOCATION_SOCKET_URL=http://socket_driver:2021 LOCATION_SOCKET_URL=http://socket_driver:2021
@@ -135,7 +138,10 @@ SIRO_COMMISSION_IQ=0.14
ADMIN_TOOL_IPS=92.241.36.211,92.241.36.165,92.241.37.3 ADMIN_TOOL_IPS=92.241.36.211,92.241.36.165,92.241.37.3
ALLOWED_UPLOAD_HOST=siromove.com ALLOWED_UPLOAD_HOST=siromove.com
ALLOWED_TESTER_EMAILS=driver_tester@siromove.com,passenger_tester@siromove.com ALLOWED_TESTER_EMAILS=driver_tester@siromove.com,passenger_tester@siromove.com
ALLOWED_SOCKET_URLS=http://188.68.36.205:2021,http://188.68.36.205:3031,https://location.intaleq.xyz # ⚠️ لازم يغطّي كل قيمة في القسم 4/5 أعلاه: isAllowedSocketUrl() تعمل
# str_starts_with، وأي عنوان غير مغطّى تُسقَط رسائله (بلا أي أثر قبل هذا الإصلاح).
# أسماء حاويات Docker إجبارية هنا — لا تحذفها واستبدلها بالـ IP فقط.
ALLOWED_SOCKET_URLS=http://socket_driver,http://socket_passenger,http://nginx,http://188.68.36.205:2021,http://188.68.36.205:3031,https://location.intaleq.xyz
ALLOWED_ORIGIN=https://siromove.com ALLOWED_ORIGIN=https://siromove.com
ADMIN_PHONE_NUMBERS=963992952235,963942542053,962772735902 ADMIN_PHONE_NUMBERS=963992952235,963942542053,962772735902
SERVICE_PHONE1=963952475740 SERVICE_PHONE1=963952475740
+9 -6
View File
@@ -171,19 +171,22 @@ function forwardLocationToPassengerSocket(
$now = time(); $now = time();
$last = $fwdThrottle[$driverId] ?? null; $last = $fwdThrottle[$driverId] ?? null;
// ⚠️ الحمولة تستخدم latitude/longitude (انظر update_location) — قراءة
// lat/lng هنا كانت ترجع null فتصير المسافة ضخمة، فلا يعمل الـ throttle
// إطلاقاً ويُعاد التوجيه مع كل نبضة GPS.
$curLat = (float)($payload['latitude'] ?? $payload['lat'] ?? 0);
$curLng = (float)($payload['longitude'] ?? $payload['lng'] ?? 0);
if ($last !== null) { if ($last !== null) {
$timeDiff = $now - $last['ts']; $timeDiff = $now - $last['ts'];
$dist = haversineDistance( $dist = haversineDistance($last['lat'], $last['lng'], $curLat, $curLng);
$last['lat'], $last['lng'],
(float)$payload['lat'], (float)$payload['lng']
);
if ($dist < FORWARD_MIN_METERS && $timeDiff < FORWARD_MAX_SECONDS) return; if ($dist < FORWARD_MIN_METERS && $timeDiff < FORWARD_MAX_SECONDS) return;
} }
$fwdThrottle[$driverId] = [ $fwdThrottle[$driverId] = [
'ts' => $now, 'ts' => $now,
'lat' => (float)$payload['lat'], 'lat' => $curLat,
'lng' => (float)$payload['lng'], 'lng' => $curLng,
]; ];
$passengerSocketUrl = getenv('PASSENGER_SOCKET_INTERNAL_URL') ?: 'http://127.0.0.1:3031'; $passengerSocketUrl = getenv('PASSENGER_SOCKET_INTERNAL_URL') ?: 'http://127.0.0.1:3031';
+90
View File
@@ -63,6 +63,34 @@ function getInternalSocketKey(): string {
$INTERNAL_KEY = getInternalSocketKey(); $INTERNAL_KEY = getInternalSocketKey();
// ── Redis سيرفر الموقع (للتحقق من عضوية الراكب في خط مواصلاتي) ──
// نفس الـ Redis الذي يكتب عليه driver_socket و transit/functions.php.
// الهوست من REDIS_HOST حتى يعمل داخل Docker وخارجه على حدّ سواء.
$_locationRedis = null;
function getLocationRedis(): ?\Redis {
global $_locationRedis;
if ($_locationRedis !== null) {
try { $_locationRedis->ping(); return $_locationRedis; }
catch (\Exception $_) { $_locationRedis = null; }
}
try {
$redisPass = '';
foreach ([getenv('REDIS_PASS_KEY_PATH') ?: '', '/keys/.reds_pass_key', '/home/location/.reds_pass_key'] as $p) {
if (!empty($p) && file_exists($p)) { $redisPass = trim((string)@file_get_contents($p)); break; }
}
$host = getenv('REDIS_HOST') ?: (file_exists('/.dockerenv') ? 'redis' : '127.0.0.1');
$r = new \Redis();
$r->connect($host, (int)(getenv('REDIS_PORT') ?: 6379), 1.5);
if ($redisPass) $r->auth($redisPass);
$_locationRedis = $r;
return $r;
} catch (\Exception $e) {
socket_log("[REDIS_ERROR] Location Redis unavailable: " . $e->getMessage());
return null;
}
}
function getJwtSecret(): string { function getJwtSecret(): string {
$keyPath = getenv('JWT_SECRET_KEY_PATH'); $keyPath = getenv('JWT_SECRET_KEY_PATH');
if ($keyPath && file_exists($keyPath)) { if ($keyPath && file_exists($keyPath)) {
@@ -142,6 +170,28 @@ $io->on('workerStart', function () use ($io, $INTERNAL_KEY, $INTERNAL_PORT) {
$connection->send('OK'); $connection->send('OK');
} elseif ($action === 'broadcast_bus_location') {
// 🚌 بثّ موقع الباص لكل ركاب الخط المشتركين (مواصلاتي)
// يصل من driver_socket بعد أن يبعث سائق الباص update_bus_location
$routeId = $post['route_id'] ?? null;
$rawPayload = $post['payload'] ?? null;
if (!$routeId || !$rawPayload) {
socket_log("[HTTP_ERROR] Missing route_id or payload for action: broadcast_bus_location", $post);
$connection->send('Error: Missing route_id or payload');
return;
}
$payload = is_string($rawPayload)
? (json_decode($rawPayload, true) ?? $rawPayload)
: $rawPayload;
socket_log("[HTTP_SUCCESS] Emitting 'bus_location_update' to route #$routeId", $payload);
$io->to('transit_route_' . $routeId)->emit('bus_location_update', $payload);
$connection->send('OK');
} else { } else {
socket_log("[HTTP_WARNING] Unknown action received: $action", $post); socket_log("[HTTP_WARNING] Unknown action received: $action", $post);
$connection->send('Unknown action: ' . $action); $connection->send('Unknown action: ' . $action);
@@ -191,6 +241,46 @@ $io->on('connection', function ($socket) {
// socket_log("[SOCKET_HEARTBEAT] Received from Passenger #$passengerId"); // socket_log("[SOCKET_HEARTBEAT] Received from Passenger #$passengerId");
}); });
// 🚌 اشتراك الراكب في بثّ موقع باص خط (مواصلاتي)
// يُرفض إن لم يكن الراكب عضواً نشطاً في المؤسسة المالكة للخط
$socket->on('subscribe_transit_route', function ($data) use ($socket, $passengerId) {
$data = (array) $data;
$routeId = (int)($data['route_id'] ?? 0);
if ($routeId <= 0) return;
// transit:route_org:{routeId} → org_id (كُتب في route/approve.php)
// transit:org_members:{orgId} → SET من passenger_ids (transitCacheEnrollment)
$redis = getLocationRedis();
if ($redis) {
$orgId = $redis->get("transit:route_org:{$routeId}");
if ($orgId === false || $orgId === null) {
socket_log("[TRANSIT_BLOCKED] Route #{$routeId} not in Redis — passenger #{$passengerId}");
$socket->emit('transit_error', ['code' => 'ROUTE_NOT_FOUND', 'route_id' => $routeId]);
return;
}
if (!$redis->sIsMember("transit:org_members:{$orgId}", (string)$passengerId)) {
socket_log("[TRANSIT_BLOCKED] Passenger #{$passengerId} not enrolled in org #{$orgId} (route #{$routeId})");
$socket->emit('transit_error', ['code' => 'NOT_ENROLLED', 'route_id' => $routeId]);
return;
}
} else {
socket_log("[TRANSIT_WARN] Redis unavailable — enrollment check skipped (passenger #{$passengerId} route #{$routeId})");
}
$socket->join('transit_route_' . $routeId);
socket_log("[TRANSIT] Passenger #$passengerId subscribed to route #$routeId");
});
// 🚌 إلغاء الاشتراك عند إغلاق الراكب للخط
$socket->on('unsubscribe_transit_route', function ($data) use ($socket, $passengerId) {
$data = (array) $data;
$routeId = (int)($data['route_id'] ?? 0);
if ($routeId <= 0) return;
$socket->leave('transit_route_' . $routeId);
socket_log("[TRANSIT] Passenger #$passengerId unsubscribed from route #$routeId");
});
$socket->on('disconnect', function () use ($passengerId, $clientIp) { $socket->on('disconnect', function () use ($passengerId, $clientIp) {
socket_log("[SOCKET_DISCONNECTED] Passenger Disconnected: #$passengerId (IP: $clientIp)"); socket_log("[SOCKET_DISCONNECTED] Passenger Disconnected: #$passengerId (IP: $clientIp)");
}); });
@@ -126,9 +126,11 @@ class FirebaseMessagesController extends GetxController {
// اقرأ "النوع" من حمولة البيانات، وليس من العنوان // اقرأ "النوع" من حمولة البيانات، وليس من العنوان
String category = message.data['category'] ?? ''; String category = message.data['category'] ?? '';
// اقرأ العنوان والنص (للعرض) // اقرأ العنوان والنص (للعرض) — data أولاً ثم notification، مثل تطبيق الراكب.
String title = message.notification?.title ?? ''; // السيرفر (FcmService/send_fcm.php) يحقن title/body داخل data، والرسائل
String body = message.notification?.body ?? ''; // الصامتة (data-only) بلا notification أصلاً فكانا يخرجان فارغين.
String title = message.data['title'] ?? message.notification?.title ?? '';
String body = message.data['body'] ?? message.notification?.body ?? '';
// استخدم switch لسهولة القراءة والصيانة // استخدم switch لسهولة القراءة والصيانة
switch (category) { switch (category) {
@@ -295,8 +297,8 @@ class FirebaseMessagesController extends GetxController {
default: default:
Log.print('Received unhandled notification category: $category'); Log.print('Received unhandled notification category: $category');
// Optionally show a generic notification // إشعار عام — لكن لا نعرض فقاعة فارغة للرسائل الصامتة (data-only)
if (Platform.isAndroid) { if (Platform.isAndroid && (title.isNotEmpty || body.isNotEmpty)) {
notificationController.showNotification(title, body, 'default', ''); notificationController.showNotification(title, body, 'default', '');
} }
break; break;
@@ -6,8 +6,10 @@ import 'package:intaleq_maps/intaleq_maps.dart';
import '../../../constant/box_name.dart'; import '../../../constant/box_name.dart';
import '../../../constant/links.dart'; import '../../../constant/links.dart';
import '../../../main.dart'; // contains global 'box' import '../../../env/env.dart';
import '../../../main.dart'; // contains global 'box' + 'storage'
import '../../../print.dart'; import '../../../print.dart';
import '../../functions/encrypt_decrypt.dart'; // r()
import 'ride_lifecycle_controller.dart'; import 'ride_lifecycle_controller.dart';
import 'nearby_drivers_controller.dart'; import 'nearby_drivers_controller.dart';
import 'map_engine_controller.dart'; import 'map_engine_controller.dart';
@@ -29,10 +31,38 @@ class MapSocketController extends GetxController {
DateTime? get lastDriverLocationTime => _lastSocketLocationTime; DateTime? get lastDriverLocationTime => _lastSocketLocationTime;
int get socketLocationUpdatesCount => _socketLocationUpdatesCount; int get socketLocationUpdatesCount => _socketLocationUpdatesCount;
void initConnectionWithSocket() { /// يقرأ الـ JWT بنفس آلية CRUD._getJwt (SecureStorage أولاً ثم GetStorage).
/// passenger_socket.php يرفض أي اتصال بلا jwt، فبدونه لا تصل أي أحداث للراكب.
Future<String> _getJwtForSocket() async {
try {
final String? encryptedJwt = await storage.read(key: BoxName.jwt);
if (encryptedJwt != null && encryptedJwt.isNotEmpty) {
return r(encryptedJwt).toString().split(Env.addd)[0];
}
} catch (e) {
Log.print('Error reading JWT from SecureStorage for socket: $e');
}
final String? fallback = box.read(BoxName.jwt);
if (fallback != null && fallback.toString().isNotEmpty) {
try {
return r(fallback).toString().split(Env.addd)[0];
} catch (e) {
Log.print('Error decrypting fallback JWT for socket: $e');
}
}
return '';
}
Future<void> initConnectionWithSocket() async {
if (isSocketConnected) return; if (isSocketConnected) return;
String passengerId = box.read(BoxName.passengerID).toString(); String passengerId = box.read(BoxName.passengerID).toString();
final String jwt = await _getJwtForSocket();
if (jwt.isEmpty) {
Log.print(
"⚠️ No JWT available — passenger socket would be rejected by the server. Aborting connect.");
return;
}
Log.print("🔌 Initializing Socket for Passenger: $passengerId"); Log.print("🔌 Initializing Socket for Passenger: $passengerId");
socket = io_client.io( socket = io_client.io(
@@ -40,7 +70,7 @@ class MapSocketController extends GetxController {
io_client.OptionBuilder() io_client.OptionBuilder()
.setTransports(['websocket']) .setTransports(['websocket'])
.disableAutoConnect() .disableAutoConnect()
.setQuery({'id': passengerId}) .setQuery({'id': passengerId, 'jwt': jwt})
.setReconnectionAttempts(20) .setReconnectionAttempts(20)
.setReconnectionDelay(2000) .setReconnectionDelay(2000)
.setReconnectionDelayMax(10000) .setReconnectionDelayMax(10000)
@@ -171,13 +201,15 @@ class MapSocketController extends GetxController {
// ── مواصلاتي: اشتراك/إلغاء اشتراك ببثّ موقع خط ───────────── // ── مواصلاتي: اشتراك/إلغاء اشتراك ببثّ موقع خط ─────────────
// يُستدعى عند فتح/إغلاق شاشة تتبع الباص الحي. يضمن السوكيت متصلاً أولاً. // يُستدعى عند فتح/إغلاق شاشة تتبع الباص الحي. يضمن السوكيت متصلاً أولاً.
void subscribeToTransitRoute(int routeId) { Future<void> subscribeToTransitRoute(int routeId) async {
_subscribedTransitRouteId = routeId; _subscribedTransitRouteId = routeId;
if (!isSocketConnected) { if (!isSocketConnected) {
initConnectionWithSocket(); await initConnectionWithSocket();
// سيُعاد الاشتراك تلقائياً من onConnect إن أضفنا ذلك، لكن نحاول فوراً أيضاً // سيُعاد الاشتراك تلقائياً من onConnect إن أضفنا ذلك، لكن نحاول فوراً أيضاً
} }
if (socket.connected) { // socket هو late — لو انسحب initConnectionWithSocket (بلا JWT مثلاً) يبقى
// غير مُهيّأ، فقراءته مباشرة ترمي LateInitializationError.
if (_isSocketInitialized && socket.connected) {
socket.emit('subscribe_transit_route', {'route_id': routeId}); socket.emit('subscribe_transit_route', {'route_id': routeId});
Log.print('🚌 Subscribed to transit route #$routeId'); Log.print('🚌 Subscribed to transit route #$routeId');
} }
@@ -185,7 +217,7 @@ class MapSocketController extends GetxController {
void unsubscribeFromTransitRoute(int routeId) { void unsubscribeFromTransitRoute(int routeId) {
if (_subscribedTransitRouteId == routeId) _subscribedTransitRouteId = null; if (_subscribedTransitRouteId == routeId) _subscribedTransitRouteId = null;
if (isSocketConnected && socket.connected) { if (_isSocketInitialized && isSocketConnected && socket.connected) {
socket.emit('unsubscribe_transit_route', {'route_id': routeId}); socket.emit('unsubscribe_transit_route', {'route_id': routeId});
Log.print('🚌 Unsubscribed from transit route #$routeId'); Log.print('🚌 Unsubscribed from transit route #$routeId');
} }
@@ -243,7 +275,12 @@ class MapSocketController extends GetxController {
_onRideFinishedWithSocket(data); _onRideFinishedWithSocket(data);
break; break;
// cancel_ride_by_driver.php يبعث 'cancelled_by_driver' حرفياً،
// و cancel_ride_by_passenger.php يبعث 'cancelled_by_passenger'.
// كانت الحالتان تسقطان من الـ switch فيبقى الراكب معلّقاً على الشاشة.
case 'cancelled': case 'cancelled':
case 'cancelled_by_driver':
case 'canceled_by_driver':
rideLifecycle.processRideCancelledByDriver(data, source: "Socket"); rideLifecycle.processRideCancelledByDriver(data, source: "Socket");
break; break;