From 29c6c93dc4d5f6966eb3cc4b08377463305ff9a4 Mon Sep 17 00:00:00 2001 From: "katph@pc" Date: Sun, 24 Dec 2023 17:16:10 +0800 Subject: [PATCH] Add show showBackgroundCallUI implementation to handle fcm from other plugins --- .../twilio/twilio_voice/TwilioVoicePlugin.kt | 18 ++- .../twilio_voice/fcm/VoiceMessagingService.kt | 126 ++++++++++++++++++ .../twilio_voice/types/TVMethodChannels.kt | 1 - .../twilio_voice_method_channel.dart | 98 +++++++++----- .../twilio_voice_platform_interface.dart | 3 +- 5 files changed, 210 insertions(+), 36 deletions(-) create mode 100644 android/src/main/kotlin/com/twilio/twilio_voice/fcm/VoiceMessagingService.kt diff --git a/android/src/main/kotlin/com/twilio/twilio_voice/TwilioVoicePlugin.kt b/android/src/main/kotlin/com/twilio/twilio_voice/TwilioVoicePlugin.kt index 426e2f63..2b65cb7a 100644 --- a/android/src/main/kotlin/com/twilio/twilio_voice/TwilioVoicePlugin.kt +++ b/android/src/main/kotlin/com/twilio/twilio_voice/TwilioVoicePlugin.kt @@ -21,6 +21,7 @@ import androidx.core.content.ContextCompat import androidx.localbroadcastmanager.content.LocalBroadcastManager import com.twilio.twilio_voice.constants.Constants import com.twilio.twilio_voice.constants.FlutterErrorCodes +import com.twilio.twilio_voice.fcm.VoiceMessagingService import com.twilio.twilio_voice.receivers.TVBroadcastReceiver import com.twilio.twilio_voice.service.TVConnectionService import com.twilio.twilio_voice.storage.Storage @@ -136,6 +137,7 @@ class TwilioVoicePlugin : FlutterPlugin, MethodCallHandler, EventChannel.StreamH flutterPluginBinding.applicationContext ) hasStarted = true + context = flutterPluginBinding.applicationContext } override fun onDetachedFromEngine(binding: FlutterPluginBinding) { @@ -853,7 +855,19 @@ class TwilioVoicePlugin : FlutterPlugin, MethodCallHandler, EventChannel.StreamH } TVMethodChannels.BACKGROUND_CALL_UI -> { - // Deprecated in favour of ConnectionService implementation + val args = call.arguments as? Map ?: run { + result.error( + FlutterErrorCodes.MALFORMED_ARGUMENTS, + "Arguments should be a Map<*, *>", + null + ) + return@onMethodCall + } + + Log.d(TAG, "onMethodCall: BACKGROUND_CALL_UI args: $args") + + context?.let { val voiceMessagingService = VoiceMessagingService(it) + Voice.handleMessage(it, args, voiceMessagingService) } result.success(true) } @@ -1892,4 +1906,4 @@ class TwilioVoicePlugin : FlutterPlugin, MethodCallHandler, EventChannel.StreamH // logEvent("onDisconnected") // } //endregion -} \ No newline at end of file +} diff --git a/android/src/main/kotlin/com/twilio/twilio_voice/fcm/VoiceMessagingService.kt b/android/src/main/kotlin/com/twilio/twilio_voice/fcm/VoiceMessagingService.kt new file mode 100644 index 00000000..8141d519 --- /dev/null +++ b/android/src/main/kotlin/com/twilio/twilio_voice/fcm/VoiceMessagingService.kt @@ -0,0 +1,126 @@ +package com.twilio.twilio_voice.fcm + +import android.Manifest +import android.annotation.SuppressLint +import android.content.Context +import android.content.Intent +import android.telecom.TelecomManager +import android.util.Log +import androidx.annotation.RequiresPermission +import androidx.localbroadcastmanager.content.LocalBroadcastManager +import com.google.firebase.messaging.RemoteMessage +import com.twilio.twilio_voice.receivers.TVBroadcastReceiver +import com.twilio.twilio_voice.service.TVConnectionService +import com.twilio.twilio_voice.storage.StorageImpl +import com.twilio.twilio_voice.types.TelecomManagerExtension.canReadPhoneNumbers +import com.twilio.twilio_voice.types.TelecomManagerExtension.canReadPhoneState +import com.twilio.twilio_voice.types.TelecomManagerExtension.hasCallCapableAccount +import com.twilio.voice.CallException +import com.twilio.voice.CallInvite +import com.twilio.voice.CancelledCallInvite +import com.twilio.voice.MessageListener +import com.twilio.voice.Voice + +class VoiceMessagingService(private val applicationContext: Context) : MessageListener{ + companion object { + private const val TAG = "VoiceMessagingService" + + + } + + //region MessageListener + @RequiresPermission(allOf = [Manifest.permission.RECORD_AUDIO, Manifest.permission.READ_PHONE_STATE, Manifest.permission.READ_PHONE_NUMBERS]) + @SuppressLint("MissingPermission") + override fun onCallInvite(callInvite: CallInvite) { + Log.d( + TAG, + "onCallInvite: {\n\t" + + "CallSid: ${callInvite.callSid}, \n\t" + + "From: ${callInvite.from}, \n\t" + + "To: ${callInvite.to}, \n\t" + + "Parameters: ${callInvite.customParameters.entries.joinToString { "${it.key}:${it.value}" }},\n\t" + + "}" + ) + // Get TelecomManager instance + val tm = applicationContext.getSystemService(Context.TELECOM_SERVICE) as TelecomManager + + val shouldRejectOnNoPermissions: Boolean = StorageImpl(applicationContext).rejectOnNoPermissions + var missingPermissions: Array = emptyArray() + + // Check permission READ_PHONE_STATE + if (!tm.canReadPhoneState(applicationContext)) { + missingPermissions += "No `READ_PHONE_STATE` permission, cannot check if phone account is registered. Request this with `requestReadPhoneStatePermission()`" + } + + // Check permission READ_PHONE_NUMBERS + if (!tm.canReadPhoneNumbers(applicationContext)) { + missingPermissions += "No `READ_PHONE_NUMBERS` permission, cannot communicate with ConnectionService if not granted. Request this with `requestReadPhoneNumbersPermission()`" + } + + // NOTE(cybex-dev): Foreground services requiring privacy permission e.g. microphone or + // camera are required to be started in the foreground. Since we're using the Telecom's + // PhoneAccount, we don't directly require microphone access. Further, microphone access + // is always denied if the app requiring microphone access via a Foreground service + // is in the background (by design). +// // Check permission RECORD_AUDIO +// if (!applicationContext.hasMicrophoneAccess()) { +// shouldRejectCall = true +// requiredPermissions += "No `RECORD_AUDIO` permission, VoiceSDK requires this permission. Request this with `requestMicPermission()`" +// } + + if(!tm.hasCallCapableAccount(applicationContext, TVConnectionService::class.java.name)) { + missingPermissions += "No call capable phone account registered. Request this with `registerPhoneAccount()`" + } + + // If we have missingPermissions, then we cannot proceed with answering the call. + if (missingPermissions.isNotEmpty()) { + missingPermissions.forEach { Log.e(TAG, it) } + + // If we're not rejecting on no permissions, and can't answer because we don't have the required permissions / phone account, we let it ring. + // This details a use-case where multiple instances of a user is logged in, and can accept the call on another device. + if(!shouldRejectOnNoPermissions) { + return + } + + Log.e(TAG, "onCallInvite: Rejecting incoming call\nSID: ${callInvite.callSid}") + + // send broadcast to TVBroadcastReceiver, we notify Flutter about incoming call + Intent(applicationContext, TVBroadcastReceiver::class.java).apply { + action = TVBroadcastReceiver.ACTION_INCOMING_CALL_IGNORED + putExtra(TVBroadcastReceiver.EXTRA_INCOMING_CALL_IGNORED_REASON, missingPermissions) + putExtra(TVBroadcastReceiver.EXTRA_CALL_HANDLE, callInvite.callSid) + LocalBroadcastManager.getInstance(applicationContext).sendBroadcast(this) + } + + // Reject incoming call + Log.d(TAG, "onCallInvite: Rejecting incoming call") + callInvite.reject(applicationContext) + + return + } + + // send broadcast to TVConnectionService, we notify the TelecomManager about incoming call + Intent(applicationContext, TVConnectionService::class.java).apply { + action = TVConnectionService.ACTION_INCOMING_CALL + putExtra(TVConnectionService.EXTRA_INCOMING_CALL_INVITE, callInvite) + applicationContext.startService(this) + } + + // send broadcast to TVBroadcastReceiver, we notify Flutter about incoming call + Intent(applicationContext, TVBroadcastReceiver::class.java).apply { + action = TVBroadcastReceiver.ACTION_INCOMING_CALL + putExtra(TVBroadcastReceiver.EXTRA_CALL_INVITE, callInvite) + putExtra(TVBroadcastReceiver.EXTRA_CALL_HANDLE, callInvite.callSid) + LocalBroadcastManager.getInstance(applicationContext).sendBroadcast(this) + } + } + + override fun onCancelledCallInvite(cancelledCallInvite: CancelledCallInvite, callException: CallException?) { + Log.d(TAG, "onCancelledCallInvite: ", callException) + Intent(applicationContext, TVConnectionService::class.java).apply { + action = TVConnectionService.ACTION_CANCEL_CALL_INVITE + putExtra(TVConnectionService.EXTRA_CANCEL_CALL_INVITE, cancelledCallInvite) + applicationContext.startService(this) + } + } +} \ No newline at end of file diff --git a/android/src/main/kotlin/com/twilio/twilio_voice/types/TVMethodChannels.kt b/android/src/main/kotlin/com/twilio/twilio_voice/types/TVMethodChannels.kt index 0d756e28..401fb706 100644 --- a/android/src/main/kotlin/com/twilio/twilio_voice/types/TVMethodChannels.kt +++ b/android/src/main/kotlin/com/twilio/twilio_voice/types/TVMethodChannels.kt @@ -33,7 +33,6 @@ enum class TVMethodChannels(val method: String) { REQUEST_READ_PHONE_STATE_PERMISSION("requestReadPhoneStatePermission"), HAS_CALL_PHONE_PERMISSION("hasCallPhonePermission"), REQUEST_CALL_PHONE_PERMISSION("requestCallPhonePermission"), - @Deprecated("No longer required due to Custom UI replaced with native call screen") BACKGROUND_CALL_UI("backgroundCallUi"), SHOW_NOTIFICATIONS("showNotifications"), HAS_READ_PHONE_NUMBERS_PERMISSION("hasReadPhoneNumbersPermission"), diff --git a/lib/_internal/method_channel/twilio_voice_method_channel.dart b/lib/_internal/method_channel/twilio_voice_method_channel.dart index 04caa6e2..355f2458 100644 --- a/lib/_internal/method_channel/twilio_voice_method_channel.dart +++ b/lib/_internal/method_channel/twilio_voice_method_channel.dart @@ -29,7 +29,9 @@ class MethodChannelTwilioVoice extends TwilioVoicePlatform { /// Sends call events @override Stream get callEventsListener { - _callEventsListener ??= _eventChannel.receiveBroadcastStream().map((dynamic event) => parseCallEvent(event)); + _callEventsListener ??= _eventChannel + .receiveBroadcastStream() + .map((dynamic event) => parseCallEvent(event)); return _callEventsListener!; } @@ -43,7 +45,10 @@ class MethodChannelTwilioVoice extends TwilioVoicePlatform { /// ios device token is obtained internally @override Future setTokens({required String accessToken, String? deviceToken}) { - return _channel.invokeMethod('tokens', {"accessToken": accessToken, "deviceToken": deviceToken}); + return _channel.invokeMethod('tokens', { + "accessToken": accessToken, + "deviceToken": deviceToken + }); } /// Whether or not should the user receive a notification after a missed call, default to true. @@ -51,7 +56,8 @@ class MethodChannelTwilioVoice extends TwilioVoicePlatform { /// Setting is persisted across restarts until overridden @override set showMissedCallNotifications(bool value) { - _channel.invokeMethod('show-notifications', {"show": value}); + _channel + .invokeMethod('show-notifications', {"show": value}); } /// Unregisters from Twilio @@ -59,7 +65,8 @@ class MethodChannelTwilioVoice extends TwilioVoicePlatform { /// If no accessToken is provided, previously registered accessToken will be used @override Future unregister({String? accessToken}) { - return _channel.invokeMethod('unregister', {"accessToken": accessToken}); + return _channel.invokeMethod( + 'unregister', {"accessToken": accessToken}); } /// Checks if device needs background permission @@ -68,7 +75,8 @@ class MethodChannelTwilioVoice extends TwilioVoicePlatform { @Deprecated('custom call UI not used anymore, has no effect') @override Future requiresBackgroundPermissions() { - return _channel.invokeMethod('requiresBackgroundPermissions', {}).then((bool? value) => value ?? false); + return _channel.invokeMethod('requiresBackgroundPermissions', + {}).then((bool? value) => value ?? false); } /// Requests background permission @@ -88,7 +96,8 @@ class MethodChannelTwilioVoice extends TwilioVoicePlatform { if (defaultTargetPlatform != TargetPlatform.android) { return Future.value(true); } - return _channel.invokeMethod('hasRegisteredPhoneAccount', {}).then((bool? value) => value ?? false); + return _channel.invokeMethod('hasRegisteredPhoneAccount', + {}).then((bool? value) => value ?? false); } /// Register phone account with TelecomManager @@ -99,7 +108,8 @@ class MethodChannelTwilioVoice extends TwilioVoicePlatform { if (defaultTargetPlatform != TargetPlatform.android) { return Future.value(true); } - return _channel.invokeMethod('registerPhoneAccount', {}).then((bool? value) => value ?? false); + return _channel.invokeMethod( + 'registerPhoneAccount', {}).then((bool? value) => value ?? false); } /// Checks if App's phone account is enabled @@ -110,7 +120,8 @@ class MethodChannelTwilioVoice extends TwilioVoicePlatform { if (defaultTargetPlatform != TargetPlatform.android) { return Future.value(true); } - return _channel.invokeMethod('isPhoneAccountEnabled', {}).then((bool? value) => value ?? false); + return _channel.invokeMethod('isPhoneAccountEnabled', {}).then( + (bool? value) => value ?? false); } /// Open phone account settings @@ -121,13 +132,15 @@ class MethodChannelTwilioVoice extends TwilioVoicePlatform { if (defaultTargetPlatform != TargetPlatform.android) { return Future.value(true); } - return _channel.invokeMethod('openPhoneAccountSettings', {}).then((bool? value) => value ?? false); + return _channel.invokeMethod('openPhoneAccountSettings', + {}).then((bool? value) => value ?? false); } /// Checks if device has microphone permission @override Future hasMicAccess() { - return _channel.invokeMethod('hasMicPermission', {}).then((bool? value) => value ?? false); + return _channel.invokeMethod( + 'hasMicPermission', {}).then((bool? value) => value ?? false); } /// Request microphone permission @@ -144,7 +157,8 @@ class MethodChannelTwilioVoice extends TwilioVoicePlatform { if (defaultTargetPlatform != TargetPlatform.android) { return Future.value(true); } - return _channel.invokeMethod('hasReadPhoneStatePermission', {}).then((bool? value) => value ?? false); + return _channel.invokeMethod('hasReadPhoneStatePermission', + {}).then((bool? value) => value ?? false); } /// Request read phone state permission @@ -166,7 +180,8 @@ class MethodChannelTwilioVoice extends TwilioVoicePlatform { if (defaultTargetPlatform != TargetPlatform.android) { return Future.value(true); } - return _channel.invokeMethod('hasCallPhonePermission', {}).then((bool? value) => value ?? false); + return _channel.invokeMethod('hasCallPhonePermission', + {}).then((bool? value) => value ?? false); } /// request 'android.permission.CALL_PHONE' permission @@ -188,7 +203,8 @@ class MethodChannelTwilioVoice extends TwilioVoicePlatform { if (defaultTargetPlatform != TargetPlatform.android) { return Future.value(true); } - return _channel.invokeMethod('hasManageOwnCallsPermission', {}).then((bool? value) => value ?? false); + return _channel.invokeMethod('hasManageOwnCallsPermission', + {}).then((bool? value) => value ?? false); } /// Requests system permission to manage calls @@ -210,7 +226,8 @@ class MethodChannelTwilioVoice extends TwilioVoicePlatform { if (defaultTargetPlatform != TargetPlatform.android) { return Future.value(true); } - return _channel.invokeMethod('hasReadPhoneNumbersPermission', {}).then((bool? value) => value ?? false); + return _channel.invokeMethod('hasReadPhoneNumbersPermission', + {}).then((bool? value) => value ?? false); } /// Request read phone numbers permission @@ -249,7 +266,9 @@ class MethodChannelTwilioVoice extends TwilioVoicePlatform { if (defaultTargetPlatform != TargetPlatform.android) { return Future.value(true); } - return _channel.invokeMethod('rejectCallOnNoPermissions', {"shouldReject": shouldReject}).then((bool? value) => value ?? false); + return _channel.invokeMethod('rejectCallOnNoPermissions', { + "shouldReject": shouldReject + }).then((bool? value) => value ?? false); } /// Returns true if call is rejected when no `CALL_PHONE` permissions are granted nor Phone Account (via `isPhoneAccountEnabled`) is registered. Defaults to false. @@ -260,7 +279,8 @@ class MethodChannelTwilioVoice extends TwilioVoicePlatform { if (defaultTargetPlatform != TargetPlatform.android) { return Future.value(false); } - return _channel.invokeMethod('isRejectingCallOnNoPermissions', {}).then((bool? value) => value ?? false); + return _channel.invokeMethod('isRejectingCallOnNoPermissions', + {}).then((bool? value) => value ?? false); } /// Set iOS call kit icon @@ -277,7 +297,8 @@ class MethodChannelTwilioVoice extends TwilioVoicePlatform { /// Use `TwilioVoice.instance.updateCallKitIcon(icon: "TransparentIcon")` @override Future updateCallKitIcon({String? icon}) { - return _channel.invokeMethod('updateCallKitIcon', {"icon": icon}); + return _channel + .invokeMethod('updateCallKitIcon', {"icon": icon}); } /// Register clientId for background calls @@ -285,13 +306,15 @@ class MethodChannelTwilioVoice extends TwilioVoicePlatform { /// Register the client name for incoming calls while calling using ids @override Future registerClient(String clientId, String clientName) { - return _channel.invokeMethod('registerClient', {"id": clientId, "name": clientName}); + return _channel.invokeMethod('registerClient', + {"id": clientId, "name": clientName}); } /// Unregister clientId for background calls @override Future unregisterClient(String clientId) { - return _channel.invokeMethod('unregisterClient', {"id": clientId}); + return _channel + .invokeMethod('unregisterClient', {"id": clientId}); } /// Set default caller name for no registered clients @@ -299,14 +322,16 @@ class MethodChannelTwilioVoice extends TwilioVoicePlatform { /// This caller name will be shown for incoming calls @override Future setDefaultCallerName(String callerName) { - return _channel.invokeMethod('defaultCaller', {"defaultCaller": callerName}); + return _channel.invokeMethod( + 'defaultCaller', {"defaultCaller": callerName}); } /// Android-only, shows background call UI /// Deprecated, has no effect @override - Future showBackgroundCallUI() { - return Future.value(true); + Future showBackgroundCallUI(Map fcmData) { + printDebug("Calling background UI"); + return _channel.invokeMethod("backgroundCallUi", fcmData); } @override @@ -362,10 +387,12 @@ class MethodChannelTwilioVoice extends TwilioVoicePlatform { return CallEvent.connected; } else if (state.startsWith("Incoming|")) { // Added as temporary override for incoming calls, not breaking current (expected) Ringing behaviour - call.activeCall = createCallFromState(state, callDirection: CallDirection.incoming); + call.activeCall = + createCallFromState(state, callDirection: CallDirection.incoming); if (kDebugMode) { - printDebug('Incoming - From: ${call.activeCall!.from}, To: ${call.activeCall!.to}, Direction: ${call.activeCall!.callDirection}'); + printDebug( + 'Incoming - From: ${call.activeCall!.from}, To: ${call.activeCall!.to}, Direction: ${call.activeCall!.callDirection}'); } return CallEvent.incoming; @@ -373,22 +400,27 @@ class MethodChannelTwilioVoice extends TwilioVoicePlatform { call.activeCall = createCallFromState(state); if (kDebugMode) { - printDebug('Ringing - From: ${call.activeCall!.from}, To: ${call.activeCall!.to}, Direction: ${call.activeCall!.callDirection}'); + printDebug( + 'Ringing - From: ${call.activeCall!.from}, To: ${call.activeCall!.to}, Direction: ${call.activeCall!.callDirection}'); } return CallEvent.ringing; } else if (state.startsWith("Answer")) { - call.activeCall = createCallFromState(state, callDirection: CallDirection.incoming); + call.activeCall = + createCallFromState(state, callDirection: CallDirection.incoming); if (kDebugMode) { - printDebug('Answer - From: ${call.activeCall!.from}, To: ${call.activeCall!.to}, Direction: ${call.activeCall!.callDirection}'); + printDebug( + 'Answer - From: ${call.activeCall!.from}, To: ${call.activeCall!.to}, Direction: ${call.activeCall!.callDirection}'); } return CallEvent.answer; } else if (state.startsWith("ReturningCall")) { - call.activeCall = createCallFromState(state, callDirection: CallDirection.outgoing); + call.activeCall = + createCallFromState(state, callDirection: CallDirection.outgoing); if (kDebugMode) { - printDebug('Returning Call - From: ${call.activeCall!.from}, To: ${call.activeCall!.to}, Direction: ${call.activeCall!.callDirection}'); + printDebug( + 'Returning Call - From: ${call.activeCall!.from}, To: ${call.activeCall!.to}, Direction: ${call.activeCall!.callDirection}'); } return CallEvent.returningCall; @@ -432,9 +464,13 @@ class MethodChannelTwilioVoice extends TwilioVoicePlatform { } } -ActiveCall createCallFromState(String state, {CallDirection? callDirection, bool initiated = false}) { +ActiveCall createCallFromState(String state, + {CallDirection? callDirection, bool initiated = false}) { List tokens = state.split('|'); - final direction = callDirection ?? ("incoming" == tokens[3].toLowerCase() ? CallDirection.incoming : CallDirection.outgoing); + final direction = callDirection ?? + ("incoming" == tokens[3].toLowerCase() + ? CallDirection.incoming + : CallDirection.outgoing); return ActiveCall( from: tokens[1], to: tokens[2], diff --git a/lib/_internal/platform_interface/twilio_voice_platform_interface.dart b/lib/_internal/platform_interface/twilio_voice_platform_interface.dart index 6d10a654..9b281422 100644 --- a/lib/_internal/platform_interface/twilio_voice_platform_interface.dart +++ b/lib/_internal/platform_interface/twilio_voice_platform_interface.dart @@ -181,8 +181,7 @@ abstract class TwilioVoicePlatform extends SharedPlatformInterface { Future setDefaultCallerName(String callerName); /// Android-only, shows background call UI - @Deprecated('custom call UI not used anymore, has no effect') - Future showBackgroundCallUI(); + Future showBackgroundCallUI(Map fcmData); /// Sends call events CallEvent parseCallEvent(String state);