diff --git a/ios/Runner/AppDelegate.swift b/ios/Runner/AppDelegate.swift index 8d3392a2..b340b48c 100644 --- a/ios/Runner/AppDelegate.swift +++ b/ios/Runner/AppDelegate.swift @@ -15,6 +15,8 @@ import flutter_foreground_task private var voipDiagnosticsChannel: FlutterMethodChannel? private let voipDiagnosticsStorageKey = "EatvioVoipDiagnostics" private let pendingAcceptedVoipCallKey = "EatvioPendingAcceptedVoipCall" + private let pendingAcceptedAtKey = "eatvioAcceptedAt" + private let pendingAcceptedCallTtl: TimeInterval = 120 private let voipDiagnosticsChannelName = "com.eatvioapp/voip_diagnostics" private let callAudioSessionChannelName = "com.eatvioapp/call_audio_session" private let appUrlScheme = "eatvio" @@ -234,6 +236,26 @@ import flutter_foreground_task return nil } + private func voipBool(_ value: Any?) -> Bool? { + if let bool = value as? Bool { + return bool + } + if let number = value as? NSNumber { + return number.intValue != 0 + } + if let text = value as? String { + switch text.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() { + case "1", "true", "yes": + return true + case "0", "false", "no": + return false + default: + return nil + } + } + return nil + } + private func conversationUuid(fromActionUrl actionUrl: String?) -> String? { guard let actionUrl = actionUrl, @@ -282,16 +304,31 @@ import flutter_foreground_task } private func storePendingAcceptedCall(_ call: Call) { - let payload = sanitizedDiagnosticValue(call.data.toJSON()) as? [String: Any] ?? [:] + var payload = sanitizedDiagnosticValue(call.data.toJSON()) as? [String: Any] ?? [:] + payload[pendingAcceptedAtKey] = Date().timeIntervalSince1970 UserDefaults.standard.set(payload, forKey: pendingAcceptedVoipCallKey) } + /// An accept that Flutter never consumed (the process died before the + /// scene opened) must not open the call screen on a later launch: that + /// screen requests a token, and the token request rings the other person. private func drainPendingAcceptedVoipCall() -> [String: Any]? { let defaults = UserDefaults.standard - guard let payload = defaults.dictionary(forKey: pendingAcceptedVoipCallKey) else { + guard var payload = defaults.dictionary(forKey: pendingAcceptedVoipCallKey) else { return nil } defaults.removeObject(forKey: pendingAcceptedVoipCallKey) + let acceptedAt = (payload.removeValue(forKey: pendingAcceptedAtKey) as? NSNumber)?.doubleValue + if let acceptedAt, Date().timeIntervalSince1970 - acceptedAt > pendingAcceptedCallTtl { + recordVoipDiagnostic( + "pending_accepted_call_expired", + data: [ + "callKitId": payload["id"] as? String ?? "", + "ageSeconds": Date().timeIntervalSince1970 - acceptedAt, + ] + ) + return nil + } return payload } @@ -515,6 +552,13 @@ import flutter_foreground_task handle: conversation, type: 1 ) + // The plugin sends this payload with its "ended" event. Dart uses the + // marker to tell a remote end from a decline by the user. + var endExtra: [String: Any] = ["endedPush": true] + if !conversation.isEmpty { + endExtra["conversationUuid"] = conversation + } + endData.extra = endExtra as NSDictionary plugin.endCall(endData) releaseIncomingCallClaim(id) } @@ -686,12 +730,18 @@ import flutter_foreground_task } else if !nameCaller.isEmpty { extra["counterpartName"] = nameCaller } + if let isConsultant = voipBool(dict["is_consultant"] ?? dict["isConsultant"]) { + extra["counterpartIsConsultant"] = isConsultant + } // Closing this report looks like a user decline. Dart must not tell the // backend, or the server sends another ended VoIP and the other phone // shows it as a new incoming call. if isEndedPush { extra["endedPush"] = true } + // An ended push with no ringing call still has to be reported. That + // report is closed at once, so it must not leave a trace anywhere. + let isPhantomEndedReport = isEndedPush && callKitIdSourceName != "active_conversation" let data = flutter_callkit_incoming.Data( id: id, @@ -699,8 +749,15 @@ import flutter_foreground_task handle: trimmedConversationUuid.isEmpty ? nameCaller : trimmedConversationUuid, type: isVideo ? 1 : 0 ) + data.appName = "Итвио" + data.handleType = "generic" + data.supportsVideo = true data.duration = 45000 data.extra = extra as NSDictionary + data.missedNotificationSubtitle = "Пропущенный видеозвонок" + data.missedNotificationCallbackText = "Перезвонить" + data.isShowMissedCallNotification = !isEndedPush + data.includesCallsInRecents = !isPhantomEndedReport // The plugin activates AVAudioSession itself on answer and again 1.2s // later. That drops the ringing UI and leaves one side silent. CallKit // and WebRTC own the session instead. @@ -736,18 +793,25 @@ import flutter_foreground_task callKitIdSourceName: callKitIdSourceName, conversationUuid: conversationUuid, endAfterReport: isEndedPush, + endImmediately: isPhantomEndedReport, completion: completion ) } /// PushKit kills the app unless every VoIP push is reported to CallKit and /// its completion handler runs. Both happen here, including an "ended" push. + /// + /// `endImmediately` is for an ended push that matched no ringing call. The + /// report is closed inside its own completion, before the incoming UI has a + /// chance to show. An end transaction would take one more round trip and + /// would also reach `onDecline`, which resets the in-app audio restart. private func reportVoipPushToCallKit( _ data: flutter_callkit_incoming.Data, callKitId: String, callKitIdSourceName: String, conversationUuid: String, endAfterReport: Bool, + endImmediately: Bool, completion: @escaping () -> Void ) { let gate = VoipPushCompletion(completion) @@ -757,6 +821,12 @@ import flutter_foreground_task } let afterReport = { + if endAfterReport, endImmediately, + let plugin = SwiftFlutterCallkitIncomingPlugin.sharedInstance + { + // Reason 6 is CXCallEndedReason.remoteEnded in the plugin. + plugin.saveEndCall(callKitId, 6) + } if endAfterReport { DispatchQueue.main.async { self.endCallsForEndedPush( diff --git a/lib/core/firebase_api.dart b/lib/core/firebase_api.dart index cf4496de..d18024b3 100644 --- a/lib/core/firebase_api.dart +++ b/lib/core/firebase_api.dart @@ -69,13 +69,13 @@ class FirebaseApi { } Future registerPushToken() async { - if (await TokenStorage.haveTokenInStorage()) { - try { - await _requestNotificationPermission(); - final deviceInfo = DeviceInfoPlugin(); - final credentials = await _resolvePushCredentials(); - final token = credentials.token; - if (token == null || token.isEmpty) return; + if (!await TokenStorage.haveTokenInStorage()) return; + try { + await _requestNotificationPermission(); + final deviceInfo = DeviceInfoPlugin(); + final credentials = await _resolvePushCredentials(); + final token = credentials.token; + if (token != null && token.isNotEmpty) { await sl().post( '/api/profile/notification/token/register', data: { @@ -89,14 +89,17 @@ class FirebaseApi { throw TimeoutException('registerPushToken timeout'); }, ); - if (Platform.isIOS) { - await _registerIosVoipPushToken(); - } - } catch (e, st) { - log(e.toString(), stackTrace: st); - await _errorReporter.report(e, st, origin: 'FirebaseApi'); - // Не пробрасываем ошибку дальше, так как это не критичная операция } + } catch (e, st) { + log(e.toString(), stackTrace: st); + await _errorReporter.report(e, st, origin: 'FirebaseApi'); + // Не пробрасываем ошибку дальше, так как это не критичная операция + } + // Incoming calls on iOS come only through the VoIP token. It does not + // depend on the FCM token, so a missing or failed FCM registration must + // not leave the device without calls. + if (Platform.isIOS) { + await _registerIosVoipPushToken(); } } diff --git a/lib/features/video_calls/services/video_call_callkit_coordinator.dart b/lib/features/video_calls/services/video_call_callkit_coordinator.dart index 523f41fa..f57b6745 100644 --- a/lib/features/video_calls/services/video_call_callkit_coordinator.dart +++ b/lib/features/video_calls/services/video_call_callkit_coordinator.dart @@ -10,6 +10,7 @@ import 'package:eatvio_app/features/video_calls/presentation/bloc/video_call_ses import 'package:eatvio_app/features/video_calls/services/video_call_fcm_handler.dart'; import 'package:eatvio_app/features/video_calls/services/video_call_peer_departure.dart'; import 'package:eatvio_app/features/video_calls/services/video_call_notification_navigator.dart'; +import 'package:eatvio_app/features/video_calls/services/video_call_remote_end_registry.dart'; import 'package:flutter/services.dart'; import 'package:flutter/widgets.dart'; import 'package:flutter_callkit_incoming/entities/call_event.dart'; @@ -375,7 +376,7 @@ class VideoCallCallKitCoordinator { ); return; } - await _handleEnded(id); + await _handleEnded(id, eventExtra: _callKitEventParams(event)?.extra); } Future _captureCallKitEventToSentry(CallEvent event) async { @@ -873,10 +874,24 @@ class VideoCallCallKitCoordinator { extra, handle: params?.handle ?? callKitId, ); - final endedPushDismissal = callKitDeclineIsEndedPush( - cachedExtra: params?.extra, - eventExtra: eventExtra, - ); + final remoteEndDismissal = + callKitDeclineIsEndedPush( + cachedExtra: params?.extra, + eventExtra: eventExtra, + ) || + VideoCallRemoteEndRegistry.contains(callKitId); + + if (remoteEndDismissal) { + // The call is already closed on the native side. Another endCall is + // not needed: while the plugin's push flag is set it would end the + // last reported call instead of this id, which may be a newer ring. + log( + 'VideoCallCallKitCoordinator: remote end dismissal, callKitId=$callKitId', + name: 'VideoCallCallKit', + ); + _forgetCallKitId(callKitId); + return; + } _suppressedEndCallKitIds.add(callKitId); try { @@ -888,12 +903,7 @@ class VideoCallCallKitCoordinator { }), ); } - _callKitParamsById.remove(callKitId); - if (_activeCallKitId == callKitId) { - _activeCallKitId = null; - } - - if (endedPushDismissal) return; + _forgetCallKitId(callKitId); if (_manager.isActiveCall || _cubit.state.connectPhase != VideoCallConnectPhase.idle) { @@ -907,7 +917,10 @@ class VideoCallCallKitCoordinator { } } - Future _handleEnded(String callKitId) async { + Future _handleEnded( + String callKitId, { + Map? eventExtra, + }) async { if (_suppressedEndCallKitIds.contains(callKitId)) { log( 'VideoCallCallKitCoordinator: ignore spurious ended during accept, callKitId=$callKitId', @@ -916,16 +929,36 @@ class VideoCallCallKitCoordinator { return; } - _callKitParamsById.remove(callKitId); - if (_activeCallKitId == callKitId) { - _activeCallKitId = null; + // iOS reports an ended VoIP push to CallKit and closes it right away. + // That close is marked and must be remembered before the decline (or the + // later timeout) event for the same id arrives with no marker. + final remoteEnd = callKitDeclineIsEndedPush( + cachedExtra: _paramsForCallId(callKitId)?.extra, + eventExtra: eventExtra, + ); + if (remoteEnd) { + VideoCallRemoteEndRegistry.mark(callKitId); } + _forgetCallKitId(callKitId); + + // Native already ends a remote-ended call. Calling endCall again here + // overwrites the plugin's stored payload, so the decline that follows + // would lose the marker. + if (remoteEnd) return; + try { await FlutterCallkitIncoming.endCall(callKitId); } catch (_) {} } + void _forgetCallKitId(String callKitId) { + _callKitParamsById.remove(callKitId); + if (_activeCallKitId == callKitId) { + _activeCallKitId = null; + } + } + static String _parseConversationUuid( Map extra, { String? handle, diff --git a/lib/features/video_calls/services/video_call_ended_push_handler.dart b/lib/features/video_calls/services/video_call_ended_push_handler.dart index 5da0b1ca..9f50cc39 100644 --- a/lib/features/video_calls/services/video_call_ended_push_handler.dart +++ b/lib/features/video_calls/services/video_call_ended_push_handler.dart @@ -2,6 +2,7 @@ import 'dart:io'; import 'package:eatvio_app/core/locator_service.dart'; import 'package:eatvio_app/features/video_calls/presentation/bloc/video_call_session/video_call_session_cubit.dart'; +import 'package:eatvio_app/features/video_calls/services/video_call_remote_end_registry.dart'; import 'package:flutter/services.dart'; import 'package:flutter_callkit_incoming/entities/call_kit_params.dart'; import 'package:flutter_callkit_incoming/flutter_callkit_incoming.dart'; @@ -53,16 +54,23 @@ abstract final class VideoCallEndedPushHandler { return false; } + /// The close of a ringing call comes back from the plugin as a decline. + /// Each id is marked first, so the coordinator does not report that + /// decline to the backend as if the user had rejected the call. static Future _dismissViaCallKit(String? conversationUuid) async { final uuid = conversationUuid?.trim(); try { final activeCalls = await FlutterCallkitIncoming.activeCalls(); if (uuid == null || uuid.isEmpty) { + for (final call in activeCalls) { + VideoCallRemoteEndRegistry.mark(call.id); + } await FlutterCallkitIncoming.endAllCalls(); return; } for (final call in activeCalls) { if (!callMatchesConversation(call, uuid)) continue; + VideoCallRemoteEndRegistry.mark(call.id); try { await FlutterCallkitIncoming.endCall(call.id); } catch (_) {} diff --git a/lib/features/video_calls/services/video_call_incoming_push_presenter.dart b/lib/features/video_calls/services/video_call_incoming_push_presenter.dart index f8ae7cef..1751ebc8 100644 --- a/lib/features/video_calls/services/video_call_incoming_push_presenter.dart +++ b/lib/features/video_calls/services/video_call_incoming_push_presenter.dart @@ -20,6 +20,10 @@ typedef VideoCallAndroidIncomingShow = /// never arrived. abstract final class VideoCallIncomingPushPresenter { static const String _androidAccentColor = '#2787F5'; + + /// Same texts as the native VoIP path in `AppDelegate.swift`. + static const String iosMissedCallSubtitle = 'Пропущенный видеозвонок'; + static const String iosMissedCallCallbackText = 'Перезвонить'; static const MethodChannel _voipChannel = MethodChannel( 'com.eatvioapp/voip_diagnostics', ); @@ -97,6 +101,11 @@ abstract final class VideoCallIncomingPushPresenter { 'counterpartIsConsultant': payload.counterpartIsConsultant, }, callingNotification: const NotificationParams(showNotification: false), + missedCallNotification: const NotificationParams( + showNotification: true, + subtitle: iosMissedCallSubtitle, + callbackText: iosMissedCallCallbackText, + ), android: const AndroidParams( isCustomNotification: true, isShowLogo: false, diff --git a/lib/features/video_calls/services/video_call_remote_end_registry.dart b/lib/features/video_calls/services/video_call_remote_end_registry.dart new file mode 100644 index 00000000..47a423db --- /dev/null +++ b/lib/features/video_calls/services/video_call_remote_end_registry.dart @@ -0,0 +1,37 @@ +/// CallKit ids that were closed because the call ended remotely. +/// +/// On iOS every close of a ringing call comes back from the plugin as a +/// decline, whether the user tapped Decline or the app ended the call after a +/// `video_call_ended` signal. Only a decline by the user may be reported to +/// the backend; the other kind would make the server send one more ended +/// push and the other phone would show it as a new incoming call. +abstract final class VideoCallRemoteEndRegistry { + /// Longer than the 45 s ring timeout: the plugin still emits a timeout + /// event for a call it reported and closed right away. + static const Duration ttl = Duration(minutes: 2); + + static final Map _markedAt = {}; + + static void mark(String callKitId, {DateTime? now}) { + final id = _normalize(callKitId); + if (id.isEmpty) return; + final at = now ?? DateTime.now(); + _prune(at); + _markedAt[id] = at; + } + + static bool contains(String callKitId, {DateTime? now}) { + final id = _normalize(callKitId); + if (id.isEmpty) return false; + _prune(now ?? DateTime.now()); + return _markedAt.containsKey(id); + } + + static void clear() => _markedAt.clear(); + + static String _normalize(String callKitId) => callKitId.trim().toLowerCase(); + + static void _prune(DateTime now) { + _markedAt.removeWhere((_, markedAt) => now.difference(markedAt) >= ttl); + } +} diff --git a/pubspec.yaml b/pubspec.yaml index 65b8ffaf..d5782df6 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,6 +1,6 @@ name: eatvio_app description: A new Flutter project. -version: 1.3.57+146 +version: 1.3.58+147 publish_to: 'none' environment: diff --git a/test/features/video_calls/services/video_call_ended_push_handler_test.dart b/test/features/video_calls/services/video_call_ended_push_handler_test.dart index 62bbe6a8..90df2f1a 100644 --- a/test/features/video_calls/services/video_call_ended_push_handler_test.dart +++ b/test/features/video_calls/services/video_call_ended_push_handler_test.dart @@ -1,9 +1,15 @@ import 'package:eatvio_app/features/video_calls/services/video_call_ended_push_handler.dart'; +import 'package:eatvio_app/features/video_calls/services/video_call_remote_end_registry.dart'; +import 'package:flutter/services.dart'; import 'package:flutter_callkit_incoming/entities/call_kit_params.dart'; import 'package:flutter_test/flutter_test.dart'; void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + const conversation = '11111111-1111-4111-8111-111111111111'; + const otherConversation = '22222222-2222-4222-8222-222222222222'; + const callKitChannel = MethodChannel('flutter_callkit_incoming'); group('VideoCallEndedPushHandler.callMatchesConversation', () { test('matches the conversation stored as the call handle', () { @@ -32,10 +38,7 @@ void main() { }); test('leaves a call from another chat alone', () { - const call = CallKitParams( - id: 'other-id', - handle: '22222222-2222-4222-8222-222222222222', - ); + const call = CallKitParams(id: 'other-id', handle: otherConversation); expect( VideoCallEndedPushHandler.callMatchesConversation(call, conversation), @@ -43,4 +46,62 @@ void main() { ); }); }); + + group('VideoCallEndedPushHandler.dismissIncomingCallUi (CallKit)', () { + final calls = []; + late List> activeCalls; + + setUp(() { + calls.clear(); + VideoCallRemoteEndRegistry.clear(); + activeCalls = >[ + { + 'id': 'ringing-id', + 'handle': conversation, + 'extra': {'conversationUuid': conversation}, + }, + { + 'id': 'other-id', + 'handle': otherConversation, + 'extra': {'conversationUuid': otherConversation}, + }, + ]; + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(callKitChannel, (call) async { + calls.add(call); + if (call.method == 'activeCalls') return activeCalls; + return null; + }); + }); + + tearDown(() { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(callKitChannel, null); + }); + + test('ends only the matching call and marks it as a remote end', () async { + await VideoCallEndedPushHandler.dismissIncomingCallUi( + conversationUuid: conversation, + ); + + final ended = calls + .where((call) => call.method == 'endCall') + .map((call) => (call.arguments as Map)['id']) + .toList(); + expect(ended, ['ringing-id']); + expect(VideoCallRemoteEndRegistry.contains('ringing-id'), isTrue); + expect(VideoCallRemoteEndRegistry.contains('other-id'), isFalse); + }); + + test( + 'without a conversation ends every call and marks all of them', + () async { + await VideoCallEndedPushHandler.dismissIncomingCallUi(); + + expect(calls.map((call) => call.method), contains('endAllCalls')); + expect(VideoCallRemoteEndRegistry.contains('ringing-id'), isTrue); + expect(VideoCallRemoteEndRegistry.contains('other-id'), isTrue); + }, + ); + }); } diff --git a/test/features/video_calls/services/video_call_incoming_push_presenter_test.dart b/test/features/video_calls/services/video_call_incoming_push_presenter_test.dart index 64850877..09f331da 100644 --- a/test/features/video_calls/services/video_call_incoming_push_presenter_test.dart +++ b/test/features/video_calls/services/video_call_incoming_push_presenter_test.dart @@ -75,6 +75,15 @@ void main() { expect(shown.single.ios?.audioSessionActive, isFalse); expect(shown.single.ios?.audioSessionMode, 'videoChat'); expect(shown.single.ios?.ringtonePath, isNull); + expect(shown.single.missedCallNotification?.showNotification, isTrue); + expect( + shown.single.missedCallNotification?.subtitle, + VideoCallIncomingPushPresenter.iosMissedCallSubtitle, + ); + expect( + shown.single.missedCallNotification?.callbackText, + VideoCallIncomingPushPresenter.iosMissedCallCallbackText, + ); }); test('iOS stops polling when the push claims the call', () async { diff --git a/test/features/video_calls/services/video_call_remote_end_registry_test.dart b/test/features/video_calls/services/video_call_remote_end_registry_test.dart new file mode 100644 index 00000000..2a24a02c --- /dev/null +++ b/test/features/video_calls/services/video_call_remote_end_registry_test.dart @@ -0,0 +1,44 @@ +import 'package:eatvio_app/features/video_calls/services/video_call_remote_end_registry.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + setUp(VideoCallRemoteEndRegistry.clear); + + group('VideoCallRemoteEndRegistry', () { + test('remembers a marked id regardless of case and whitespace', () { + VideoCallRemoteEndRegistry.mark(' 11111111-1111-4111-8111-111111111111 '); + + expect( + VideoCallRemoteEndRegistry.contains( + '11111111-1111-4111-8111-111111111111'.toUpperCase(), + ), + isTrue, + ); + }); + + test('does not know an id that was never marked', () { + expect(VideoCallRemoteEndRegistry.contains('other'), isFalse); + expect(VideoCallRemoteEndRegistry.contains(''), isFalse); + }); + + test('outlives the 45 s ring timeout and expires after the ttl', () { + final markedAt = DateTime(2026, 10, 19, 12); + VideoCallRemoteEndRegistry.mark('call', now: markedAt); + + expect( + VideoCallRemoteEndRegistry.contains( + 'call', + now: markedAt.add(const Duration(seconds: 50)), + ), + isTrue, + ); + expect( + VideoCallRemoteEndRegistry.contains( + 'call', + now: markedAt.add(VideoCallRemoteEndRegistry.ttl), + ), + isFalse, + ); + }); + }); +}