import 'dart:async'; import 'package:flutter/material.dart'; import 'package:callkeep/callkeep.dart'; import 'package:flutter_foreground_task/flutter_foreground_task.dart'; import 'package:flutter_gen/gen_l10n/l10n.dart'; import 'package:matrix/matrix.dart'; import 'package:permission_handler/permission_handler.dart'; import 'package:fluffychat/utils/voip_plugin.dart'; class CallKeeper { CallKeeper(this.callKeepManager, this.call) { call.onCallStateChanged.stream.listen(_handleCallState); } CallKeepManager callKeepManager; bool? held = false; bool? muted = false; bool connected = false; CallSession call; // update native caller to show what remote user has done. void _handleCallState(CallState state) { Logs().i('CallKeepManager::handleCallState: ${state.toString()}'); switch (state) { case CallState.kConnecting: Logs().v('callkeep connecting'); break; case CallState.kConnected: Logs().v('callkeep connected'); if (!connected) { callKeepManager.answer(call.callId); } else { callKeepManager.setMutedCall(call.callId, false); callKeepManager.setOnHold(call.callId, false); } break; case CallState.kEnded: callKeepManager.hangup(call.callId); break; /* TODO: case CallState.kMuted: callKeepManager.setMutedCall(uuid, true); break; case CallState.kHeld: callKeepManager.setOnHold(uuid, true); break; */ case CallState.kFledgling: // TODO: Handle this case. break; case CallState.kInviteSent: // TODO: Handle this case. break; case CallState.kWaitLocalMedia: // TODO: Handle this case. break; case CallState.kCreateOffer: // TODO: Handle this case. break; case CallState.kCreateAnswer: // TODO: Handle this case. break; case CallState.kRinging: // TODO: Handle this case. break; } } } Map calls = {}; class CallKeepManager { factory CallKeepManager() { return _instance; } CallKeepManager._internal() { _callKeep = FlutterCallkeep(); } static final CallKeepManager _instance = CallKeepManager._internal(); late FlutterCallkeep _callKeep; VoipPlugin? _voipPlugin; String get appName => 'FluffyChat'; Future get hasPhoneAccountEnabled async => await _callKeep.hasPhoneAccount(); Map get alertOptions => { 'alertTitle': 'Permissions required', 'alertDescription': 'Allow $appName to register as a calling account? This will allow calls to be handled by the native android dialer.', 'cancelButton': 'Cancel', 'okButton': 'ok', // Required to get audio in background when using Android 11 'foregroundService': { 'channelId': 'com.fluffy.fluffychat', 'channelName': 'Foreground service for my app', 'notificationTitle': '$appName is running on background', 'notificationIcon': 'mipmap/ic_notification_launcher', }, 'additionalPermissions': [''], }; bool setupDone = false; Future showCallkitIncoming(CallSession call) async { if (!setupDone) { await _callKeep.setup( null, { 'ios': { 'appName': appName, }, 'android': alertOptions, }, backgroundMode: true); } setupDone = true; await displayIncomingCall(call); call.onCallStateChanged.stream.listen((state) { if (state == CallState.kEnded) { _callKeep.endAllCalls(); } }); call.onCallEventChanged.stream.listen( (event) { if (event == CallEvent.kLocalHoldUnhold) { Logs().i( 'Call hold event: local ${call.localHold}, remote ${call.remoteOnHold}'); } }, ); } void removeCall(String? callUUID) { calls.remove(callUUID); } void addCall(String? callUUID, CallKeeper callKeeper) { if (calls.containsKey(callUUID)) return; calls[callUUID] = callKeeper; } void setCallHeld(String? callUUID, bool? held) { calls[callUUID]!.held = held; } void setCallMuted(String? callUUID, bool? muted) { calls[callUUID]!.muted = muted; } void didDisplayIncomingCall(CallKeepDidDisplayIncomingCall event) { final callUUID = event.callUUID; final number = event.handle; Logs().v('[displayIncomingCall] $callUUID number: $number'); // addCall(callUUID, CallKeeper(this null)); } void onPushKitToken(CallKeepPushKitToken event) { Logs().v('[onPushKitToken] token => ${event.token}'); } Future initialize() async { _callKeep.on(CallKeepPerformAnswerCallAction(), answerCall); _callKeep.on(CallKeepDidPerformDTMFAction(), didPerformDTMFAction); _callKeep.on( CallKeepDidReceiveStartCallAction(), didReceiveStartCallAction); _callKeep.on(CallKeepDidToggleHoldAction(), didToggleHoldCallAction); _callKeep.on( CallKeepDidPerformSetMutedCallAction(), didPerformSetMutedCallAction); _callKeep.on(CallKeepPerformEndCallAction(), endCall); _callKeep.on(CallKeepPushKitToken(), onPushKitToken); _callKeep.on(CallKeepDidDisplayIncomingCall(), didDisplayIncomingCall); Logs().i('[VOIP] Initialized'); } Future hangup(String callUUID) async { await _callKeep.endCall(callUUID); removeCall(callUUID); } Future reject(String callUUID) async { await _callKeep.rejectCall(callUUID); } Future answer(String? callUUID) async { final keeper = calls[callUUID]!; if (!keeper.connected) { await _callKeep.answerIncomingCall(callUUID!); keeper.connected = true; } } Future setOnHold(String callUUID, bool held) async { await _callKeep.setOnHold(callUUID, held); setCallHeld(callUUID, held); } Future setMutedCall(String callUUID, bool muted) async { await _callKeep.setMutedCall(callUUID, muted); setCallMuted(callUUID, muted); } Future updateDisplay(String callUUID) async { // Workaround because Android doesn't display well displayName, se we have to switch ... if (isIOS) { await _callKeep.updateDisplay(callUUID, displayName: 'New Name', handle: callUUID); } else { await _callKeep.updateDisplay(callUUID, displayName: callUUID, handle: 'New Name'); } } Future displayIncomingCall(CallSession call) async { final callKeeper = CallKeeper(this, call); addCall(call.callId, callKeeper); await _callKeep.displayIncomingCall( call.callId, '${call.displayName!} (FluffyChat)', localizedCallerName: '${call.displayName!} (FluffyChat)', handleType: 'number', hasVideo: call.type == CallType.kVideo, ); return callKeeper; } Future checkoutPhoneAccountSetting(BuildContext context) async { showDialog( context: context, barrierDismissible: true, useRootNavigator: false, builder: (_) => AlertDialog( title: Text(L10n.of(context)!.callingPermissions), content: Column( mainAxisSize: MainAxisSize.min, children: [ ListTile( onTap: () => openCallingAccountsPage(context), title: Text(L10n.of(context)!.callingAccount), subtitle: Text(L10n.of(context)!.callingAccountDetails), trailing: const Icon(Icons.phone), ), const Divider(), ListTile( onTap: () => FlutterForegroundTask.openSystemAlertWindowSettings( forceOpen: true), title: Text(L10n.of(context)!.appearOnTop), subtitle: Text(L10n.of(context)!.appearOnTopDetails), trailing: const Icon(Icons.file_upload_rounded), ), const Divider(), ListTile( onTap: () => openAppSettings(), title: Text(L10n.of(context)!.otherCallingPermissions), trailing: const Icon(Icons.mic), ), ], ), ), ); } void openCallingAccountsPage(BuildContext context) async { await _callKeep.setup(context, { 'ios': { 'appName': appName, }, 'android': alertOptions, }); final hasPhoneAccount = await _callKeep.hasPhoneAccount(); Logs().e(hasPhoneAccount.toString()); if (!hasPhoneAccount) { await _callKeep.hasDefaultPhoneAccount(context, alertOptions); } else { await _callKeep.openPhoneAccounts(); } } /// CallActions. Future answerCall(CallKeepPerformAnswerCallAction event) async { final callUUID = event.callUUID; final keeper = calls[event.callUUID]!; if (!keeper.connected) { Logs().e('answered'); // Answer Call keeper.call.answer(); keeper.connected = true; } Timer(const Duration(seconds: 1), () { _callKeep.setCurrentCallActive(callUUID!); }); } Future endCall(CallKeepPerformEndCallAction event) async { final keeper = calls[event.callUUID]; keeper?.call.hangup(); removeCall(event.callUUID); } Future didPerformDTMFAction(CallKeepDidPerformDTMFAction event) async { final keeper = calls[event.callUUID]!; keeper.call.sendDTMF(event.digits!); } Future didReceiveStartCallAction( CallKeepDidReceiveStartCallAction event) async { if (event.handle == null) { // @TODO: sometime we receive `didReceiveStartCallAction` with handle` undefined` return; } final callUUID = event.callUUID!; if (event.callUUID == null) { final call = await _voipPlugin!.voip.inviteToCall(event.handle!, CallType.kVideo); addCall(callUUID, CallKeeper(this, call)); } await _callKeep.startCall(callUUID, event.handle!, event.handle!); Timer(const Duration(seconds: 1), () { _callKeep.setCurrentCallActive(callUUID); }); } Future didPerformSetMutedCallAction( CallKeepDidPerformSetMutedCallAction event) async { final keeper = calls[event.callUUID]; if (event.muted!) { keeper!.call.setMicrophoneMuted(true); } else { keeper!.call.setMicrophoneMuted(false); } setCallMuted(event.callUUID, event.muted); } Future didToggleHoldCallAction( CallKeepDidToggleHoldAction event) async { final keeper = calls[event.callUUID]; if (event.hold!) { keeper!.call.setRemoteOnHold(true); } else { keeper!.call.setRemoteOnHold(false); } setCallHeld(event.callUUID, event.hold); } }