استرجاع شبكة أمان موقع السائق (انحدارة من الالتزام ab3be7e2):
- كان شرط "Smart Mode" يتخطّى _startDriverLocationPollingWithTimer كلياً إذا
جاء القبول من السوكيت. قبل إصلاح ترتيب الاتصال كان السوكيت يخسر دائماً
فالـ polling يعمل ويحمل الموقع؛ وبعد أن صار يفوز تعطّلت شبكة الأمان.
- وصول حدث القبول لا يعني أن تدفّق المواقع يعمل: التدفّق له شرط مستقل —
تطبيق السائق يحقن passenger_id في update_location فقط إذا كان rideStatus
نشطاً و BoxName.passengerID مكتوباً، وهو لا يُكتب إلا بعد فتح صفحة خريطة
السائق (map_driver_controller.dart:2444).
- الآن الـ polling يعمل دائماً، و handleDriverLocationUpdate يوقفه تلقائياً
بعد 3 تحديثات ناجحة من السوكيت — وهذا المنطق كان موجوداً أصلاً وهو التصميم
المقصود. السوكيت يبقى المسار السريع بلا فقدان شبكة الأمان.
عدم تعطيل إنشاء الرحلة بانتظار سوكيت غير قابل للوصول:
- ensureConnectedBeforeRide كانت تنتظر 4 ثوانٍ قبل كل طلب. سوكيتات
Workerman تستمع نصّاً صريحاً على 2020/3030 بينما التطبيقان يستخدمان
https:// عليها (nginx لا يمرّرها — انظر docker/nginx/default.conf)، فمصافحة
TLS ضد منفذ غير TLS تتجمّد حتى المهلة ⇒ 4 ثوانٍ مهدورة في كل رحلة.
- المهلة صارت 2ث، وأُضيف cooldown دقيقتين: بعد فشل قريب لا ننتظر إطلاقاً بل
نحاول الاتصال في الخلفية ونكمل الطلب فوراً.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
491 lines
18 KiB
Dart
491 lines
18 KiB
Dart
import 'dart:async';
|
|
import 'dart:convert';
|
|
import 'package:get/get.dart';
|
|
import 'package:socket_io_client/socket_io_client.dart' as io_client;
|
|
import 'package:intaleq_maps/intaleq_maps.dart';
|
|
|
|
import '../../../constant/box_name.dart';
|
|
import '../../../constant/links.dart';
|
|
import '../../../env/env.dart';
|
|
import '../../../main.dart'; // contains global 'box' + 'storage'
|
|
import '../../../print.dart';
|
|
import '../../functions/encrypt_decrypt.dart'; // r()
|
|
import 'ride_lifecycle_controller.dart';
|
|
import 'nearby_drivers_controller.dart';
|
|
import 'map_engine_controller.dart';
|
|
|
|
class MapSocketController extends GetxController {
|
|
late io_client.Socket socket;
|
|
bool isSocketConnected = false;
|
|
bool _isSocketInitialized = false;
|
|
// يغطّي نافذة الـ await على قراءة الـ JWT: بلا هذا يقدر نداءان متزامنان
|
|
// (initializeDataAfterLogin + startSearchingForDriver) يفتحان سوكيتين.
|
|
bool _isConnecting = false;
|
|
|
|
/// جاهز لاستقبال الأحداث — أي منضمّ فعلياً لغرفة passenger_{id} على السيرفر.
|
|
bool get isReadyForEvents => isSocketConnected && _isSocketInitialized;
|
|
Timer? _heartbeatTimer;
|
|
DateTime? _lastSocketLocationTime;
|
|
int _socketLocationUpdatesCount = 0;
|
|
Timer? _watchdogTimer;
|
|
|
|
// 🚌 مواصلاتي — الخط الحالي المشترَك فيه + مستمع تحديث موقع الباص
|
|
int? _subscribedTransitRouteId;
|
|
void Function(Map<String, dynamic> data)? onBusLocationUpdate;
|
|
void Function(String errorCode)? onTransitError;
|
|
|
|
DateTime? get lastDriverLocationTime => _lastSocketLocationTime;
|
|
int get socketLocationUpdatesCount => _socketLocationUpdatesCount;
|
|
|
|
/// يقرأ الـ 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 '';
|
|
}
|
|
|
|
/// يجب أن يُنادى **قبل** إنشاء الرحلة: غرف Socket.IO لا تخزّن الأحداث، فإن
|
|
/// قبل كابتن قبل أن ينضم الراكب لغرفته فإن ride_status_change يُرمى نهائياً
|
|
/// ولا يبقى إلا الـ FCM/polling. النداء آمن ومتكرر (idempotent).
|
|
Future<void> initConnectionWithSocket() async {
|
|
if (isSocketConnected || _isSocketInitialized || _isConnecting) return;
|
|
_isConnecting = true;
|
|
|
|
String passengerId = box.read(BoxName.passengerID).toString();
|
|
final String jwt = await _getJwtForSocket();
|
|
if (jwt.isEmpty) {
|
|
_isConnecting = false;
|
|
Log.print(
|
|
"⚠️ No JWT available — passenger socket would be rejected by the server. Aborting connect.");
|
|
return;
|
|
}
|
|
if (passengerId.isEmpty || passengerId == 'null') {
|
|
_isConnecting = false;
|
|
Log.print("⚠️ No passengerID yet — deferring socket connect.");
|
|
return;
|
|
}
|
|
Log.print("🔌 Initializing Socket for Passenger: $passengerId");
|
|
|
|
socket = io_client.io(
|
|
AppLink.serverSocket,
|
|
io_client.OptionBuilder()
|
|
.setTransports(['websocket'])
|
|
.disableAutoConnect()
|
|
.setQuery({'id': passengerId, 'jwt': jwt})
|
|
// محاولات لا نهائية، توحيداً مع تطبيق السائق (background_service.dart).
|
|
// كانت 20: بعدها يستسلم السوكيت **نهائياً** فيصمت للأبد بعد بضع دقائق
|
|
// من شبكة سيئة، ويبقى الراكب على الـ polling بلا أن يدري. صار الاتصال
|
|
// يعيش من فتح الخريطة لا من إنشاء الرحلة، فالسقف الثابت خطر أكبر الآن.
|
|
.setReconnectionAttempts(double.infinity)
|
|
.setReconnectionDelay(2000)
|
|
.setReconnectionDelayMax(10000)
|
|
.enableReconnection()
|
|
.setTimeout(20000)
|
|
.setExtraHeaders({'Connection': 'Upgrade'})
|
|
.build(),
|
|
);
|
|
_isSocketInitialized = true;
|
|
_isConnecting = false;
|
|
|
|
socket.connect();
|
|
|
|
socket.onConnect((_) {
|
|
Log.print("✅ Socket Connected Successfully");
|
|
isSocketConnected = true;
|
|
_startHeartbeat();
|
|
|
|
final rideLifecycle = Get.find<RideLifecycleController>();
|
|
if (rideLifecycle.rideId != 'yet' && rideLifecycle.driverId.isNotEmpty) {
|
|
socket.emit('subscribe_driver_location', {
|
|
'ride_id': rideLifecycle.rideId,
|
|
'driver_id': rideLifecycle.driverId,
|
|
});
|
|
Log.print("📡 Re-subscribed to driver location after connect");
|
|
}
|
|
if (_subscribedTransitRouteId != null) {
|
|
socket.emit('subscribe_transit_route', {'route_id': _subscribedTransitRouteId});
|
|
Log.print("🚌 Re-subscribed to transit route after connect");
|
|
}
|
|
update();
|
|
});
|
|
|
|
socket.onDisconnect((_) {
|
|
Log.print("⚠️ Socket Disconnected — Auto-Reconnect will handle it");
|
|
isSocketConnected = false;
|
|
|
|
final rideLifecycle = Get.find<RideLifecycleController>();
|
|
if (rideLifecycle.isActiveRideState()) {
|
|
Log.print("🔄 Enabling Fast Polling Fallback (4s) until reconnect...");
|
|
rideLifecycle.startMasterTimerWithInterval(4);
|
|
}
|
|
update();
|
|
});
|
|
|
|
socket.onReconnect((_) {
|
|
Log.print("🔁 Socket Reconnected Successfully!");
|
|
isSocketConnected = true;
|
|
_startHeartbeat();
|
|
|
|
final rideLifecycle = Get.find<RideLifecycleController>();
|
|
if (rideLifecycle.rideId != 'yet' && rideLifecycle.driverId.isNotEmpty) {
|
|
socket.emit('subscribe_driver_location', {
|
|
'ride_id': rideLifecycle.rideId,
|
|
'driver_id': rideLifecycle.driverId,
|
|
});
|
|
Log.print("📡 Re-subscribed to driver location after reconnect");
|
|
}
|
|
if (_subscribedTransitRouteId != null) {
|
|
socket.emit('subscribe_transit_route', {'route_id': _subscribedTransitRouteId});
|
|
Log.print("🚌 Re-subscribed to transit route after reconnect");
|
|
}
|
|
|
|
if (rideLifecycle.isActiveRideState()) {
|
|
Log.print("✅ Socket back online — stopping Fast Polling Fallback");
|
|
rideLifecycle.cancelMasterTimer();
|
|
}
|
|
update();
|
|
});
|
|
|
|
socket.onReconnectAttempt((attemptNumber) {
|
|
Log.print("🔄 Socket Reconnect Attempt #$attemptNumber...");
|
|
});
|
|
|
|
socket.onError((error) {
|
|
Log.print("❌ Socket Error: $error");
|
|
isSocketConnected = false;
|
|
});
|
|
|
|
socket.on('connect_error', (error) {
|
|
Log.print("❌ Socket Connect Error: $error");
|
|
isSocketConnected = false;
|
|
// في الإصدار 1.0.2 أحياناً auto-reconnect لا يعمل بعد connect_error
|
|
// نتأكد يدوياً من إعادة الاتصال
|
|
Future.delayed(const Duration(seconds: 3), () {
|
|
if (!isSocketConnected && _isSocketInitialized) {
|
|
Log.print("🔄 Manual reconnect after connect_error...");
|
|
try {
|
|
socket.connect();
|
|
} catch (e) {
|
|
Log.print("Manual reconnect error: $e");
|
|
}
|
|
}
|
|
});
|
|
});
|
|
|
|
socket.on('ride_status_change', (data) {
|
|
Log.print("📩 Socket Event: ride_status_change -> $data");
|
|
_handleRideStatusChangeWithSocket(data);
|
|
});
|
|
|
|
socket.on('driver_location_update', (data) {
|
|
handleDriverLocationUpdate(data);
|
|
});
|
|
|
|
// 🚌 مواصلاتي — بثّ موقع الباص الحي لخط مشترَك فيه
|
|
socket.on('bus_location_update', (data) {
|
|
if (data == null) return;
|
|
try {
|
|
final map = Map<String, dynamic>.from(data as Map);
|
|
onBusLocationUpdate?.call(map);
|
|
} catch (e) {
|
|
Log.print('Error parsing bus_location_update: $e');
|
|
}
|
|
});
|
|
|
|
socket.on('transit_error', (data) {
|
|
if (data == null) return;
|
|
try {
|
|
final map = Map<String, dynamic>.from(data as Map);
|
|
final code = map['code']?.toString() ?? '';
|
|
Log.print('⚠️ transit_error: $code');
|
|
onTransitError?.call(code);
|
|
} catch (e) {
|
|
Log.print('Error parsing transit_error: $e');
|
|
}
|
|
});
|
|
}
|
|
|
|
/// يوصل السوكيت وينتظر انضمامه فعلياً لغرفة الراكب، بسقف زمني.
|
|
/// يُنادى قبل إنشاء الرحلة حتى لا يقبل كابتن والراكب ما زال خارج غرفته
|
|
/// (الحدث يُفقد نهائياً في تلك الحالة — لا طابور في غرف Socket.IO).
|
|
/// لا يُفشل إنشاء الرحلة أبداً: عند انتهاء المهلة نكمل ونتّكل على
|
|
/// الـ FCM/polling كشبكة احتياطية.
|
|
/// آخر مرة فشل فيها الاتصال — حتى لا نُبطئ كل طلب رحلة بانتظارٍ عقيم إذا كان
|
|
/// السوكيت غير قابل للوصول أصلاً (منفذ مغلق/TLS غير مُنهى/سيرفر واقف).
|
|
static DateTime? _lastConnectFailure;
|
|
static const Duration _failureCooldown = Duration(minutes: 2);
|
|
|
|
Future<bool> ensureConnectedBeforeRide({
|
|
Duration timeout = const Duration(seconds: 2),
|
|
}) async {
|
|
if (isReadyForEvents) return true;
|
|
|
|
final lastFail = _lastConnectFailure;
|
|
if (lastFail != null &&
|
|
DateTime.now().difference(lastFail) < _failureCooldown) {
|
|
// فشل قريباً — نبدأ محاولة الاتصال في الخلفية ولا نُعطّل الطلب إطلاقاً
|
|
initConnectionWithSocket();
|
|
Log.print(
|
|
"⏭️ Skipping socket wait (recent failure). Ride proceeds on FCM/polling.");
|
|
return false;
|
|
}
|
|
|
|
await initConnectionWithSocket();
|
|
|
|
final deadline = DateTime.now().add(timeout);
|
|
while (!isSocketConnected && DateTime.now().isBefore(deadline)) {
|
|
await Future.delayed(const Duration(milliseconds: 100));
|
|
}
|
|
|
|
if (isSocketConnected) {
|
|
_lastConnectFailure = null;
|
|
Log.print("✅ Socket ready before ride creation.");
|
|
return true;
|
|
}
|
|
_lastConnectFailure = DateTime.now();
|
|
Log.print(
|
|
"⚠️ Socket not ready within ${timeout.inMilliseconds}ms — continuing; FCM/polling will cover.");
|
|
return false;
|
|
}
|
|
|
|
// ── مواصلاتي: اشتراك/إلغاء اشتراك ببثّ موقع خط ─────────────
|
|
// يُستدعى عند فتح/إغلاق شاشة تتبع الباص الحي. يضمن السوكيت متصلاً أولاً.
|
|
Future<void> subscribeToTransitRoute(int routeId) async {
|
|
_subscribedTransitRouteId = routeId;
|
|
if (!isSocketConnected) {
|
|
await initConnectionWithSocket();
|
|
// سيُعاد الاشتراك تلقائياً من onConnect إن أضفنا ذلك، لكن نحاول فوراً أيضاً
|
|
}
|
|
// socket هو late — لو انسحب initConnectionWithSocket (بلا JWT مثلاً) يبقى
|
|
// غير مُهيّأ، فقراءته مباشرة ترمي LateInitializationError.
|
|
if (_isSocketInitialized && socket.connected) {
|
|
socket.emit('subscribe_transit_route', {'route_id': routeId});
|
|
Log.print('🚌 Subscribed to transit route #$routeId');
|
|
}
|
|
}
|
|
|
|
void unsubscribeFromTransitRoute(int routeId) {
|
|
if (_subscribedTransitRouteId == routeId) _subscribedTransitRouteId = null;
|
|
if (_isSocketInitialized && isSocketConnected && socket.connected) {
|
|
socket.emit('unsubscribe_transit_route', {'route_id': routeId});
|
|
Log.print('🚌 Unsubscribed from transit route #$routeId');
|
|
}
|
|
}
|
|
|
|
/// ⚠️ زائدة عن الحاجة عملياً: Socket.IO يعمل ping/pong على مستوى البروتوكول،
|
|
/// ومستمع 'heartbeat' في passenger_socket.php فارغ ولا يفعل شيئاً. أبقيناها
|
|
/// بفاصل 30ث بدل 15ث لتنصيف حركتها — الاتصال صار يعيش من فتح الخريطة لا من
|
|
/// إنشاء الرحلة، فعدد الاتصالات الخاملة أعلى بكثير. حذفها كاملاً ممكن لاحقاً
|
|
/// بعد التأكد أن لا شيء على السيرفر يعتمد عليها لقياس الحضور.
|
|
void _startHeartbeat() {
|
|
_heartbeatTimer?.cancel();
|
|
_heartbeatTimer = Timer.periodic(const Duration(seconds: 30), (timer) {
|
|
if (isSocketConnected && socket.connected) {
|
|
socket.emit('heartbeat',
|
|
{'passenger_id': box.read(BoxName.passengerID).toString()});
|
|
}
|
|
});
|
|
}
|
|
|
|
bool isSocketHealthy() {
|
|
if (!isSocketConnected) return false;
|
|
if (_lastSocketLocationTime == null) return false;
|
|
final diff = DateTime.now().difference(_lastSocketLocationTime!).inSeconds;
|
|
return diff < 20;
|
|
}
|
|
|
|
void _handleRideStatusChangeWithSocket(dynamic data) {
|
|
if (data == null || data['status'] == null) return;
|
|
|
|
String newStatus = data['status'].toString().toLowerCase();
|
|
Log.print("🔔 Socket Status Update: $newStatus");
|
|
|
|
final rideLifecycle = Get.find<RideLifecycleController>();
|
|
|
|
Map<String, dynamic>? driverInfo;
|
|
if (data['driver_info'] != null && data['driver_info'] is Map) {
|
|
driverInfo = Map<String, dynamic>.from(data['driver_info']);
|
|
}
|
|
|
|
switch (newStatus) {
|
|
case 'accepted':
|
|
case 'apply':
|
|
case 'applied':
|
|
rideLifecycle.processRideAcceptance(
|
|
driverData: driverInfo, source: "Socket");
|
|
break;
|
|
|
|
case 'arrived':
|
|
rideLifecycle.processDriverArrival("Socket");
|
|
break;
|
|
|
|
case 'started':
|
|
case 'begin':
|
|
rideLifecycle.processRideBegin(source: "Socket");
|
|
break;
|
|
|
|
case 'finished':
|
|
case 'ended':
|
|
_onRideFinishedWithSocket(data);
|
|
break;
|
|
|
|
// cancel_ride_by_driver.php يبعث 'cancelled_by_driver' حرفياً،
|
|
// و cancel_ride_by_passenger.php يبعث 'cancelled_by_passenger'.
|
|
// كانت الحالتان تسقطان من الـ switch فيبقى الراكب معلّقاً على الشاشة.
|
|
case 'cancelled':
|
|
case 'cancelled_by_driver':
|
|
case 'canceled_by_driver':
|
|
rideLifecycle.processRideCancelledByDriver(data, source: "Socket");
|
|
break;
|
|
|
|
case 'no_drivers_found':
|
|
rideLifecycle.showNoDriverDialog();
|
|
break;
|
|
}
|
|
}
|
|
|
|
void _onRideFinishedWithSocket(dynamic data) {
|
|
Log.print("🏁 Ride Finished (Socket)");
|
|
final rideLifecycle = Get.find<RideLifecycleController>();
|
|
|
|
var rawList = data['DriverList'];
|
|
List<dynamic> listToSend = [];
|
|
|
|
if (rawList != null) {
|
|
if (rawList is List) {
|
|
listToSend = rawList;
|
|
} else if (rawList is String) {
|
|
try {
|
|
listToSend = jsonDecode(rawList);
|
|
} catch (e) {
|
|
Log.print("Error decoding DriverList: $e");
|
|
}
|
|
}
|
|
}
|
|
|
|
if (listToSend.isEmpty && data['price'] != null) {
|
|
listToSend = [
|
|
rideLifecycle.driverId,
|
|
rideLifecycle.rideId,
|
|
rideLifecycle.driverToken,
|
|
data['price'].toString()
|
|
];
|
|
}
|
|
|
|
rideLifecycle.processRideFinished(listToSend, source: "Socket");
|
|
}
|
|
|
|
void handleDriverLocationUpdate(dynamic data) {
|
|
if (!isSocketConnected || data == null) return;
|
|
_lastSocketLocationTime = DateTime.now();
|
|
_socketLocationUpdatesCount++;
|
|
|
|
final rideLifecycle = Get.find<RideLifecycleController>();
|
|
if (rideLifecycle.driverId.isEmpty &&
|
|
(data['driver_id'] ?? data['driverId']) != null) {
|
|
rideLifecycle.driverId =
|
|
(data['driver_id'] ?? data['driverId']).toString();
|
|
}
|
|
|
|
if (_socketLocationUpdatesCount >= 3 &&
|
|
rideLifecycle.locationPollingTimer != null) {
|
|
Log.print("✅ Socket delivering locations reliably. Stopping polling.");
|
|
rideLifecycle.stopDriverLocationPolling();
|
|
}
|
|
|
|
try {
|
|
double lat = double.tryParse(
|
|
(data['latitude'] ?? data['lat'])?.toString() ?? '0') ??
|
|
0;
|
|
double lng = double.tryParse(
|
|
(data['longitude'] ?? data['lng'])?.toString() ?? '0') ??
|
|
0;
|
|
double heading = double.tryParse(data['heading']?.toString() ?? '0') ?? 0;
|
|
|
|
if (lat == 0 || lng == 0) return;
|
|
|
|
LatLng newPos = LatLng(lat, lng);
|
|
|
|
final nearbyDrivers = Get.find<NearbyDriversController>();
|
|
if (nearbyDrivers.driverCarsLocationToPassengerAfterApplied.isEmpty) {
|
|
nearbyDrivers.driverCarsLocationToPassengerAfterApplied.add(newPos);
|
|
} else {
|
|
nearbyDrivers.driverCarsLocationToPassengerAfterApplied[0] = newPos;
|
|
}
|
|
|
|
double speed = double.tryParse(data['speed']?.toString() ?? '0') ?? 0;
|
|
rideLifecycle.checkAndRecalculateIfDeviated(
|
|
newPos,
|
|
heading: heading,
|
|
speed: speed,
|
|
);
|
|
|
|
final mapEngine = Get.find<MapEngineController>();
|
|
if (mapEngine.mapController != null) {
|
|
double zoom = 16.5;
|
|
if (speed > 0) {
|
|
zoom = 17.0 - ((speed - 10) / 70) * 2.5;
|
|
zoom = zoom.clamp(14.5, 17.0);
|
|
}
|
|
mapEngine.mapController!
|
|
.animateCamera(CameraUpdate.newLatLngZoom(newPos, zoom));
|
|
}
|
|
|
|
final dynamic distanceValue =
|
|
data['distance_m'] ?? data['distance_meters'];
|
|
final double? distanceMeters =
|
|
double.tryParse(distanceValue?.toString() ?? '');
|
|
final int? etaSeconds = data['eta_seconds'] == null
|
|
? null
|
|
: int.tryParse(data['eta_seconds'].toString());
|
|
final bool hasServerMetrics = (etaSeconds != null && etaSeconds > 0) ||
|
|
(distanceMeters != null && distanceMeters > 0);
|
|
if (hasServerMetrics) {
|
|
rideLifecycle.updateDriverRouteMetrics(
|
|
etaSeconds: etaSeconds != null && etaSeconds > 0 ? etaSeconds : null,
|
|
distanceMeters: distanceMeters,
|
|
);
|
|
}
|
|
|
|
rideLifecycle.updateDriverMarker(newPos, heading);
|
|
rideLifecycle.updateRemainingRoute(newPos, updateEta: !hasServerMetrics);
|
|
rideLifecycle.update();
|
|
} catch (e) {
|
|
Log.print('Error in handleDriverLocationUpdate: $e');
|
|
}
|
|
}
|
|
|
|
void disposeRideSocket() {
|
|
_heartbeatTimer?.cancel();
|
|
_watchdogTimer?.cancel();
|
|
if (_isSocketInitialized) {
|
|
socket.disconnect();
|
|
socket.dispose();
|
|
isSocketConnected = false;
|
|
_isSocketInitialized = false;
|
|
Log.print("🔌 Socket Disposed");
|
|
}
|
|
}
|
|
|
|
@override
|
|
void onClose() {
|
|
disposeRideSocket();
|
|
super.onClose();
|
|
}
|
|
}
|