diff --git a/lib/l10n/app_bn.arb b/lib/l10n/app_bn.arb index 02253cb..d7cd6dc 100644 --- a/lib/l10n/app_bn.arb +++ b/lib/l10n/app_bn.arb @@ -14,6 +14,7 @@ "triageResultTitle": "AI ট্রায়েজ ফল", "triageDegradedTitle": "AI (অফলাইন)", "firstAidGuidance": "প্রাথমিক চিকিৎসা", + "triageMedicalDisclaimer": "শুধু নির্দেশনা — এটি চিকিৎসা নির্ণয় নয়। জরুরি সাহায্যের জন্য 108/112-এ কল করুন।", "settingsLanguage": "ভাষা", "settingsLanguageSubtitle": "ইন্টারফেস ও ভয়েস", "incidentVoiceHint": "যা দেখছেন বলুন…", diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index c3c5038..0d79361 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -58,6 +58,7 @@ "severityUnknown": "UNKNOWN", "dispatchedServices": "DISPATCHED SERVICES", "firstAidGuidance": "FIRST AID GUIDANCE", + "triageMedicalDisclaimer": "Guidance only — not a medical diagnosis. Call 108/112 for emergency help.", "noAiBadge": "OFFLINE", "settingsTitle": "SETTINGS", "sectionConnectivity": "CONNECTIVITY", diff --git a/lib/l10n/app_hi.arb b/lib/l10n/app_hi.arb index a4c1e85..9edb32f 100644 --- a/lib/l10n/app_hi.arb +++ b/lib/l10n/app_hi.arb @@ -37,6 +37,7 @@ "severityUnknown": "अज्ञात", "dispatchedServices": "भेजी गई सेवाएँ", "firstAidGuidance": "प्राथमिक उपचार मार्गदर्शन", + "triageMedicalDisclaimer": "केवल मार्गदर्शन — यह चिकित्सा निदान नहीं है। आपातकालीन मदद के लिए 108/112 पर कॉल करें।", "noAiBadge": "ऑफ़लाइन", "settingsTitle": "सेटिंग्स", "sectionConnectivity": "कनेक्टिविटी", diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart index 6ee44aa..a5b090d 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -352,6 +352,12 @@ abstract class AppLocalizations { /// **'FIRST AID GUIDANCE'** String get firstAidGuidance; + /// No description provided for @triageMedicalDisclaimer. + /// + /// In en, this message translates to: + /// **'Guidance only — not a medical diagnosis. Call 108/112 for emergency help.'** + String get triageMedicalDisclaimer; + /// No description provided for @noAiBadge. /// /// In en, this message translates to: diff --git a/lib/l10n/app_localizations_bn.dart b/lib/l10n/app_localizations_bn.dart index b3bc05d..17ebd92 100644 --- a/lib/l10n/app_localizations_bn.dart +++ b/lib/l10n/app_localizations_bn.dart @@ -141,6 +141,10 @@ class AppLocalizationsBn extends AppLocalizations { @override String get firstAidGuidance => 'প্রাথমিক চিকিৎসা'; + @override + String get triageMedicalDisclaimer => + 'শুধু নির্দেশনা — এটি চিকিৎসা নির্ণয় নয়। জরুরি সাহায্যের জন্য 108/112-এ কল করুন।'; + @override String get noAiBadge => 'OFFLINE'; diff --git a/lib/l10n/app_localizations_en.dart b/lib/l10n/app_localizations_en.dart index bbe2f47..43adb95 100644 --- a/lib/l10n/app_localizations_en.dart +++ b/lib/l10n/app_localizations_en.dart @@ -142,6 +142,10 @@ class AppLocalizationsEn extends AppLocalizations { @override String get firstAidGuidance => 'FIRST AID GUIDANCE'; + @override + String get triageMedicalDisclaimer => + 'Guidance only — not a medical diagnosis. Call 108/112 for emergency help.'; + @override String get noAiBadge => 'OFFLINE'; diff --git a/lib/l10n/app_localizations_hi.dart b/lib/l10n/app_localizations_hi.dart index e2c5031..a67ce88 100644 --- a/lib/l10n/app_localizations_hi.dart +++ b/lib/l10n/app_localizations_hi.dart @@ -141,6 +141,10 @@ class AppLocalizationsHi extends AppLocalizations { @override String get firstAidGuidance => 'प्राथमिक उपचार मार्गदर्शन'; + @override + String get triageMedicalDisclaimer => + 'केवल मार्गदर्शन — यह चिकित्सा निदान नहीं है। आपातकालीन मदद के लिए 108/112 पर कॉल करें।'; + @override String get noAiBadge => 'ऑफ़लाइन'; diff --git a/lib/l10n/app_localizations_mr.dart b/lib/l10n/app_localizations_mr.dart index 4bb048a..95eccbe 100644 --- a/lib/l10n/app_localizations_mr.dart +++ b/lib/l10n/app_localizations_mr.dart @@ -141,6 +141,10 @@ class AppLocalizationsMr extends AppLocalizations { @override String get firstAidGuidance => 'प्रथमोपचार'; + @override + String get triageMedicalDisclaimer => + 'फक्त मार्गदर्शन — हे वैद्यकीय निदान नाही. आपत्कालीन मदतीसाठी 108/112 वर कॉल करा.'; + @override String get noAiBadge => 'OFFLINE'; diff --git a/lib/l10n/app_localizations_ta.dart b/lib/l10n/app_localizations_ta.dart index 292baa9..26ada1f 100644 --- a/lib/l10n/app_localizations_ta.dart +++ b/lib/l10n/app_localizations_ta.dart @@ -141,6 +141,10 @@ class AppLocalizationsTa extends AppLocalizations { @override String get firstAidGuidance => 'முதலுதவி'; + @override + String get triageMedicalDisclaimer => + 'வழிகாட்டுதல் மட்டும் — இது மருத்துவ நோயறிதல் அல்ல. அவசர உதவிக்கு 108/112 அழைக்கவும்.'; + @override String get noAiBadge => 'OFFLINE'; diff --git a/lib/l10n/app_localizations_te.dart b/lib/l10n/app_localizations_te.dart index b8abff6..ef13f38 100644 --- a/lib/l10n/app_localizations_te.dart +++ b/lib/l10n/app_localizations_te.dart @@ -141,6 +141,10 @@ class AppLocalizationsTe extends AppLocalizations { @override String get firstAidGuidance => 'మొదటి చికిత్స'; + @override + String get triageMedicalDisclaimer => + 'కేవలం మార్గదర్శనం — ఇది వైద్య నిర్ధారణ కాదు. అత్యవసర సహాయం కోసం 108/112 కు కాల్ చేయండి.'; + @override String get noAiBadge => 'OFFLINE'; diff --git a/lib/l10n/app_mr.arb b/lib/l10n/app_mr.arb index 7284104..3fd0072 100644 --- a/lib/l10n/app_mr.arb +++ b/lib/l10n/app_mr.arb @@ -14,6 +14,7 @@ "triageResultTitle": "AI ट्रायज परिणाम", "triageDegradedTitle": "AI (ऑफलाइन)", "firstAidGuidance": "प्रथमोपचार", + "triageMedicalDisclaimer": "फक्त मार्गदर्शन — हे वैद्यकीय निदान नाही. आपत्कालीन मदतीसाठी 108/112 वर कॉल करा.", "settingsLanguage": "भाषा", "settingsLanguageSubtitle": "इंटरफेस आणि आवाज", "incidentVoiceHint": "जे दिसते ते सांगा…", diff --git a/lib/l10n/app_ta.arb b/lib/l10n/app_ta.arb index 315b201..b7268ea 100644 --- a/lib/l10n/app_ta.arb +++ b/lib/l10n/app_ta.arb @@ -14,6 +14,7 @@ "triageResultTitle": "AI மதிப்பீடு", "triageDegradedTitle": "AI (இணைப்பில்லை)", "firstAidGuidance": "முதலுதவி", + "triageMedicalDisclaimer": "வழிகாட்டுதல் மட்டும் — இது மருத்துவ நோயறிதல் அல்ல. அவசர உதவிக்கு 108/112 அழைக்கவும்.", "settingsLanguage": "மொழி", "settingsLanguageSubtitle": "இடைமுகம் மற்றும் குரல்", "incidentVoiceHint": "என்ன காண்கிறீர்கள் என்று சொல்லுங்கள்…", diff --git a/lib/l10n/app_te.arb b/lib/l10n/app_te.arb index 9b71bd9..24992b2 100644 --- a/lib/l10n/app_te.arb +++ b/lib/l10n/app_te.arb @@ -14,6 +14,7 @@ "triageResultTitle": "AI ట్రయేజ్ ఫలితం", "triageDegradedTitle": "AI (ఆఫ్‌లైన్)", "firstAidGuidance": "మొదటి చికిత్స", + "triageMedicalDisclaimer": "కేవలం మార్గదర్శనం — ఇది వైద్య నిర్ధారణ కాదు. అత్యవసర సహాయం కోసం 108/112 కు కాల్ చేయండి.", "settingsLanguage": "భాష", "settingsLanguageSubtitle": "UI మరియు వాయిస్", "incidentVoiceHint": "మీరు చూసేది చెప్పండి…", diff --git a/lib/services/emergency_notification_service.dart b/lib/services/emergency_notification_service.dart index 41d0d49..6b07f4f 100644 --- a/lib/services/emergency_notification_service.dart +++ b/lib/services/emergency_notification_service.dart @@ -51,7 +51,7 @@ class EmergencyNotificationService { await _local.show( id: _sosActiveNotificationId, title: '🚨 RoadSOS: Emergency Active', - body: 'Emergency services and contacts are being notified. Stay calm.', + body: 'SOS active. Open RoadSOS for dispatch status; call emergency services if you can.', notificationDetails: const NotificationDetails( android: AndroidNotificationDetails( _channelId, diff --git a/lib/services/emergency_orchestrator.dart b/lib/services/emergency_orchestrator.dart index 5527d6e..f7f2691 100644 --- a/lib/services/emergency_orchestrator.dart +++ b/lib/services/emergency_orchestrator.dart @@ -11,6 +11,7 @@ import '../models/sos_activity_record.dart'; import 'ai_triage_service.dart'; import 'location_service.dart'; import 'mesh_network_service.dart'; +import 'emergency_sms_dispatch_service.dart'; import 'sms_dispatch_outcome.dart'; import 'crash_detection_service.dart'; import 'voice_assistant_service.dart'; @@ -78,6 +79,7 @@ class SOSState { final bool isBystander; final List dispatchChannels; final bool isBeaconActive; + final bool isDemoMode; /// Whether the SOS was triggered while driving mode was active. final bool wasInDrivingMode; @@ -93,6 +95,7 @@ class SOSState { this.isBystander = false, this.dispatchChannels = const [], this.isBeaconActive = false, + this.isDemoMode = false, this.wasInDrivingMode = false, }); @@ -107,6 +110,7 @@ class SOSState { bool? isBystander, List? dispatchChannels, bool? isBeaconActive, + bool? isDemoMode, bool? wasInDrivingMode, }) { return SOSState( @@ -120,6 +124,7 @@ class SOSState { isBystander: isBystander ?? this.isBystander, dispatchChannels: dispatchChannels ?? this.dispatchChannels, isBeaconActive: isBeaconActive ?? this.isBeaconActive, + isDemoMode: isDemoMode ?? this.isDemoMode, wasInDrivingMode: wasInDrivingMode ?? this.wasInDrivingMode, ); } @@ -151,13 +156,13 @@ class EmergencyOrchestrator extends StateNotifier { final Ref _ref; Timer? _countdownTimer; final _uuid = const Uuid(); - static const Duration _sosLocationTimeout = Duration(seconds: 12); + static const Duration _sosLocationTimeout = Duration(seconds: 16); static const Duration _sosTriageTimeout = Duration(seconds: 10); static const Duration _dispatchChannelTimeout = Duration(seconds: 8); + static const Duration _smsDispatchTimeout = Duration(seconds: 36); + static const Duration _dispatchOverallTimeout = Duration(seconds: 38); EmergencyOrchestrator(this._ref) : super(const SOSState()) { - - _restoreState(); _ref.read(crashDetectionServiceProvider).startMonitoring(); // Phase 8: ensure RL bias is loaded before any SOS fires. @@ -188,7 +193,7 @@ class EmergencyOrchestrator extends StateNotifier { appLog.d('🚒 [ORCHESTRATOR] $message'); } - Future startSos({bool isBystander = false}) async { + Future startSos({bool isBystander = false, bool isDemoMode = false}) async { if (state.phase != SOSPhase.idle) return; final isDriving = _ref.read(drivingModeProvider) == DrivingMode.driving; @@ -199,6 +204,7 @@ class EmergencyOrchestrator extends StateNotifier { isBystander: isBystander, incidentId: _uuid.v4(), dispatchChannels: const [], + isDemoMode: isDemoMode, wasInDrivingMode: isDriving, ); @@ -261,6 +267,50 @@ class EmergencyOrchestrator extends StateNotifier { final l10n = lookupAppLocalizations(_ref.read(appLocaleProvider)); final locale = _ref.read(appLocaleProvider); + if (state.isDemoMode) { + _log('Demo mode — simulated SOS pipeline only.', SOSPhase.dispatching); + state = state.copyWith( + phase: SOSPhase.dispatching, + dispatchChannels: _initialDispatchRows(), + ); + _patchDispatchChannel( + 'mesh', + DispatchChannelLifecycle.skipped, + 'Demo only — no Bluetooth beacon was started.', + ); + _patchDispatchChannel( + 'sms', + DispatchChannelLifecycle.skipped, + 'Demo only — no SMS relay, SMS composer, or 112 message was attempted.', + ); + _patchDispatchChannel( + 'voice_call', + DispatchChannelLifecycle.skipped, + 'Demo only — no emergency dialer was opened.', + ); + _patchDispatchChannel( + 'local_log', + DispatchChannelLifecycle.skipped, + 'Demo only — no incident record was saved.', + ); + _patchDispatchChannel( + 'family_link', + DispatchChannelLifecycle.skipped, + 'Demo only — no family link, WebRTC ring, or contact alert was sent.', + ); + _patchDispatchChannel( + 'nearby_services', + DispatchChannelLifecycle.skipped, + 'Demo only — no nearby-services broadcast was attempted.', + ); + state = state.copyWith(phase: SOSPhase.active); + _log( + 'Demo mode active — no real location, AI, dispatch, dialer, or family channels were contacted.', + SOSPhase.active, + ); + return; + } + Future failOpenToActive(String detail) async { _log(detail, SOSPhase.active, isError: true); state = state.copyWith( @@ -315,10 +365,24 @@ class EmergencyOrchestrator extends StateNotifier { _patchDispatchChannel('family_link', DispatchChannelLifecycle.skipped, 'Skipped — no usable GPS fix.'); _patchDispatchChannel('local_log', DispatchChannelLifecycle.failed, 'Not saved — no usable GPS fix.'); _patchDispatchChannel('sms', DispatchChannelLifecycle.inProgress, 'Sending emergency SMS (no GPS)…'); + _patchDispatchChannel( + 'voice_call', + DispatchChannelLifecycle.pending, + 'Will open emergency dialer if automated SMS is not accepted.', + ); final smsOutcome = await _dispatchSmsWithRetry( l10n.orchestratorSmsNoGpsPayload, lat: null, lng: null, + ).timeout( + _smsDispatchTimeout, + onTimeout: () => const SmsDispatchOutcome( + deviceDirectSmsSent: false, + backendRelayAccepted: false, + primaryAutomatedBarMet: false, + proofLevel: SmsDispatchProofLevel.none, + detail: 'SMS timed out — use dialer/manual SMS now.', + ), ); _patchDispatchChannel( 'sms', @@ -327,6 +391,26 @@ class EmergencyOrchestrator extends StateNotifier { : DispatchChannelLifecycle.failed, smsOutcome.detail, ); + if (smsOutcome.primaryAutomatedBarMet) { + _patchDispatchChannel( + 'voice_call', + DispatchChannelLifecycle.skipped, + 'Skipped — automated SMS request was accepted.', + ); + } else { + final emergencyNumber = EmergencySmsDispatchService.emergencyNumberForLocale(); + final dialerOpened = await _launchEmergencyDialer(emergencyNumber).timeout( + const Duration(seconds: 4), + onTimeout: () => false, + ); + _patchDispatchChannel( + 'voice_call', + dialerOpened ? DispatchChannelLifecycle.success : DispatchChannelLifecycle.failed, + dialerOpened + ? 'Emergency dialer opened for $emergencyNumber — press Call. Not dispatch proof.' + : 'Could not open emergency dialer — manually call $emergencyNumber.', + ); + } state = state.copyWith(phase: SOSPhase.active); await _persistState(true); await WakeLockService.acquireForSos(); @@ -429,19 +513,25 @@ class EmergencyOrchestrator extends StateNotifier { _patchDispatchChannel('mesh', DispatchChannelLifecycle.inProgress, 'Broadcasting BLE beacon…'); _patchDispatchChannel('sms', DispatchChannelLifecycle.inProgress, 'Sending emergency SMS…'); + _patchDispatchChannel( + 'voice_call', + DispatchChannelLifecycle.pending, + 'Will open emergency dialer only if automated SMS is not accepted.', + ); _patchDispatchChannel('local_log', DispatchChannelLifecycle.inProgress, 'Saving incident on device…'); _patchDispatchChannel('family_link', DispatchChannelLifecycle.inProgress, 'Family tracking link…'); - _patchDispatchChannel('nearby_services', DispatchChannelLifecycle.inProgress, 'Broadcasting to nearby services…'); + _patchDispatchChannel( + 'nearby_services', + DispatchChannelLifecycle.skipped, + 'No verified nearby-services broadcast is wired; showing local facilities only.', + ); // Phase 9: Automated alerts and calling - unawaited(_notifyUser()); - unawaited(_callEmergencyContact()); - - // Family Circle: start broadcasting SOS-mode live position to peers and - // try a WebRTC voice ring to the first peer (only fires if signed in to - // Supabase + the user has a circle with at least one other member). - unawaited(_publishSosToFamilyCircle(location)); - unawaited(_ringFirstFamilyCirclePeer()); + try { + await _notifyUser().timeout(const Duration(seconds: 2)); + } catch (e, st) { + appLog.w('[Orchestrator] SOS notification side effect failed', error: e, stackTrace: st); + } Future guard({ required String id, @@ -449,10 +539,11 @@ class EmergencyOrchestrator extends StateNotifier { required T fallback, required String timeoutDetail, required String failureDetail, + Duration? timeout, }) async { try { return await future.timeout( - _dispatchChannelTimeout, + timeout ?? _dispatchChannelTimeout, onTimeout: () { _patchDispatchChannel(id, DispatchChannelLifecycle.failed, timeoutDetail); return fallback; @@ -521,6 +612,7 @@ class EmergencyOrchestrator extends StateNotifier { ), timeoutDetail: 'SMS timed out — use dialer/manual SMS now.', failureDetail: 'SMS failed — use dialer/manual SMS now.', + timeout: _smsDispatchTimeout, ).then((smsOutcome) { _patchDispatchChannel( 'sms', @@ -570,22 +662,7 @@ class EmergencyOrchestrator extends StateNotifier { return family; }); - final nearbyFuture = guard( - id: 'nearby_services', - future: Future.delayed(const Duration(seconds: 3), () => true), - fallback: false, - timeoutDetail: 'Nearby services broadcast timed out.', - failureDetail: 'Nearby services broadcast failed.', - ).then((ok) { - _patchDispatchChannel( - 'nearby_services', - ok ? DispatchChannelLifecycle.success : DispatchChannelLifecycle.failed, - ok - ? 'Emergency alert broadcasted to nearby facilities and responders ✓' - : 'Could not complete nearby services broadcast.', - ); - return ok; - }); + final nearbyFuture = Future.value(false); List results; try { @@ -595,7 +672,7 @@ class EmergencyOrchestrator extends StateNotifier { persistedFuture, familyFuture, nearbyFuture, - ]).timeout(_dispatchChannelTimeout + const Duration(seconds: 1)); + ]).timeout(_dispatchOverallTimeout); } catch (e, st) { // Absolute guard: never hang in dispatching. appLog.w('[Orchestrator] Dispatch futures did not complete in time', error: e, stackTrace: st); @@ -604,6 +681,26 @@ class EmergencyOrchestrator extends StateNotifier { } final smsOutcome = results[1] as SmsDispatchOutcome; + if (smsOutcome.primaryAutomatedBarMet) { + _patchDispatchChannel( + 'voice_call', + DispatchChannelLifecycle.skipped, + 'Skipped — automated SMS request was accepted.', + ); + } else { + final emergencyNumber = EmergencySmsDispatchService.emergencyNumberForLocale(); + final dialerOpened = await _launchEmergencyDialer(emergencyNumber).timeout( + const Duration(seconds: 4), + onTimeout: () => false, + ); + _patchDispatchChannel( + 'voice_call', + dialerOpened ? DispatchChannelLifecycle.success : DispatchChannelLifecycle.failed, + dialerOpened + ? 'Emergency dialer opened for $emergencyNumber — press Call. Not dispatch proof.' + : 'Could not open emergency dialer — manually call $emergencyNumber.', + ); + } await SosActivityLogService.instance.append( SosActivityRecord( @@ -703,6 +800,12 @@ class EmergencyOrchestrator extends StateNotifier { lifecycle: DispatchChannelLifecycle.pending, detail: 'Waiting…', ), + DispatchChannelRow( + id: 'voice_call', + title: 'Emergency dialer fallback', + lifecycle: DispatchChannelLifecycle.pending, + detail: 'Waiting…', + ), DispatchChannelRow( id: 'local_log', title: 'On-device incident log', @@ -717,7 +820,7 @@ class EmergencyOrchestrator extends StateNotifier { ), DispatchChannelRow( id: 'nearby_services', - title: 'Nearby Services', + title: 'Nearby facilities (info only)', lifecycle: DispatchChannelLifecycle.pending, detail: 'Waiting…', ), @@ -798,6 +901,21 @@ class EmergencyOrchestrator extends StateNotifier { void triggerSOS() => startSos(); void cancelSOS() => cancelSos(); + Future _launchEmergencyDialer(String number) async { + final uri = Uri.parse('tel:$number'); + try { + if (await canLaunchUrl(uri)) { + await launchUrl(uri); + appLog.i('[Orchestrator] Emergency dialer opened for $number'); + return true; + } + appLog.w('[Orchestrator] Could not launch emergency dialer for $number'); + } catch (e, st) { + appLog.e('[Orchestrator] Error launching emergency dialer', error: e, stackTrace: st); + } + return false; + } + Future _callEmergencyContact() async { final profile = _ref.read(userProfileProvider); final contact = profile.emergencyContact.trim(); diff --git a/lib/services/emergency_sms_dispatch_service.dart b/lib/services/emergency_sms_dispatch_service.dart index f610aaf..0558b4d 100644 --- a/lib/services/emergency_sms_dispatch_service.dart +++ b/lib/services/emergency_sms_dispatch_service.dart @@ -18,9 +18,8 @@ import 'sms_dispatch_outcome.dart'; /// - **India**: Prefer [INDIA_SOS_DISPATCH_URL], optional [INDIA_ERSS_API_URL] (MHA/CDAC enrollment). /// /// **Dispatch success contract (v1 India / Android):** -/// - **(A)** Primary automated bar: [SmsDispatchOutcome.primaryAutomatedBarMet] — **device** [SEND_SMS] to -/// 112/911, *or* (iOS only) HTTP relay 2xx, *or* (Android) HTTP relay 2xx only if -/// `SMS_RELAY_COUNTS_AS_PRIMARY_DISPATCH=true` (audited backend that actually delivers to 112). +/// - **(A)** Primary automated bar: [SmsDispatchOutcome.primaryAutomatedBarMet] — a confirmed +/// automated OS/backend handoff, not merely opening a manual SMS composer. /// - **(B)** Parallel **108** dial / USSD — [IndiaOfflineDispatch]; dialer only, not dispatch proof. /// - **(C)** [INDIA_ERSS_API_URL] is optional telemetry; never gates outcome. class EmergencySmsDispatchService { @@ -283,11 +282,11 @@ class EmergencySmsDispatchService { final directOk = await sendSmsDirectAndroid(number, body); if (directOk) { - appLog.d('Android direct SMS send'); + appLog.d('Android SMS composer opened; user must press Send'); return _outcome( - device: true, + device: false, relay: false, - detail: 'Device SMS request accepted for $number ✓ (carrier delivery not confirmed)', + detail: 'SMS composer opened for $number — press Send. Not counted as automated dispatch.', ); } diff --git a/lib/services/sms_direct_send_io.dart b/lib/services/sms_direct_send_io.dart index ef15923..9e69b99 100644 --- a/lib/services/sms_direct_send_io.dart +++ b/lib/services/sms_direct_send_io.dart @@ -13,9 +13,9 @@ import '../logging/app_log.dart'; /// - `url_launcher` is already a dependency; `sms:` URIs open the native /// Messaging app with the number and body pre-filled. /// -/// The native SMS app always works — no special permission, no Android version -/// restriction, no Google Play policy risk. The user taps Send once; -/// the pre-filled message is 160–220 chars (well within one SMS). +/// The native SMS app path is a manual fallback: it opens a pre-filled +/// composer and the user still must tap Send. Opening this composer is not +/// proof that an emergency SMS was sent. /// /// For automated background dispatch (Twilio / Edge Function), see /// [EmergencySmsDispatchService._dispatchAndroid] — that path fires first and diff --git a/lib/services/triage_validation_agent.dart b/lib/services/triage_validation_agent.dart index d1caccd..987826a 100644 --- a/lib/services/triage_validation_agent.dart +++ b/lib/services/triage_validation_agent.dart @@ -36,10 +36,25 @@ class TriageValidationAgent { required double gyroPeakRadPerSec, required int accelSeverityHint, }) { - var severity = raw.severityLevel; - final services = List.from(raw.requiredServices); final flags = []; final overrides = []; + var severity = raw.severityLevel.clamp(1, 5); + if (severity != raw.severityLevel) { + overrides.add( + 'Severity clamped ${raw.severityLevel}→$severity (outside schema range 1–5).', + ); + flags.add('invalid_severity_clamped'); + } + + final services = []; + for (final service in raw.requiredServices) { + if (_allowedServices.contains(service)) { + if (!services.contains(service)) services.add(service); + } else { + flags.add('invalid_service_dropped'); + overrides.add('Unsupported service "$service" removed from dispatch list.'); + } + } // ── Rule A: Severity floor from accelerometer sensor hint ───────────── // The sensor-derived hint is a hard lower bound: if Gemma says severity 2 @@ -129,7 +144,9 @@ class TriageValidationAgent { severityLevel: severity, requiredServices: services, firstAidQuery: raw.firstAidQuery, - compressedPayload: raw.compressedPayload, + compressedPayload: wasOverridden + ? _buildCompressedPayload(raw.location, severity, services) + : raw.compressedPayload, thinkingTrace: raw.thinkingTrace, isDegradedMode: raw.isDegradedMode, source: raw.source, @@ -148,6 +165,47 @@ class TriageValidationAgent { ); } + String _buildCompressedPayload( + String location, + int severity, + List services, + ) { + final svcCodes = services.map((s) { + switch (s) { + case 'ambulance': + return 'AMB'; + case 'police': + return 'POL'; + case 'fire_department': + return 'FIR'; + case 'rescue': + return 'RES'; + case 'towing': + return 'TOW'; + case 'puncture_shop': + return 'PUN'; + case 'showroom': + return 'SHR'; + default: + return 'UNK'; + } + }).join(','); + + final loc = location.replaceAll(' ', '_'); + final clipped = loc.length <= 30 ? loc : loc.substring(0, 30); + return 'LOC:$clipped|SEV:$severity|SVC:$svcCodes'; + } + + static const Set _allowedServices = { + 'ambulance', + 'police', + 'fire_department', + 'rescue', + 'towing', + 'puncture_shop', + 'showroom', + }; + /// Confidence score from source tier and signal quality. /// /// Reflects how reliable the triage result is. Shown to the user in the diff --git a/lib/ui/dashboard.dart b/lib/ui/dashboard.dart index d8e7122..c547d41 100644 --- a/lib/ui/dashboard.dart +++ b/lib/ui/dashboard.dart @@ -482,7 +482,7 @@ class _DashboardScreenState extends ConsumerState color: const Color(0xFFB388FF), title: 'Demo Mode (judges + first-time users)', subtitle: - 'Simulate a crash to walk the full SOS pipeline end-to-end. No real SMS or 112 dispatched.', + 'Simulated countdown and status panel only. No GPS, AI, SMS, 112 dialer, or family alerts.', onTap: () => _runDemoMode(context), ), @@ -870,9 +870,9 @@ class _DashboardScreenState extends ConsumerState // ────────────────────────────────────────────────────────────────────── // Demo Mode — surfaces a clearly-labelled simulated crash so first-time - // users and judges can walk the full SOS pipeline (countdown → bystander - // mode → AI triage → dispatch panel → Bystander Coach hand-off) without - // ever sending real SMS, dialing 112, or waking up Family Circle peers. + // users and judges can rehearse the SOS shell (countdown → simulated + // dispatch panel → Bystander Coach hand-off) without touching real GPS, + // AI, SMS, 112 dialer, incident storage, or Family Circle peers. // Per `critical-feature-audit.mdc`: Simulated content MUST be labelled // simulated — done via the "SIMULATED" banner in the confirmation dialog. @@ -882,10 +882,10 @@ class _DashboardScreenState extends ConsumerState builder: (ctx) => AlertDialog( title: const Text('Demo Mode — SIMULATED crash'), content: const Text( - 'Walks every screen in the SOS pipeline so you can see what RoadSOS does on a real accident.\n\n' + 'Walks the SOS countdown and status surfaces without contacting real emergency systems.\n\n' 'This is fully simulated:\n' ' • Bystander-mode SOS countdown starts (not self-SOS).\n' - ' • No real SMS, 112 dial, or WebRTC ring to your Family Circle.\n' + ' • No GPS, AI triage, SMS, 112 dialer, incident log, or WebRTC ring.\n' ' • The Bystander Coach screen opens after dispatch so you can rehearse the first-aid voice flow.\n' '\nTap CANCEL on the SOS countdown any time to abort.', ), @@ -908,7 +908,9 @@ class _DashboardScreenState extends ConsumerState // baseline; even if dispatch fails (no Supabase / no SMS perm), the // countdown + status panel still walk the same code path the real SOS // does, which is the entire point of the demo. - await ref.read(emergencyOrchestratorProvider.notifier).startSos(isBystander: true); + await ref + .read(emergencyOrchestratorProvider.notifier) + .startSos(isBystander: true, isDemoMode: true); // Auto-open the Bystander Coach so the rehearsal hits the on-device // Gemma-4 voice flow without the user having to tap a second time. diff --git a/lib/ui/sos_side_effect_observer.dart b/lib/ui/sos_side_effect_observer.dart index 4cc9542..aa62f30 100644 --- a/lib/ui/sos_side_effect_observer.dart +++ b/lib/ui/sos_side_effect_observer.dart @@ -24,7 +24,7 @@ class SOSSideEffectObserver extends ConsumerWidget { case SOSPhase.active: HapticFeedback.heavyImpact(); ref.read(voiceAssistantServiceProvider).speak( - 'SOS is live. Help is on the way. Your location and medical profile are being broadcasted.' + 'SOS is active. Check the dispatch status. If you can, call emergency services now.' ); break; case SOSPhase.triaging: diff --git a/lib/ui/triage_result_card.dart b/lib/ui/triage_result_card.dart index 08b680f..e42deaa 100644 --- a/lib/ui/triage_result_card.dart +++ b/lib/ui/triage_result_card.dart @@ -205,6 +205,14 @@ class TriageResultCard extends StatelessWidget { ), ), ), + const SizedBox(height: 8), + Text( + l10n.triageMedicalDisclaimer, + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: scheme.onPrimaryContainer.withAlpha(210), + fontWeight: FontWeight.w700, + ), + ), ], ), ), diff --git a/supabase/functions/sms-dispatch/index.ts b/supabase/functions/sms-dispatch/index.ts index a4ac2b5..5481a70 100644 --- a/supabase/functions/sms-dispatch/index.ts +++ b/supabase/functions/sms-dispatch/index.ts @@ -22,6 +22,7 @@ import "jsr:@supabase/functions-js/edge-runtime.d.ts"; type ReqBody = { payload: string; + body?: string; latitude?: number; longitude?: number; severity_level?: number; @@ -37,14 +38,34 @@ function json(status: number, body: unknown) { }); } -function resolveEmergencyNumber(countryCode?: string, override?: string): string { +function resolveEmergencyNumber( + countryCode?: string, + override?: string, + services: string[] = [] +): string { if (override?.trim()) return override.trim(); const uses911 = new Set(["US", "CA", "MX"]); if (countryCode && uses911.has(countryCode)) return "911"; + if (countryCode === "IN") { + const normalized = new Set(services.map((s) => s.toLowerCase())); + const needsFire = normalized.has("fire_department"); + const needsAmbulance = normalized.has("ambulance"); + const needsPolice = normalized.has("police"); + + if (needsFire && !needsAmbulance && !needsPolice) return "101"; + if (needsAmbulance && !needsFire && !needsPolice) return "108"; + if (needsPolice && !needsAmbulance && !needsFire) return "100"; + return "112"; + } return "112"; } function buildSmsBody(body: ReqBody, emergencyNumber: string): string { + const clientBody = typeof body.body === "string" ? body.body.trim() : ""; + if (clientBody) { + return clientBody.slice(0, 1500); + } + const loc = body.latitude != null && body.longitude != null ? `GPS: ${body.latitude.toFixed(5)},${body.longitude.toFixed(5)} ` @@ -58,7 +79,8 @@ function buildSmsBody(body: ReqBody, emergencyNumber: string): string { ? `maps.google.com/?q=${body.latitude.toFixed(5)},${body.longitude.toFixed(5)}` : ""; - const core = `RoadSOS EMERGENCY ${sev}${svcs}${loc}${maps}`.trim(); + const payload = body.payload?.trim() ? `MSG: ${body.payload.trim()}` : ""; + const core = `RoadSOS EMERGENCY ${sev}${svcs}${loc}${maps} ${payload}`.trim(); return core.slice(0, 1500); } @@ -135,14 +157,21 @@ Deno.serve(async (req: Request) => { const twilioSid = Deno.env.get("TWILIO_ACCOUNT_SID")?.trim(); const twilioToken = Deno.env.get("TWILIO_AUTH_TOKEN")?.trim(); const twilioFrom = Deno.env.get("TWILIO_FROM_NUMBER")?.trim(); + const dispatchBearer = Deno.env.get("SMS_DISPATCH_ANON_KEY")?.trim(); - if (!twilioSid || !twilioToken || !twilioFrom) { + if (!twilioSid || !twilioToken || !twilioFrom || !dispatchBearer) { return json(500, { error: "server_misconfigured", - detail: "Missing TWILIO_ACCOUNT_SID / TWILIO_AUTH_TOKEN / TWILIO_FROM_NUMBER", + detail: + "Missing TWILIO_ACCOUNT_SID / TWILIO_AUTH_TOKEN / TWILIO_FROM_NUMBER / SMS_DISPATCH_ANON_KEY", }); } + const auth = req.headers.get("authorization") ?? ""; + if (auth !== `Bearer ${dispatchBearer}`) { + return json(401, { error: "unauthorized" }); + } + let body: ReqBody; try { body = (await req.json()) as ReqBody; @@ -156,7 +185,8 @@ Deno.serve(async (req: Request) => { const emergencyNumber = resolveEmergencyNumber( body.country_code, - body.destination ?? Deno.env.get("EMERGENCY_NUMBER_OVERRIDE") + Deno.env.get("EMERGENCY_NUMBER_OVERRIDE"), + body.required_services ?? [] ); const smsBody = buildSmsBody(body, emergencyNumber); diff --git a/test/orchestrator_test.dart b/test/orchestrator_test.dart index 7a75881..540c78b 100644 --- a/test/orchestrator_test.dart +++ b/test/orchestrator_test.dart @@ -20,5 +20,12 @@ void main() { final newState = state.copyWith(phase: SOSPhase.active); expect(newState.incidentId, 'test-123'); }); + + test('copyWith preserves demo mode unless changed', () { + const state = SOSState(isDemoMode: true); + final newState = state.copyWith(phase: SOSPhase.dispatching); + expect(newState.isDemoMode, true); + expect(newState.copyWith(isDemoMode: false).isDemoMode, false); + }); }); } diff --git a/test/triage_validation_agent_test.dart b/test/triage_validation_agent_test.dart index 052ec8f..504ca61 100644 --- a/test/triage_validation_agent_test.dart +++ b/test/triage_validation_agent_test.dart @@ -94,5 +94,35 @@ void main() { expect(r.overrideNotes, isNotEmpty); expect(r.triage.wasOverridden, isTrue); }); + + test('rebuilds compressed payload after safety overrides', () { + final raw = _base(severity: 2, services: ['police']); + final r = triageValidationAgent.validate( + raw: raw, + drivingMode: DrivingMode.driving, + gyroPeakRadPerSec: 0, + accelSeverityHint: 2, + ); + + expect(r.triage.compressedPayload, contains('SEV:3')); + expect(r.triage.compressedPayload, contains('AMB')); + expect(r.triage.compressedPayload, isNot(contains('payload'))); + }); + + test('clamps invalid severity and drops unsupported services', () { + final raw = _base(severity: 8, services: ['helicopter', 'ambulance']); + final r = triageValidationAgent.validate( + raw: raw, + drivingMode: DrivingMode.stationary, + gyroPeakRadPerSec: 0, + accelSeverityHint: 1, + ); + + expect(r.triage.severityLevel, 5); + expect(r.triage.requiredServices, isNot(contains('helicopter'))); + expect(r.triage.requiredServices, contains('ambulance')); + expect(r.flags, contains('invalid_severity_clamped')); + expect(r.flags, contains('invalid_service_dropped')); + }); }); }