forked from eatvio/eatvio_app
feat(video_calls): voip push handling fix
This commit is contained in:
@@ -11,6 +11,9 @@ import flutter_foreground_task
|
||||
@main
|
||||
@objc class AppDelegate: FlutterAppDelegate, PKPushRegistryDelegate, CallkitIncomingAppDelegate {
|
||||
private var voipRegistry: PKPushRegistry?
|
||||
private var voipDiagnosticsChannel: FlutterMethodChannel?
|
||||
private let voipDiagnosticsStorageKey = "EatvioVoipDiagnostics"
|
||||
private let voipDiagnosticsChannelName = "com.eatvioapp/voip_diagnostics"
|
||||
|
||||
override func application(
|
||||
_ application: UIApplication,
|
||||
@@ -30,7 +33,24 @@ import flutter_foreground_task
|
||||
UNUserNotificationCenter.current().delegate = self
|
||||
}
|
||||
|
||||
return super.application(application, didFinishLaunchingWithOptions: launchOptions)
|
||||
let launched = super.application(application, didFinishLaunchingWithOptions: launchOptions)
|
||||
|
||||
if let controller = window?.rootViewController as? FlutterViewController {
|
||||
let channel = FlutterMethodChannel(
|
||||
name: voipDiagnosticsChannelName,
|
||||
binaryMessenger: controller.binaryMessenger
|
||||
)
|
||||
voipDiagnosticsChannel = channel
|
||||
channel.setMethodCallHandler { [weak self] call, result in
|
||||
guard call.method == "drainVoipDiagnostics" else {
|
||||
result(FlutterMethodNotImplemented)
|
||||
return
|
||||
}
|
||||
result(self?.drainVoipDiagnostics() ?? [])
|
||||
}
|
||||
}
|
||||
|
||||
return launched
|
||||
}
|
||||
|
||||
@available(iOS 8.0, *)
|
||||
@@ -88,6 +108,54 @@ import flutter_foreground_task
|
||||
return nil
|
||||
}
|
||||
|
||||
private func sanitizedDiagnosticValue(_ value: Any?) -> Any {
|
||||
guard let value = value else {
|
||||
return ""
|
||||
}
|
||||
if let string = value as? String {
|
||||
return string
|
||||
}
|
||||
if let number = value as? NSNumber {
|
||||
return number
|
||||
}
|
||||
if let bool = value as? Bool {
|
||||
return bool
|
||||
}
|
||||
if let dictionary = value as? [AnyHashable: Any] {
|
||||
return dictionary.reduce(into: [String: Any]()) { result, entry in
|
||||
result[String(describing: entry.key)] = sanitizedDiagnosticValue(entry.value)
|
||||
}
|
||||
}
|
||||
if let array = value as? [Any] {
|
||||
return array.map { sanitizedDiagnosticValue($0) }
|
||||
}
|
||||
return String(describing: value)
|
||||
}
|
||||
|
||||
private func recordVoipDiagnostic(_ name: String, data: [String: Any]) {
|
||||
let defaults = UserDefaults.standard
|
||||
var events = defaults.array(forKey: voipDiagnosticsStorageKey) as? [[String: Any]] ?? []
|
||||
var event: [String: Any] = [
|
||||
"name": name,
|
||||
"timestamp": ISO8601DateFormatter().string(from: Date()),
|
||||
"appState": UIApplication.shared.applicationState.rawValue,
|
||||
]
|
||||
event["data"] = sanitizedDiagnosticValue(data)
|
||||
events.append(event)
|
||||
if events.count > 20 {
|
||||
events = Array(events.suffix(20))
|
||||
}
|
||||
defaults.set(events, forKey: voipDiagnosticsStorageKey)
|
||||
voipDiagnosticsChannel?.invokeMethod("voipDiagnosticsUpdated", arguments: nil)
|
||||
}
|
||||
|
||||
private func drainVoipDiagnostics() -> [[String: Any]] {
|
||||
let defaults = UserDefaults.standard
|
||||
let events = defaults.array(forKey: voipDiagnosticsStorageKey) as? [[String: Any]] ?? []
|
||||
defaults.removeObject(forKey: voipDiagnosticsStorageKey)
|
||||
return events
|
||||
}
|
||||
|
||||
|
||||
func pushRegistry(
|
||||
_ registry: PKPushRegistry,
|
||||
@@ -126,6 +194,13 @@ import flutter_foreground_task
|
||||
|
||||
if pushMarkers.contains(where: { $0.contains("video_call") })
|
||||
&& pushMarkers.contains(where: { $0.contains("end") || $0.contains("cancel") }) {
|
||||
recordVoipDiagnostic(
|
||||
"voip_end_or_cancel_received",
|
||||
data: [
|
||||
"markers": pushMarkers,
|
||||
"payload": dict,
|
||||
]
|
||||
)
|
||||
SwiftFlutterCallkitIncomingPlugin.sharedInstance?.endAllCalls()
|
||||
completion()
|
||||
return
|
||||
@@ -147,10 +222,10 @@ import flutter_foreground_task
|
||||
?? (dict["handle"] as? String)
|
||||
?? ""
|
||||
let id = [
|
||||
conversationUuid,
|
||||
dict["call_id"] as? String,
|
||||
dict["id"] as? String,
|
||||
dict["message_id"] as? String,
|
||||
conversationUuid,
|
||||
]
|
||||
.compactMap { $0?.trimmingCharacters(in: .whitespacesAndNewlines) }
|
||||
.first { !$0.isEmpty && UUID(uuidString: $0) != nil }
|
||||
@@ -194,21 +269,43 @@ import flutter_foreground_task
|
||||
id: id,
|
||||
nameCaller: nameCaller,
|
||||
handle: conversationUuid.isEmpty ? nameCaller : conversationUuid,
|
||||
type: 0
|
||||
type: isVideo ? 1 : 0
|
||||
)
|
||||
data.appName = "Итвио"
|
||||
data.duration = 45000
|
||||
data.handleType = "generic"
|
||||
data.supportsVideo = isVideo
|
||||
data.ringtonePath = "system_ringtone_default"
|
||||
data.configureAudioSession = false
|
||||
data.includesCallsInRecents = false
|
||||
data.extra = extra as NSDictionary
|
||||
|
||||
NSLog(
|
||||
"[EatvioVoIP] report incoming call id=\(id) conversation=\(conversationUuid) state=\(UIApplication.shared.applicationState.rawValue) markers=\(pushMarkers.joined(separator: ",")) payload=\(dict)"
|
||||
)
|
||||
recordVoipDiagnostic(
|
||||
"voip_incoming_received",
|
||||
data: [
|
||||
"callKitId": id,
|
||||
"conversationUuid": conversationUuid,
|
||||
"nameCaller": nameCaller,
|
||||
"isVideo": isVideo,
|
||||
"markers": pushMarkers,
|
||||
"payload": dict,
|
||||
]
|
||||
)
|
||||
|
||||
SwiftFlutterCallkitIncomingPlugin.sharedInstance?.showCallkitIncoming(
|
||||
data,
|
||||
fromPushKit: true
|
||||
) {
|
||||
let activeCalls = SwiftFlutterCallkitIncomingPlugin.sharedInstance?.activeCalls() ?? []
|
||||
let activeIds = activeCalls.compactMap { $0["id"] as? String }.joined(separator: ",")
|
||||
NSLog(
|
||||
"[EatvioVoIP] report incoming completed id=\(id) activeCallIds=\(activeIds)"
|
||||
)
|
||||
self.recordVoipDiagnostic(
|
||||
"callkit_report_completed",
|
||||
data: [
|
||||
"callKitId": id,
|
||||
"conversationUuid": conversationUuid,
|
||||
"activeCallIds": activeIds,
|
||||
"activeCallsCount": activeCalls.count,
|
||||
]
|
||||
)
|
||||
completion()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,10 +9,12 @@ import 'package:eatvio_app/features/video_calls/presentation/bloc/video_call_ses
|
||||
import 'package:eatvio_app/features/video_calls/presentation/bloc/video_call_session/video_call_session_state.dart';
|
||||
import 'package:eatvio_app/features/video_calls/services/video_call_fcm_handler.dart';
|
||||
import 'package:eatvio_app/features/video_calls/services/video_call_notification_navigator.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter/widgets.dart';
|
||||
import 'package:flutter_callkit_incoming/entities/call_event.dart';
|
||||
import 'package:flutter_callkit_incoming/entities/call_kit_params.dart';
|
||||
import 'package:flutter_callkit_incoming/flutter_callkit_incoming.dart';
|
||||
import 'package:sentry_flutter/sentry_flutter.dart';
|
||||
|
||||
class VideoCallCallKitCoordinator {
|
||||
VideoCallCallKitCoordinator({
|
||||
@@ -28,12 +30,16 @@ class VideoCallCallKitCoordinator {
|
||||
final VideoCallSessionCubit? _videoCallSessionCubit;
|
||||
|
||||
StreamSubscription<CallEvent?>? _eventSubscription;
|
||||
AppLifecycleListener? _lifecycleListener;
|
||||
final Map<String, CallKitParams> _callKitParamsById = {};
|
||||
final Set<String> _acceptHandledCallKitIds = {};
|
||||
final Set<String> _suppressedEndCallKitIds = {};
|
||||
String? _activeCallKitId;
|
||||
bool _initialized = false;
|
||||
bool _nativeDiagnosticsDrainScheduled = false;
|
||||
|
||||
static const MethodChannel _voipDiagnosticsChannel = MethodChannel(
|
||||
'com.eatvioapp/voip_diagnostics',
|
||||
);
|
||||
|
||||
Future<void> initialize() async {
|
||||
if (_initialized) return;
|
||||
@@ -42,12 +48,14 @@ class VideoCallCallKitCoordinator {
|
||||
_eventSubscription = FlutterCallkitIncoming.onEvent.listen(_onCallKitEvent);
|
||||
|
||||
if (Platform.isIOS) {
|
||||
_voipDiagnosticsChannel.setMethodCallHandler((call) async {
|
||||
if (call.method == 'voipDiagnosticsUpdated') {
|
||||
_scheduleNativeVoipDiagnosticsDrain();
|
||||
}
|
||||
});
|
||||
await _firebaseApi.registerPushToken();
|
||||
_lifecycleListener = AppLifecycleListener(
|
||||
onResume: () => unawaited(_openActiveCallKitCallIfNeeded()),
|
||||
);
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
unawaited(_openActiveCallKitCallIfNeeded());
|
||||
_scheduleNativeVoipDiagnosticsDrain();
|
||||
});
|
||||
return;
|
||||
}
|
||||
@@ -70,9 +78,7 @@ class VideoCallCallKitCoordinator {
|
||||
|
||||
void dispose() {
|
||||
unawaited(_eventSubscription?.cancel());
|
||||
_lifecycleListener?.dispose();
|
||||
_eventSubscription = null;
|
||||
_lifecycleListener = null;
|
||||
_callKitParamsById.clear();
|
||||
_initialized = false;
|
||||
}
|
||||
@@ -82,6 +88,52 @@ class VideoCallCallKitCoordinator {
|
||||
VideoCallSessionCubit get _cubit =>
|
||||
_videoCallSessionCubit ?? sl<VideoCallSessionCubit>();
|
||||
|
||||
void _scheduleNativeVoipDiagnosticsDrain() {
|
||||
if (_nativeDiagnosticsDrainScheduled) return;
|
||||
_nativeDiagnosticsDrainScheduled = true;
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
_nativeDiagnosticsDrainScheduled = false;
|
||||
unawaited(_drainNativeVoipDiagnosticsToSentry());
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _drainNativeVoipDiagnosticsToSentry() async {
|
||||
if (!Platform.isIOS) return;
|
||||
try {
|
||||
final events = await _voipDiagnosticsChannel.invokeListMethod<dynamic>(
|
||||
'drainVoipDiagnostics',
|
||||
);
|
||||
if (events == null || events.isEmpty) return;
|
||||
|
||||
for (final rawEvent in events) {
|
||||
final event = _stringKeyedMap(rawEvent);
|
||||
final name = event['name']?.toString() ?? 'unknown';
|
||||
await Sentry.captureMessage(
|
||||
'iOS VoIP diagnostic: $name',
|
||||
level: SentryLevel.info,
|
||||
withScope: (scope) {
|
||||
scope.setTag('origin', 'iOS VoIP');
|
||||
scope.setTag('voip_diagnostic', 'true');
|
||||
scope.setTag('voip_event', name);
|
||||
scope.setContexts('voip_diagnostic', _stringifyMapValues(event));
|
||||
},
|
||||
);
|
||||
}
|
||||
} on PlatformException catch (e, st) {
|
||||
log(
|
||||
'VideoCallCallKitCoordinator: failed to drain native VoIP diagnostics: $e',
|
||||
name: 'VideoCallCallKit',
|
||||
stackTrace: st,
|
||||
);
|
||||
} catch (e, st) {
|
||||
log(
|
||||
'VideoCallCallKitCoordinator: failed to send native VoIP diagnostics: $e',
|
||||
name: 'VideoCallCallKit',
|
||||
stackTrace: st,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _onCallKitEvent(CallEvent? event) async {
|
||||
if (event == null) return;
|
||||
|
||||
@@ -124,50 +176,6 @@ class VideoCallCallKitCoordinator {
|
||||
return _paramsForCallId(id);
|
||||
}
|
||||
|
||||
Future<void> _openActiveCallKitCallIfNeeded() async {
|
||||
if (!Platform.isIOS) return;
|
||||
if (_cubit.state.connectPhase != VideoCallConnectPhase.idle) return;
|
||||
|
||||
final activeCalls = await FlutterCallkitIncoming.activeCalls();
|
||||
if (activeCalls.isEmpty) return;
|
||||
|
||||
for (final params in activeCalls) {
|
||||
_cacheCallKitParams(params);
|
||||
if (!params.isAccepted) continue;
|
||||
final extra = params.extra ?? <String, dynamic>{};
|
||||
final conversationUuid = _parseConversationUuid(
|
||||
extra,
|
||||
handle: params.handle,
|
||||
);
|
||||
if (conversationUuid.isEmpty) continue;
|
||||
|
||||
await _deliverIncomingAccept(
|
||||
callKitId: params.id,
|
||||
conversationUuid: conversationUuid,
|
||||
counterpartName:
|
||||
_parseString(extra, const [
|
||||
'counterpartName',
|
||||
'caller_name',
|
||||
'callerName',
|
||||
]) ??
|
||||
params.nameCaller,
|
||||
counterpartAvatarUrl:
|
||||
_parseString(extra, const [
|
||||
'counterpartAvatarUrl',
|
||||
'avatar',
|
||||
'avatarUrl',
|
||||
'caller_avatar',
|
||||
]) ??
|
||||
params.avatar,
|
||||
localParticipantName: _parseString(extra, const [
|
||||
'localParticipantName',
|
||||
'local_participant_name',
|
||||
]),
|
||||
);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _onNativeAcceptCallHandle(Map<dynamic, dynamic> body) async {
|
||||
final extra = Map<String, dynamic>.from(body);
|
||||
final conversationUuid = _parseConversationUuid(extra);
|
||||
@@ -365,4 +373,21 @@ class VideoCallCallKitCoordinator {
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
static Map<String, Object?> _stringKeyedMap(Object? value) {
|
||||
if (value is! Map) return <String, Object?>{};
|
||||
return value.map((key, value) => MapEntry(key.toString(), value));
|
||||
}
|
||||
|
||||
static Map<String, Object?> _stringifyMapValues(Map<String, Object?> map) {
|
||||
return map.map((key, value) {
|
||||
if (value is Map) {
|
||||
return MapEntry(key, _stringifyMapValues(_stringKeyedMap(value)));
|
||||
}
|
||||
if (value is Iterable) {
|
||||
return MapEntry(key, value.map((item) => item?.toString()).toList());
|
||||
}
|
||||
return MapEntry(key, value?.toString());
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user