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 bae65b9e..2a27b3fb 100644 --- a/android/src/main/kotlin/com/twilio/twilio_voice/TwilioVoicePlugin.kt +++ b/android/src/main/kotlin/com/twilio/twilio_voice/TwilioVoicePlugin.kt @@ -104,8 +104,8 @@ class TwilioVoicePlugin : FlutterPlugin, MethodCallHandler, EventChannel.StreamH private var isBluetoothOn: Boolean = false private var isMuted: Boolean = false private var isHolding: Boolean = false - private val callSid: String? - get() = TVConnectionService.getActiveCallHandle() + private var callSid: String? = null + private var hasStarted = false @@ -195,6 +195,9 @@ class TwilioVoicePlugin : FlutterPlugin, MethodCallHandler, EventChannel.StreamH error.message ) logEvent(message) + logEvent("", "Call Ended") + TVConnectionService.clearActiveConnections() + } override fun onConnected(call: Call) { @@ -471,6 +474,24 @@ class TwilioVoicePlugin : FlutterPlugin, MethodCallHandler, EventChannel.StreamH } } + TVMethodChannels.GetActiveCallOnResumeFromTerminatedState -> { + //is on call + val hasActiveCalls = isOnCall() + if(hasActiveCalls){ + val activeCalls = TVConnectionService.Companion.activeConnections + val currentCall = activeCalls.values.firstOrNull() + val isAnsweredCall = currentCall?.twilioCall?.state == Call.State.CONNECTED + if(isAnsweredCall){ + val from = extractUserNumber(currentCall?.twilioCall?.from ?: "") + val to = currentCall?.twilioCall?.to ?: "" + val callDirection = currentCall?.callDirection ?: CallDirection.INCOMING + logEvents("", arrayOf("Connected", from, to, callDirection.label )) + } + } + result.success(true) + + } + TVMethodChannels.IS_BLUETOOTH_ON -> { Log.d(TAG, "isBluetoothOn invoked") result.success(isBluetoothOn) @@ -506,8 +527,7 @@ class TwilioVoicePlugin : FlutterPlugin, MethodCallHandler, EventChannel.StreamH } TVMethodChannels.CALL_SID -> { - val activeCallHandle = TVConnectionService.getActiveCallHandle(); - result.success(activeCallHandle) + result.success(callSid) } TVMethodChannels.IS_ON_CALL -> { @@ -617,11 +637,22 @@ class TwilioVoicePlugin : FlutterPlugin, MethodCallHandler, EventChannel.StreamH ) return@onMethodCall } + + val callerName = call.argument(Constants.CALLER_NAME) ?: run { + result.error( + FlutterErrorCodes.MALFORMED_ARGUMENTS, + "No '${Constants.CALLER_NAME}' provided or invalid type", + null + ) + return@onMethodCall + } + + Log.d(TAG, "calling $from -> $to") accessToken?.let { token -> context?.let { ctx -> - val success = placeCall(ctx, token, from, to, params) + val success = placeCall(ctx, token, from, to, params,callerName ) result.success(success) } ?: run { Log.e(TAG, "Context is null, cannot place call") @@ -672,7 +703,7 @@ class TwilioVoicePlugin : FlutterPlugin, MethodCallHandler, EventChannel.StreamH accessToken?.let { token -> context?.let { ctx -> - val success = placeCall(ctx, token, from, to, params, connect = true) + val success = placeCall(ctx, token, from, to, params,null, connect = true) result.success(success) } ?: run { Log.e(TAG, "Context is null, cannot place call") @@ -1094,6 +1125,7 @@ class TwilioVoicePlugin : FlutterPlugin, MethodCallHandler, EventChannel.StreamH from: String?, to: String?, params: Map, + callerName: String?, connect: Boolean = false ): Boolean { assert(accessToken.isNotEmpty()) { "Twilio Access Token cannot be empty" } @@ -1140,6 +1172,7 @@ class TwilioVoicePlugin : FlutterPlugin, MethodCallHandler, EventChannel.StreamH } putExtra(TVConnectionService.EXTRA_TO, to) putExtra(TVConnectionService.EXTRA_FROM, from) + putExtra(TVConnectionService.EXTRA_CALLER_NAME, callerName) putExtra(TVConnectionService.EXTRA_OUTGOING_PARAMS, Bundle().apply { for ((key, value) in params) { putString(key, value) @@ -1585,6 +1618,18 @@ class TwilioVoicePlugin : FlutterPlugin, MethodCallHandler, EventChannel.StreamH } } + + fun extractUserNumber(input: String): String { + // Define the regular expression pattern to match the user_number part + val pattern = Regex("""user_number:([^\s:]+)""") + + // Search for the first match in the input string + val match = pattern.find(input) + + // Extract the matched part (user_number:+11230123) + return match?.groups?.get(1)?.value ?: input + } + private fun requestPermissionForPhoneState(onPermissionResult: (Boolean) -> Unit) { return requestPermissionOrShowRationale( "Read Phone State", @@ -1661,6 +1706,7 @@ class TwilioVoicePlugin : FlutterPlugin, MethodCallHandler, EventChannel.StreamH fun handleBroadcastIntent(intent: Intent) { when (intent.action) { TVBroadcastReceiver.ACTION_AUDIO_STATE -> { + println("Event called Basil : TVBroadcastReceiver.ACTION_AUDIO_STATE") val callAudioState: CallAudioState = intent.getParcelableExtraSafe(TVBroadcastReceiver.EXTRA_AUDIO_STATE) ?: run { Log.e( @@ -1715,19 +1761,20 @@ class TwilioVoicePlugin : FlutterPlugin, MethodCallHandler, EventChannel.StreamH ) return } - val from = callInvite.from ?: "" + val from = extractUserNumber(callInvite.from ?: "") val to = callInvite.to val params = JSONObject().apply { callInvite.customParameters.forEach { (key, value) -> put(key, value) } }.toString() -// callSid = callHandle + callSid = callHandle logEvents("", arrayOf("Incoming", from, to, CallDirection.INCOMING.label, params)) logEvents("", arrayOf("Ringing", from, to, CallDirection.INCOMING.label, params)) } TVBroadcastReceiver.ACTION_CALL_ENDED -> { + println("Event called Basil : TVBroadcastReceiver.ACTION_CALL_ENDED") val callHandle = intent.getStringExtra(TVBroadcastReceiver.EXTRA_CALL_HANDLE) ?: run { Log.e( @@ -1736,7 +1783,7 @@ class TwilioVoicePlugin : FlutterPlugin, MethodCallHandler, EventChannel.StreamH ) return } -// callSid = null + callSid = null Log.d(TAG, "handleBroadcastIntent: Call Ended $callHandle") logEvent("", "Call Ended") } @@ -1782,14 +1829,14 @@ class TwilioVoicePlugin : FlutterPlugin, MethodCallHandler, EventChannel.StreamH ) return } - val from = ci.from ?: "" + val from = extractUserNumber(ci.from ?: "") val to = ci.to val params = JSONObject().apply { ci.customParameters.forEach { (key, value) -> put(key, value) } }.toString() -// callSid = callHandle + callSid = callHandle logEvents("", arrayOf("Answer", from, to, CallDirection.INCOMING.label, params)) } @@ -1809,11 +1856,13 @@ class TwilioVoicePlugin : FlutterPlugin, MethodCallHandler, EventChannel.StreamH TVNativeCallActions.ACTION_REJECTED -> { logEvent("Call Rejected") + callSid = null } TVNativeCallActions.ACTION_ABORT -> { Log.d(TAG, "handleBroadcastIntent: Abort") logEvent("", "Call Ended") + callSid = null } TVNativeCallActions.ACTION_HOLD -> { @@ -1847,7 +1896,7 @@ class TwilioVoicePlugin : FlutterPlugin, MethodCallHandler, EventChannel.StreamH val direction = intent.getIntExtra(TVBroadcastReceiver.EXTRA_CALL_DIRECTION, -1) val callDirection = CallDirection.fromId(direction).toString() -// callSid = callHandle + callSid = callHandle logEvents("", arrayOf("Ringing", from, to, callDirection)) } @@ -1877,7 +1926,7 @@ class TwilioVoicePlugin : FlutterPlugin, MethodCallHandler, EventChannel.StreamH } val direction = intent.getIntExtra(TVBroadcastReceiver.EXTRA_CALL_DIRECTION, -1) val callDirection = CallDirection.fromId(direction)!!.label -// callSid = callHandle + callSid = callHandle logEvents("", arrayOf("Connected", from, to, callDirection)) } @@ -1892,6 +1941,10 @@ class TwilioVoicePlugin : FlutterPlugin, MethodCallHandler, EventChannel.StreamH return } logEvent("Call Error: ${code}, $message"); + logEvent("", "Call Ended") + callSid = null + TVConnectionService.clearActiveConnections() + } TVNativeCallEvents.EVENT_RECONNECTING -> { @@ -1904,15 +1957,18 @@ class TwilioVoicePlugin : FlutterPlugin, MethodCallHandler, EventChannel.StreamH TVNativeCallEvents.EVENT_DISCONNECTED_LOCAL -> { logEvent("", "Call Ended") + callSid = null } TVNativeCallEvents.EVENT_DISCONNECTED_REMOTE -> { logEvent("", "Call Ended") + callSid = null } TVNativeCallEvents.EVENT_MISSED -> { logEvent("", "Missed Call") logEvent("", "Call Ended") + callSid = null } else -> { diff --git a/android/src/main/kotlin/com/twilio/twilio_voice/call/TVParametersImpl.kt b/android/src/main/kotlin/com/twilio/twilio_voice/call/TVParametersImpl.kt index 6ae3e91a..3e8b35a2 100644 --- a/android/src/main/kotlin/com/twilio/twilio_voice/call/TVParametersImpl.kt +++ b/android/src/main/kotlin/com/twilio/twilio_voice/call/TVParametersImpl.kt @@ -22,17 +22,18 @@ class TVCallInviteParametersImpl(storage: Storage, callInvite: CallInvite) : TVP ?: customParameters[PARAM_CALLER_ID]?.let { resolveHumanReadableName(it) } ?: run { val mFrom = mCallInvite.from ?: "" - if (mFrom.isEmpty()) { - return mStorage.defaultCaller - } - - if (!mFrom.startsWith("client:")) { - // we have a number, return as is - return mFrom - } - - val mToName = mFrom.replace("client:", "") - return resolveHumanReadableName(mToName) +// if (mFrom.isEmpty()) { +// return mStorage.defaultCaller +// } +// +// if (!mFrom.startsWith("client:")) { +// // we have a number, return as is +// return mFrom +// } +// +// val mToName = mFrom.replace("client:", "") +// return resolveHumanReadableName(mToName) + return extractClient(mFrom) } } @@ -55,7 +56,7 @@ class TVCallInviteParametersImpl(storage: Storage, callInvite: CallInvite) : TVP override val fromRaw: String get() { - return mCallInvite.from ?: "" + return extractClient( mCallInvite.from ?: "") } override val toRaw: String @@ -91,17 +92,18 @@ class TVCallParametersImpl(storage: Storage, call: Call, callTo: String, callFro return customParameters[PARAM_CALLER_NAME] ?: customParameters[PARAM_CALLER_ID]?.let { resolveHumanReadableName(it) } ?: run { - if (mFrom.isEmpty()) { - return mStorage.defaultCaller - } - - if (!mFrom.startsWith("client:")) { - // we have a number, return as is - return mFrom - } - - val mFromName = mFrom.replace("client:", "") - return resolveHumanReadableName(mFromName) +// if (mFrom.isEmpty()) { +// return mStorage.defaultCaller +// } +// +// if (!mFrom.startsWith("client:")) { +// // we have a number, return as is +// return mFrom +// } +// +// val mFromName = mFrom.replace("client:", "") +// return resolveHumanReadableName(mFromName) + return extractClient(mFrom) } } @@ -126,7 +128,7 @@ class TVCallParametersImpl(storage: Storage, call: Call, callTo: String, callFro override val fromRaw: String get() { - return mFrom + return extractClient(mFrom) } override val toRaw: String @@ -197,4 +199,27 @@ open class TVParametersImpl(storage: Storage, override val callSid: String = "", override fun toString(): String { return "TVParametersImpl(callSid='$callSid', from='$from', fromRaw='$fromRaw' to='$to', toRaw='$toRaw', customParameters=$customParameters)" } + + fun extractUserNumber(input: String): String { + // Define the regular expression pattern to match the user_number part + val pattern = Regex("""user_number:([^\s:]+)""") + + // Search for the first match in the input string + val match = pattern.find(input) + + // Extract the matched part (user_number:+11230123) + return match?.groups?.get(1)?.value ?: input + } + + fun extractClient(input: String): String { + // // Define the regular expression pattern to match the client part + // val pattern = Regex("""client:([^\s:]+)""") + + // // Search for the first match in the input string + // val match = pattern.find(input) + + // // Extract the matched part (client:+11230(123)) + // return match?.groups?.get(1)?.value ?: input + return input + } } \ No newline at end of file diff --git a/android/src/main/kotlin/com/twilio/twilio_voice/constants/Constants.kt b/android/src/main/kotlin/com/twilio/twilio_voice/constants/Constants.kt index 0bed977a..31d9e57d 100644 --- a/android/src/main/kotlin/com/twilio/twilio_voice/constants/Constants.kt +++ b/android/src/main/kotlin/com/twilio/twilio_voice/constants/Constants.kt @@ -18,4 +18,5 @@ object Constants { const val kDefaultCaller: String = "defaultCaller" const val kDEVICETOKEN: String = "DEVICETOKEN" + const val CALLER_NAME: String = "CallerName" } \ No newline at end of file diff --git a/android/src/main/kotlin/com/twilio/twilio_voice/fcm/VoiceFirebaseMessagingService.kt b/android/src/main/kotlin/com/twilio/twilio_voice/fcm/VoiceFirebaseMessagingService.kt index ab916fbc..2c4d6c11 100644 --- a/android/src/main/kotlin/com/twilio/twilio_voice/fcm/VoiceFirebaseMessagingService.kt +++ b/android/src/main/kotlin/com/twilio/twilio_voice/fcm/VoiceFirebaseMessagingService.kt @@ -152,7 +152,7 @@ class VoiceFirebaseMessagingService : FirebaseMessagingService(), MessageListene // 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_INVITE , callInvite) putExtra(TVBroadcastReceiver.EXTRA_CALL_HANDLE, callInvite.callSid) LocalBroadcastManager.getInstance(applicationContext).sendBroadcast(this) } diff --git a/android/src/main/kotlin/com/twilio/twilio_voice/service/TVConnection.kt b/android/src/main/kotlin/com/twilio/twilio_voice/service/TVConnection.kt index adeb7f75..7ede5a81 100644 --- a/android/src/main/kotlin/com/twilio/twilio_voice/service/TVConnection.kt +++ b/android/src/main/kotlin/com/twilio/twilio_voice/service/TVConnection.kt @@ -44,15 +44,23 @@ class TVCallInviteConnection( setCallParameters(callParams) } - override fun onAnswer() { - Log.d(TAG, "onAnswer: onAnswer") - super.onAnswer() - twilioCall = callInvite.accept(context, this) - onAction?.onChange(TVNativeCallActions.ACTION_ANSWERED, Bundle().apply { - putParcelable(TVBroadcastReceiver.EXTRA_CALL_INVITE, callInvite) - putInt(TVBroadcastReceiver.EXTRA_CALL_DIRECTION, callDirection.id) - }) - } + override fun onAnswer() { + Log.d(TAG, "onAnswer: onAnswer") + super.onAnswer() + + // Accept the call and assign it to twilioCall + twilioCall = callInvite.accept(context, this) + + // Extract the user number from callInvite.from using your custom extractUserNumber method + val extractedFrom = extractUserNumber(callInvite.from ?: "") + + // Broadcast the call answered action with the extracted number + onAction?.onChange(TVNativeCallActions.ACTION_ANSWERED, Bundle().apply { + putParcelable(TVBroadcastReceiver.EXTRA_CALL_INVITE, callInvite) + putString(TVBroadcastReceiver.EXTRA_CALL_FROM, extractedFrom) // Use extracted number here + putInt(TVBroadcastReceiver.EXTRA_CALL_DIRECTION, callDirection.id) + }) +} fun acceptInvite() { Log.d(TAG, "acceptInvite: acceptInvite") @@ -183,12 +191,24 @@ open class TVCallConnection( onCallStateListener?.withValue(call.state) onEvent?.onChange(TVNativeCallEvents.EVENT_RINGING, Bundle().apply { putString(TVBroadcastReceiver.EXTRA_CALL_HANDLE, callParams?.callSid) - putString(TVBroadcastReceiver.EXTRA_CALL_FROM, callParams?.fromRaw) + putString(TVBroadcastReceiver.EXTRA_CALL_FROM,extractUserNumber( callParams?.fromRaw ?: "")) putString(TVBroadcastReceiver.EXTRA_CALL_TO, callParams?.toRaw) putInt(TVBroadcastReceiver.EXTRA_CALL_DIRECTION, callDirection.id) }) } + + fun extractUserNumber(input: String): String { + // Define the regular expression pattern to match the user_number part + val pattern = Regex("""user_number:([^\s:]+)""") + + // Search for the first match in the input string + val match = pattern.find(input) + + // Extract the matched part (user_number:+11230123) + return match?.groups?.get(1)?.value ?: input + } + override fun onConnected(call: Call) { Log.d(TAG, "onConnected: onConnected") twilioCall = call @@ -196,7 +216,7 @@ open class TVCallConnection( onCallStateListener?.withValue(call.state) onEvent?.onChange(TVNativeCallEvents.EVENT_CONNECTED, Bundle().apply { putString(TVBroadcastReceiver.EXTRA_CALL_HANDLE, callParams?.callSid) - putString(TVBroadcastReceiver.EXTRA_CALL_FROM, callParams?.fromRaw) + putString(TVBroadcastReceiver.EXTRA_CALL_FROM,extractUserNumber(callParams?.fromRaw ?: "" )) putString(TVBroadcastReceiver.EXTRA_CALL_TO, callParams?.toRaw) putInt(TVBroadcastReceiver.EXTRA_CALL_DIRECTION, callDirection.id) }) @@ -512,4 +532,6 @@ open class TVCallConnection( Log.e(TAG, "sendDigits: Unable to send digits, active call is null") } } -} \ No newline at end of file +} + + diff --git a/android/src/main/kotlin/com/twilio/twilio_voice/service/TVConnectionService.kt b/android/src/main/kotlin/com/twilio/twilio_voice/service/TVConnectionService.kt index 224fb286..caff44c8 100644 --- a/android/src/main/kotlin/com/twilio/twilio_voice/service/TVConnectionService.kt +++ b/android/src/main/kotlin/com/twilio/twilio_voice/service/TVConnectionService.kt @@ -148,6 +148,8 @@ class TVConnectionService : ConnectionService() { */ const val EXTRA_TO: String = "EXTRA_TO" + const val EXTRA_CALLER_NAME: String = "EXTRA_CALLER_NAME" + /** * Extra used with [ACTION_PLACE_OUTGOING_CALL] to place an outgoing call connection. Denotes the caller's identity. */ @@ -183,6 +185,14 @@ class TVConnectionService : ConnectionService() { return activeConnections.isNotEmpty() } + //clear active connections + fun clearActiveConnections() { + activeConnections.clear() + } + + + + /** * Active call definition is extended to include calls in which one can actively communicate, or call is on hold, or call is ringing or dialing. This applies only to this and calling functions. * Gets the first ongoing call handle, if any. Else, gets the first call on hold. Lastly, gets the first call in either a ringing or dialing state, if any. Returns null if there are no active calls. If there are more than one active calls, the first call handle is returned. @@ -304,7 +314,7 @@ class TVConnectionService : ConnectionService() { putString(TelecomManager.EXTRA_CALL_SUBJECT, callInvite.customParameters["_TWI_SUBJECT"]) } } - + android.util.Log.d(TAG, "onCallRecived basil: $extras") // Add new incoming call to the telecom manager telecomManager.addNewIncomingCall(phoneAccountHandle, extras) } @@ -330,6 +340,7 @@ class TVConnectionService : ConnectionService() { ACTION_HANGUP -> { val callHandle = it.getStringExtra(EXTRA_CALL_HANDLE) ?: getActiveCallHandle() ?: run { Log.e(TAG, "onStartCommand: ACTION_HANGUP is missing String EXTRA_CALL_HANDLE") + activeConnections.clear() return@let } @@ -354,7 +365,7 @@ class TVConnectionService : ConnectionService() { val token = getRequiredString(EXTRA_TOKEN) ?: return@let val to = getRequiredString(EXTRA_TO, allowNullIfRaw = true) val from = getRequiredString(EXTRA_FROM, allowNullIfRaw = true) - + val outgoingName = getRequiredString(EXTRA_CALLER_NAME, allowNullIfRaw = true) val params = buildMap { it.getParcelableExtraSafe(EXTRA_OUTGOING_PARAMS)?.let { bundle -> for (key in bundle.keySet()) { @@ -362,6 +373,7 @@ class TVConnectionService : ConnectionService() { } } put(EXTRA_TOKEN, token) + put(EXTRA_CALLER_NAME, outgoingName) if (!rawConnect) { to?.let { v -> put(EXTRA_TO, v) } from?.let { v -> put(EXTRA_FROM, v) } @@ -516,7 +528,7 @@ class TVConnectionService : ConnectionService() { // Setup connection event listeners and UI parameters attachCallEventListeners(connection, ci.callSid) - applyParameters(connection, callParams) + applyParameters(connection, callParams,null) connection.setRinging() startForegroundService() @@ -550,6 +562,11 @@ class TVConnectionService : ConnectionService() { throw Exception("onCreateOutgoingConnection: ACTION_PLACE_OUTGOING_CALL is missing String EXTRA_FROM"); } + val outGoingCallerName = myBundle.getString(EXTRA_CALLER_NAME) ?: run { + Log.e(TAG, "onCreateOutgoingConnection: ACTION_PLACE_OUTGOING_CALL is missing String EXTRA_FROM") + throw Exception("onCreateOutgoingConnection: ACTION_PLACE_OUTGOING_CALL is missing String EXTRA_CALLER_NAME"); + } + // Get all params from bundle val params = HashMap() myBundle.keySet().forEach { key -> @@ -591,7 +608,7 @@ class TVConnectionService : ConnectionService() { // If call is not attached, attach it if (!activeConnections.containsKey(callSid)) { - applyParameters(connection, callParams) + applyParameters(connection, callParams,outGoingCallerName ) attachCallEventListeners(connection, callSid) callParams.callSid = callSid } @@ -687,15 +704,38 @@ class TVConnectionService : ConnectionService() { * @param connection The connection to apply the parameters to. * @param params The parameters to apply to the connection. */ - private fun applyParameters(connection: T, params: TVParameters) { + private fun applyParameters(connection: T, params: TVParameters, outgoingCallerName: String?) { params.getExtra(TVParameters.PARAM_SUBJECT, null)?.let { connection.extras.putString(TelecomManager.EXTRA_CALL_SUBJECT, it) } val name = if(connection.callDirection == CallDirection.OUTGOING) params.to else params.from - connection.setAddress(Uri.fromParts(PhoneAccount.SCHEME_TEL, name, null), TelecomManager.PRESENTATION_ALLOWED) - connection.setCallerDisplayName(name, TelecomManager.PRESENTATION_ALLOWED) + + val userName = params.customParameters["client_name"] + val userNumber = extractUserNumber(name) + + if(connection.callDirection == CallDirection.OUTGOING){ + + connection.setAddress(Uri.fromParts(PhoneAccount.SCHEME_TEL, name, null), TelecomManager.PRESENTATION_ALLOWED) + connection.setCallerDisplayName(outgoingCallerName, TelecomManager.PRESENTATION_ALLOWED) + } else { + connection.setAddress(Uri.fromParts(PhoneAccount.SCHEME_TEL, userNumber, null), TelecomManager.PRESENTATION_ALLOWED) + connection.setCallerDisplayName(userName, TelecomManager.PRESENTATION_ALLOWED) + } + } + fun extractUserNumber(input: String): String { + // Define the regular expression pattern to match the user_number part + val pattern = Regex("""user_number:([^\s:]+)""") + + // Search for the first match in the input string + val match = pattern.find(input) + + // Extract the matched part (user_number:+11230123) + return match?.groups?.get(1)?.value ?: input + } + + private fun sendBroadcastEvent(ctx: Context, event: String, callSid: String?, extras: Bundle? = null) { Intent(ctx, TVBroadcastReceiver::class.java).apply { action = event @@ -717,6 +757,9 @@ class TVConnectionService : ConnectionService() { override fun onCreateOutgoingConnectionFailed(connectionManagerPhoneAccount: PhoneAccountHandle?, request: ConnectionRequest?) { super.onCreateOutgoingConnectionFailed(connectionManagerPhoneAccount, request) Log.d(TAG, "onCreateOutgoingConnectionFailed") + println("Call error happened basil") + //clear the active connections + activeConnections.clear() stopForegroundService() } 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 92fcdddc..366dc10b 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 @@ -47,6 +47,7 @@ enum class TVMethodChannels(val method: String) { IS_PHONE_ACCOUNT_ENABLED("isPhoneAccountEnabled"), REJECT_CALL_ON_NO_PERMISSIONS("rejectCallOnNoPermissions"), IS_REJECTING_CALL_ON_NO_PERMISSIONS("isRejectingCallOnNoPermissions"), + GetActiveCallOnResumeFromTerminatedState("getActiveCallOnResumeFromTerminatedState"), UPDATE_CALLKIT_ICON("updateCallKitIcon"), CONNECT("connect"); diff --git a/android/src/main/kotlin/com/twilio/twilio_voice/types/TelecomManagerExtension.kt b/android/src/main/kotlin/com/twilio/twilio_voice/types/TelecomManagerExtension.kt index 2bbf7817..da43b9d3 100644 --- a/android/src/main/kotlin/com/twilio/twilio_voice/types/TelecomManagerExtension.kt +++ b/android/src/main/kotlin/com/twilio/twilio_voice/types/TelecomManagerExtension.kt @@ -4,8 +4,12 @@ import android.app.Activity import android.content.ComponentName import android.content.Context import android.content.Intent +import android.content.pm.ApplicationInfo +import android.content.pm.PackageManager import android.graphics.drawable.Icon import android.os.Build +import android.provider.Settings +import java.util.Locale import android.telecom.PhoneAccount import android.telecom.PhoneAccountHandle import android.telecom.TelecomManager @@ -60,27 +64,126 @@ object TelecomManagerExtension { } fun TelecomManager.openPhoneAccountSettings(activity: Activity) { - if (Build.MANUFACTURER.equals("Samsung", ignoreCase = true)|| Build.MANUFACTURER.equals("OnePlus", ignoreCase = true)) { - try { - val intent = Intent(TelecomManager.ACTION_CHANGE_PHONE_ACCOUNTS) - intent.component = ComponentName( - "com.android.server.telecom", - "com.android.server.telecom.settings.EnableAccountPreferenceActivity" - ) - intent.flags = Intent.FLAG_ACTIVITY_NEW_TASK - activity.startActivity(intent, null) - } catch (e: Exception) { - Log.e("TelecomManager", "openPhoneAccountSettings: ${e.message}") - - // use fallback method - val intent = Intent(TelecomManager.ACTION_CHANGE_PHONE_ACCOUNTS) - activity.startActivity(intent, null) + if (launchTelecomEnablePreference(activity)) { + return + } + + val packageManager = activity.packageManager + val candidateIntents = buildList { + addAll(manufacturerSpecificIntents(activity)) + add(Intent(TelecomManager.ACTION_CHANGE_PHONE_ACCOUNTS)) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { + add(Intent(Settings.ACTION_MANAGE_DEFAULT_APPS_SETTINGS)) + } + add(Intent(Settings.ACTION_SETTINGS)) + } + + candidateIntents.forEach { baseIntent -> + val intent = Intent(baseIntent).apply { + flags = Intent.FLAG_ACTIVITY_NEW_TASK + if (component == null) { + resolveSystemComponent(packageManager, baseIntent)?.let { component = it } + } + } + + if (canHandleIntent(packageManager, intent)) { + try { + activity.startActivity(intent) + return + } catch (error: Exception) { + Log.w("TelecomManager", "openPhoneAccountSettings: failed to launch ${intent.action}: ${error.message}") + } + } + } + + Log.e("TelecomManager", "openPhoneAccountSettings: Unable to find compatible settings activity.") + } + + private fun TelecomManager.launchTelecomEnablePreference(activity: Activity): Boolean { + val manufacturer = Build.MANUFACTURER?.lowercase(Locale.US).orEmpty() + val brand = Build.BRAND?.lowercase(Locale.US).orEmpty() + val targets = setOf("samsung", "oneplus", "realme", "oppo") + + if (manufacturer !in targets && brand !in targets) { + return false + } + + val baseIntent = Intent(TelecomManager.ACTION_CHANGE_PHONE_ACCOUNTS).apply { + component = ComponentName( + "com.android.server.telecom", + "com.android.server.telecom.settings.EnableAccountPreferenceActivity" + ) + flags = Intent.FLAG_ACTIVITY_NEW_TASK + } + + return try { + activity.startActivity(baseIntent) + true + } catch (error: Exception) { + Log.w( + "TelecomManager", + "launchTelecomEnablePreference: primary component failed: ${error.message}" + ) + + tryLegacyTelecomIntent(activity) + } + } + + private fun tryLegacyTelecomIntent(activity: Activity): Boolean { + return try { + val fallbackIntent = Intent(TelecomManager.ACTION_CHANGE_PHONE_ACCOUNTS).apply { + flags = Intent.FLAG_ACTIVITY_NEW_TASK } + activity.startActivity(fallbackIntent) + true + } catch (fallbackError: Exception) { + Log.w( + "TelecomManager", + "tryLegacyTelecomIntent: fallback intent failed: ${fallbackError.message}" + ) + false + } + } + + private fun manufacturerSpecificIntents(activity: Activity): List { + val manufacturer = Build.MANUFACTURER?.lowercase(Locale.US).orEmpty() + val brand = Build.BRAND?.lowercase(Locale.US).orEmpty() + + if (manufacturer !in setOf("oppo", "realme") && brand !in setOf("oppo", "realme")) { + return emptyList() + } + + val components = listOf( + ComponentName("com.android.settings", "com.android.settings.Settings\$DefaultAppSettingsActivity"), + ComponentName("com.coloros.phonemanager", "com.coloros.phonemanager.defaultapp.DefaultAppManagerActivity"), + ComponentName("com.coloros.phonemanager", "com.coloros.phonemanager.defaultapp.DefaultAppListActivity"), + ComponentName("com.coloros.phonemanager", "com.coloros.phonemanager.defaultapp.DefaultAppEntryActivity") + ) - } else { - val intent = Intent(TelecomManager.ACTION_CHANGE_PHONE_ACCOUNTS) - activity.startActivity(intent, null) + val explicitIntents = components.map { componentName -> + Intent(Intent.ACTION_VIEW).apply { component = componentName } } + + val packagedDefaultAppsIntent = Intent(Settings.ACTION_MANAGE_DEFAULT_APPS_SETTINGS).apply { + `package` = "com.android.settings" + } + + return explicitIntents + packagedDefaultAppsIntent + } + + private fun resolveSystemComponent(packageManager: PackageManager, intent: Intent): ComponentName? { + val matches = packageManager.queryIntentActivities(intent, 0) + val preferred = matches.firstOrNull { resolveInfo -> + resolveInfo.activityInfo?.applicationInfo?.flags?.and(ApplicationInfo.FLAG_SYSTEM) != 0 + } ?: matches.firstOrNull() + + return preferred?.activityInfo?.let { activityInfo -> + ComponentName(activityInfo.packageName, activityInfo.name) + } + } + + private fun canHandleIntent(packageManager: PackageManager, intent: Intent): Boolean { + return intent.resolveActivity(packageManager) != null } diff --git a/example/ios/Flutter/ephemeral/flutter_lldb_helper.py b/example/ios/Flutter/ephemeral/flutter_lldb_helper.py new file mode 100644 index 00000000..a88caf99 --- /dev/null +++ b/example/ios/Flutter/ephemeral/flutter_lldb_helper.py @@ -0,0 +1,32 @@ +# +# Generated file, do not edit. +# + +import lldb + +def handle_new_rx_page(frame: lldb.SBFrame, bp_loc, extra_args, intern_dict): + """Intercept NOTIFY_DEBUGGER_ABOUT_RX_PAGES and touch the pages.""" + base = frame.register["x0"].GetValueAsAddress() + page_len = frame.register["x1"].GetValueAsUnsigned() + + # Note: NOTIFY_DEBUGGER_ABOUT_RX_PAGES will check contents of the + # first page to see if handled it correctly. This makes diagnosing + # misconfiguration (e.g. missing breakpoint) easier. + data = bytearray(page_len) + data[0:8] = b'IHELPED!' + + error = lldb.SBError() + frame.GetThread().GetProcess().WriteMemory(base, data, error) + if not error.Success(): + print(f'Failed to write into {base}[+{page_len}]', error) + return + +def __lldb_init_module(debugger: lldb.SBDebugger, _): + target = debugger.GetDummyTarget() + # Caveat: must use BreakpointCreateByRegEx here and not + # BreakpointCreateByName. For some reasons callback function does not + # get carried over from dummy target for the later. + bp = target.BreakpointCreateByRegex("^NOTIFY_DEBUGGER_ABOUT_RX_PAGES$") + bp.SetScriptCallbackFunction('{}.handle_new_rx_page'.format(__name__)) + bp.SetAutoContinue(True) + print("-- LLDB integration loaded --") diff --git a/example/ios/Flutter/ephemeral/flutter_lldbinit b/example/ios/Flutter/ephemeral/flutter_lldbinit new file mode 100644 index 00000000..e3ba6fbe --- /dev/null +++ b/example/ios/Flutter/ephemeral/flutter_lldbinit @@ -0,0 +1,5 @@ +# +# Generated file, do not edit. +# + +command script import --relative-to-command-file flutter_lldb_helper.py diff --git a/example/ios/Runner.xcodeproj/project.pbxproj b/example/ios/Runner.xcodeproj/project.pbxproj index 571ef349..4577b8a3 100644 --- a/example/ios/Runner.xcodeproj/project.pbxproj +++ b/example/ios/Runner.xcodeproj/project.pbxproj @@ -3,12 +3,12 @@ archiveVersion = 1; classes = { }; - objectVersion = 54; + objectVersion = 50; objects = { /* Begin PBXBuildFile section */ + 03CB5A298F020DFEF5C15BF9 /* Pods_Runner.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 05447BA51642A37726BD13F6 /* Pods_Runner.framework */; }; 1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */ = {isa = PBXBuildFile; fileRef = 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */; }; - 31921B64B8BFB1D14D5B3FD8 /* Pods_Runner.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = D4B6D8E27AE1C4FFBA3F93FB /* Pods_Runner.framework */; }; 3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */ = {isa = PBXBuildFile; fileRef = 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */; }; 74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74858FAE1ED2DC5600515810 /* AppDelegate.swift */; }; 97C146FC1CF9000F007C117D /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FA1CF9000F007C117D /* Main.storyboard */; }; @@ -32,14 +32,15 @@ /* End PBXCopyFilesBuildPhase section */ /* Begin PBXFileReference section */ + 05447BA51642A37726BD13F6 /* Pods_Runner.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Runner.framework; sourceTree = BUILT_PRODUCTS_DIR; }; + 0FD3BBD256FE4A9D5485441A /* Pods-Runner.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.debug.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig"; sourceTree = ""; }; + 11DC5E9496FA4567BF18955B /* Pods-Runner.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.release.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig"; sourceTree = ""; }; 1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = GeneratedPluginRegistrant.h; sourceTree = ""; }; 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = GeneratedPluginRegistrant.m; sourceTree = ""; }; 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = AppFrameworkInfo.plist; path = Flutter/AppFrameworkInfo.plist; sourceTree = ""; }; - 459910D105676AEA61D927D4 /* Pods-Runner.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.debug.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig"; sourceTree = ""; }; 74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "Runner-Bridging-Header.h"; sourceTree = ""; }; 74858FAE1ED2DC5600515810 /* AppDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; 7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = Release.xcconfig; path = Flutter/Release.xcconfig; sourceTree = ""; }; - 842FBBAEA17BDB340A4F1B1A /* Pods-Runner.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.profile.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.profile.xcconfig"; sourceTree = ""; }; 9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Debug.xcconfig; path = Flutter/Debug.xcconfig; sourceTree = ""; }; 9740EEB31CF90195004384FC /* Generated.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Generated.xcconfig; path = Flutter/Generated.xcconfig; sourceTree = ""; }; 97C146EE1CF9000F007C117D /* Runner.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Runner.app; sourceTree = BUILT_PRODUCTS_DIR; }; @@ -47,8 +48,7 @@ 97C146FD1CF9000F007C117D /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; 97C147001CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = ""; }; 97C147021CF9000F007C117D /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; - A1A5B7F7A8766107BD0F93D1 /* Pods-Runner.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.release.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig"; sourceTree = ""; }; - D4B6D8E27AE1C4FFBA3F93FB /* Pods_Runner.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Runner.framework; sourceTree = BUILT_PRODUCTS_DIR; }; + D081A7677F6E275D726CDCC0 /* Pods-Runner.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.profile.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.profile.xcconfig"; sourceTree = ""; }; F112FE34262881BD00CD6B41 /* GoogleService-Info.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; path = "GoogleService-Info.plist"; sourceTree = ""; }; F18CB62F25F2968500653E8A /* Runner.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = Runner.entitlements; sourceTree = ""; }; F1DAF69F2624E5BF00652032 /* en */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = en; path = en.lproj/Localizable.strings; sourceTree = ""; }; @@ -60,17 +60,17 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( - 31921B64B8BFB1D14D5B3FD8 /* Pods_Runner.framework in Frameworks */, + 03CB5A298F020DFEF5C15BF9 /* Pods_Runner.framework in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; /* End PBXFrameworksBuildPhase section */ /* Begin PBXGroup section */ - 0E04F9C8A2F4C5BDEBF53455 /* Frameworks */ = { + 3F251483BF6591EB5D9A52FE /* Frameworks */ = { isa = PBXGroup; children = ( - D4B6D8E27AE1C4FFBA3F93FB /* Pods_Runner.framework */, + 05447BA51642A37726BD13F6 /* Pods_Runner.framework */, ); name = Frameworks; sourceTree = ""; @@ -78,9 +78,9 @@ 8070562AC07E27A0A2D66AC1 /* Pods */ = { isa = PBXGroup; children = ( - 459910D105676AEA61D927D4 /* Pods-Runner.debug.xcconfig */, - A1A5B7F7A8766107BD0F93D1 /* Pods-Runner.release.xcconfig */, - 842FBBAEA17BDB340A4F1B1A /* Pods-Runner.profile.xcconfig */, + 0FD3BBD256FE4A9D5485441A /* Pods-Runner.debug.xcconfig */, + 11DC5E9496FA4567BF18955B /* Pods-Runner.release.xcconfig */, + D081A7677F6E275D726CDCC0 /* Pods-Runner.profile.xcconfig */, ); path = Pods; sourceTree = ""; @@ -103,7 +103,7 @@ 97C146F01CF9000F007C117D /* Runner */, 97C146EF1CF9000F007C117D /* Products */, 8070562AC07E27A0A2D66AC1 /* Pods */, - 0E04F9C8A2F4C5BDEBF53455 /* Frameworks */, + 3F251483BF6591EB5D9A52FE /* Frameworks */, ); sourceTree = ""; }; @@ -140,14 +140,15 @@ isa = PBXNativeTarget; buildConfigurationList = 97C147051CF9000F007C117D /* Build configuration list for PBXNativeTarget "Runner" */; buildPhases = ( - 4BF2D767FFDDA6DFF200B4DE /* [CP] Check Pods Manifest.lock */, + DEDD89877982A2AF7CD3C3DA /* [CP] Check Pods Manifest.lock */, 9740EEB61CF901F6004384FC /* Run Script */, 97C146EA1CF9000F007C117D /* Sources */, 97C146EB1CF9000F007C117D /* Frameworks */, 97C146EC1CF9000F007C117D /* Resources */, 9705A1C41CF9048500538489 /* Embed Frameworks */, 3B06AD1E1E4923F5004D2608 /* Thin Binary */, - E523679BDB4BF3E08E047D74 /* [CP] Embed Pods Frameworks */, + 2C0550190AA281E4DABEDF6E /* [CP] Embed Pods Frameworks */, + BBC5A4627D503F8C23DDACC1 /* [CP] Copy Pods Resources */, ); buildRules = ( ); @@ -164,7 +165,7 @@ 97C146E61CF9000F007C117D /* Project object */ = { isa = PBXProject; attributes = { - LastUpgradeCheck = 1510; + LastUpgradeCheck = 1020; ORGANIZATIONNAME = ""; TargetAttributes = { 97C146ED1CF9000F007C117D = { @@ -209,47 +210,39 @@ /* End PBXResourcesBuildPhase section */ /* Begin PBXShellScriptBuildPhase section */ - 3B06AD1E1E4923F5004D2608 /* Thin Binary */ = { + 2C0550190AA281E4DABEDF6E /* [CP] Embed Pods Frameworks */ = { isa = PBXShellScriptBuildPhase; - alwaysOutOfDate = 1; buildActionMask = 2147483647; files = ( ); - inputPaths = ( - "${TARGET_BUILD_DIR}/${INFOPLIST_PATH}", + inputFileListPaths = ( + "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-input-files.xcfilelist", ); - name = "Thin Binary"; - outputPaths = ( + name = "[CP] Embed Pods Frameworks"; + outputFileListPaths = ( + "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-output-files.xcfilelist", ); runOnlyForDeploymentPostprocessing = 0; shellPath = /bin/sh; - shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" embed_and_thin"; + shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks.sh\"\n"; + showEnvVarsInLog = 0; }; - 4BF2D767FFDDA6DFF200B4DE /* [CP] Check Pods Manifest.lock */ = { + 3B06AD1E1E4923F5004D2608 /* Thin Binary */ = { isa = PBXShellScriptBuildPhase; buildActionMask = 2147483647; files = ( ); - inputFileListPaths = ( - ); inputPaths = ( - "${PODS_PODFILE_DIR_PATH}/Podfile.lock", - "${PODS_ROOT}/Manifest.lock", - ); - name = "[CP] Check Pods Manifest.lock"; - outputFileListPaths = ( ); + name = "Thin Binary"; outputPaths = ( - "$(DERIVED_FILE_DIR)/Pods-Runner-checkManifestLockResult.txt", ); runOnlyForDeploymentPostprocessing = 0; shellPath = /bin/sh; - shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n"; - showEnvVarsInLog = 0; + shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" embed_and_thin"; }; 9740EEB61CF901F6004384FC /* Run Script */ = { isa = PBXShellScriptBuildPhase; - alwaysOutOfDate = 1; buildActionMask = 2147483647; files = ( ); @@ -262,21 +255,43 @@ shellPath = /bin/sh; shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" build"; }; - E523679BDB4BF3E08E047D74 /* [CP] Embed Pods Frameworks */ = { + BBC5A4627D503F8C23DDACC1 /* [CP] Copy Pods Resources */ = { isa = PBXShellScriptBuildPhase; buildActionMask = 2147483647; files = ( ); inputFileListPaths = ( - "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-input-files.xcfilelist", + "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources-${CONFIGURATION}-input-files.xcfilelist", ); - name = "[CP] Embed Pods Frameworks"; + name = "[CP] Copy Pods Resources"; outputFileListPaths = ( - "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-output-files.xcfilelist", + "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources-${CONFIGURATION}-output-files.xcfilelist", ); runOnlyForDeploymentPostprocessing = 0; shellPath = /bin/sh; - shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks.sh\"\n"; + shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources.sh\"\n"; + showEnvVarsInLog = 0; + }; + DEDD89877982A2AF7CD3C3DA /* [CP] Check Pods Manifest.lock */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + ); + inputPaths = ( + "${PODS_PODFILE_DIR_PATH}/Podfile.lock", + "${PODS_ROOT}/Manifest.lock", + ); + name = "[CP] Check Pods Manifest.lock"; + outputFileListPaths = ( + ); + outputPaths = ( + "$(DERIVED_FILE_DIR)/Pods-Runner-checkManifestLockResult.txt", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n"; showEnvVarsInLog = 0; }; /* End PBXShellScriptBuildPhase section */ @@ -364,7 +379,7 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 12.0; + IPHONEOS_DEPLOYMENT_TARGET = 11.0; MTL_ENABLE_DEBUG_INFO = NO; SDKROOT = iphoneos; SUPPORTED_PLATFORMS = iphoneos; @@ -384,7 +399,7 @@ DEVELOPMENT_TEAM = 366269TEN6; ENABLE_BITCODE = NO; INFOPLIST_FILE = Runner/Info.plist; - IPHONEOS_DEPLOYMENT_TARGET = 12.0; + IPHONEOS_DEPLOYMENT_TARGET = 13.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -445,7 +460,7 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 12.0; + IPHONEOS_DEPLOYMENT_TARGET = 11.0; MTL_ENABLE_DEBUG_INFO = YES; ONLY_ACTIVE_ARCH = YES; SDKROOT = iphoneos; @@ -495,7 +510,7 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 12.0; + IPHONEOS_DEPLOYMENT_TARGET = 11.0; MTL_ENABLE_DEBUG_INFO = NO; SDKROOT = iphoneos; SUPPORTED_PLATFORMS = iphoneos; @@ -517,7 +532,7 @@ DEVELOPMENT_TEAM = 366269TEN6; ENABLE_BITCODE = NO; INFOPLIST_FILE = Runner/Info.plist; - IPHONEOS_DEPLOYMENT_TARGET = 12.0; + IPHONEOS_DEPLOYMENT_TARGET = 13.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -542,7 +557,7 @@ DEVELOPMENT_TEAM = 366269TEN6; ENABLE_BITCODE = NO; INFOPLIST_FILE = Runner/Info.plist; - IPHONEOS_DEPLOYMENT_TARGET = 12.0; + IPHONEOS_DEPLOYMENT_TARGET = 13.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", diff --git a/example/lib/main.dart b/example/lib/main.dart index c917e9d8..2dbc435f 100644 --- a/example/lib/main.dart +++ b/example/lib/main.dart @@ -1,10 +1,10 @@ import 'dart:async'; import 'dart:io'; +import 'package:firebase_analytics/firebase_analytics.dart'; import 'package:firebase_auth/firebase_auth.dart'; import 'package:firebase_core/firebase_core.dart'; import 'package:firebase_messaging/firebase_messaging.dart'; -import 'package:firebase_analytics/firebase_analytics.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:twilio_voice/twilio_voice.dart'; @@ -31,7 +31,8 @@ enum RegistrationMethod { static RegistrationMethod? fromString(String? value) { if (value == null) return null; - return RegistrationMethod.values.firstWhereOrNull((element) => element.name == value); + return RegistrationMethod.values + .firstWhereOrNull((element) => element.name == value); } static RegistrationMethod? loadFromEnvironment() { @@ -43,8 +44,7 @@ enum RegistrationMethod { void main() async { WidgetsFlutterBinding.ensureInitialized(); if (kIsWeb) { - if(firebaseEnabled) { - + if (firebaseEnabled) { // Add firebase config here const options = FirebaseOptions( apiKey: '', @@ -68,11 +68,13 @@ void main() async { await Firebase.initializeApp(); } - if(firebaseEnabled) { + if (firebaseEnabled) { FirebaseAnalytics.instance.logEvent(name: "app_started"); } - final app = App(registrationMethod: RegistrationMethod.loadFromEnvironment() ?? RegistrationMethod.env); + final app = App( + registrationMethod: + RegistrationMethod.loadFromEnvironment() ?? RegistrationMethod.env); return runApp(MaterialApp(home: app)); } @@ -134,10 +136,18 @@ class _AppState extends State { // } // } - if(firebaseEnabled) { + if (firebaseEnabled) { FirebaseAnalytics.instance.logEvent(name: "registration", parameters: { "method": widget.registrationMethod.name, - "platform": kIsWeb ? "web" : Platform.isAndroid ? "android" : Platform.isIOS ? "ios" : Platform.isMacOS ? "macos" : "unknown" + "platform": kIsWeb + ? "web" + : Platform.isAndroid + ? "android" + : Platform.isIOS + ? "ios" + : Platform.isMacOS + ? "macos" + : "unknown" }); } } @@ -147,12 +157,13 @@ class _AppState extends State { printDebug("voip-registering access token"); String? androidToken; - if(!kIsWeb && Platform.isAndroid) { + if (!kIsWeb && Platform.isAndroid) { // Get device token for Android only androidToken = await FirebaseMessaging.instance.getToken(); printDebug("androidToken is ${androidToken!}"); } - final result = await TwilioVoice.instance.setTokens(accessToken: accessToken, deviceToken: androidToken); + final result = await TwilioVoice.instance + .setTokens(accessToken: accessToken, deviceToken: androidToken); return result ?? false; } @@ -172,7 +183,8 @@ class _AppState extends State { printDebug("voip-registering with environment variables"); if (myId == null || myToken == null) { - printDebug("Failed to register with environment variables, please provide ID and TOKEN"); + printDebug( + "Failed to register with environment variables, please provide ID and TOKEN"); return false; } userId = myId; @@ -289,7 +301,8 @@ class _AppState extends State { // applies to web only if (kIsWeb || Platform.isAndroid) { final activeCall = TwilioVoice.instance.call.activeCall; - if (activeCall != null && activeCall.callDirection == CallDirection.incoming) { + if (activeCall != null && + activeCall.callDirection == CallDirection.incoming) { _showWebIncomingCallDialog(); } } @@ -329,7 +342,11 @@ class _AppState extends State { return; } printDebug("starting call to $clientIdentifier"); - TwilioVoice.instance.call.place(to: clientIdentifier, from: userId, extraOptions: {"_TWI_SUBJECT": "Company Name"}); + TwilioVoice.instance.call.place( + to: clientIdentifier, + from: userId, + extraOptions: {"_TWI_SUBJECT": "Company Name"}, + callerName: ""); } Future _onRegisterWithToken(String token, [String? identity]) async { @@ -409,7 +426,8 @@ class _AppState extends State { } } - Future showIncomingCallScreen(BuildContext context, ActiveCall activeCall) async { + Future showIncomingCallScreen( + BuildContext context, ActiveCall activeCall) async { if (!kIsWeb && !Platform.isAndroid) { printDebug("showIncomingCallScreen only for web"); return false; @@ -446,7 +464,8 @@ class _LogoutAction extends StatelessWidget { final void Function()? onSuccess; final void Function(String error)? onFailure; - const _LogoutAction({Key? key, this.onSuccess, this.onFailure}) : super(key: key); + const _LogoutAction({Key? key, this.onSuccess, this.onFailure}) + : super(key: key); @override Widget build(BuildContext context) { diff --git a/example/macos/Flutter/GeneratedPluginRegistrant.swift b/example/macos/Flutter/GeneratedPluginRegistrant.swift index a9ff770c..e27a8f68 100644 --- a/example/macos/Flutter/GeneratedPluginRegistrant.swift +++ b/example/macos/Flutter/GeneratedPluginRegistrant.swift @@ -13,8 +13,8 @@ import firebase_messaging import twilio_voice func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { - FLTFirebaseFunctionsPlugin.register(with: registry.registrar(forPlugin: "FLTFirebaseFunctionsPlugin")) - FLTFirebaseAnalyticsPlugin.register(with: registry.registrar(forPlugin: "FLTFirebaseAnalyticsPlugin")) + FirebaseFunctionsPlugin.register(with: registry.registrar(forPlugin: "FirebaseFunctionsPlugin")) + FirebaseAnalyticsPlugin.register(with: registry.registrar(forPlugin: "FirebaseAnalyticsPlugin")) FLTFirebaseAuthPlugin.register(with: registry.registrar(forPlugin: "FLTFirebaseAuthPlugin")) FLTFirebaseCorePlugin.register(with: registry.registrar(forPlugin: "FLTFirebaseCorePlugin")) FLTFirebaseMessagingPlugin.register(with: registry.registrar(forPlugin: "FLTFirebaseMessagingPlugin")) diff --git a/ios/Classes/SwiftTwilioVoicePlugin.swift b/ios/Classes/SwiftTwilioVoicePlugin.swift index 91647558..50369016 100644 --- a/ios/Classes/SwiftTwilioVoicePlugin.swift +++ b/ios/Classes/SwiftTwilioVoicePlugin.swift @@ -44,9 +44,13 @@ public class SwiftTwilioVoicePlugin: NSObject, FlutterPlugin, FlutterStreamHand var callKitCallController: CXCallController var userInitiatedDisconnect: Bool = false var callOutgoing: Bool = false - + var outgoingCallerName = "" + private var activeCalls: [UUID: CXCall] = [:] + // Speaker state management + private var desiredSpeakerState: Bool = false + static var appName: String { get { return (Bundle.main.infoDictionary!["CFBundleName"] as? String) ?? "Define CFBundleName" @@ -69,7 +73,7 @@ public class SwiftTwilioVoicePlugin: NSObject, FlutterPlugin, FlutterStreamHand //super.init(coder: aDecoder) super.init() callObserver.setDelegate(self, queue: DispatchQueue.main) - + callKitProvider.setDelegate(self, queue: nil) _ = updateCallKitIcon(icon: defaultIcon) @@ -133,6 +137,8 @@ public class SwiftTwilioVoicePlugin: NSObject, FlutterPlugin, FlutterStreamHand } else if flutterCall.method == "makeCall" { guard let callTo = arguments["To"] as? String else {return} guard let callFrom = arguments["From"] as? String else {return} + let callerName = arguments["CallerName"] as? String + outgoingCallerName = callerName ?? "" self.callArgs = arguments self.callOutgoing = true if let accessToken = arguments["accessToken"] as? String{ @@ -187,7 +193,9 @@ public class SwiftTwilioVoicePlugin: NSObject, FlutterPlugin, FlutterStreamHand guard let eventSink = eventSink else { return } - eventSink(speakerIsOn ? "Speaker On" : "Speaker Off") + // Return the actual state after attempting to set it + let actualState = isSpeakerOn() + eventSink(actualState ? "Speaker On" : "Speaker Off") } else if flutterCall.method == "isOnSpeaker" { @@ -226,6 +234,19 @@ public class SwiftTwilioVoicePlugin: NSObject, FlutterPlugin, FlutterStreamHand self.call!.sendDigits(digits); } } + else if flutterCall.method == "getActiveCallOnResumeFromTerminatedState" + { + let isCallAnswered = self.call != nil + if let call = self.call{ + let direction = (self.callOutgoing ? "Outgoing" : "Incoming") + let from = extractUserNumber(from: call.from ?? self.identity) + let to = call.to ?? self.callTo + self.sendPhoneCallEvents(description: "Connected|\(from)|\(to)|\(direction)", isError: false) + } + result(true) + } + + /* else if flutterCall.method == "receiveCalls" { guard let clientIdentity = arguments["clientIdentifier"] as? String else {return} @@ -381,7 +402,7 @@ public class SwiftTwilioVoicePlugin: NSObject, FlutterPlugin, FlutterStreamHand func answerCall(callInvite: CallInvite) { let answerCallAction = CXAnswerCallAction(call: callInvite.uuid) let transaction = CXTransaction(action: answerCallAction) - + callKitCallController.request(transaction) { error in if let error = error { self.sendPhoneCallEvents(description: "LOG|AnswerCallAction transaction request failed: \(error.localizedDescription)", isError: false) @@ -389,9 +410,17 @@ public class SwiftTwilioVoicePlugin: NSObject, FlutterPlugin, FlutterStreamHand } } } - + func makeCall(to: String) { + // Check if there's a pending call invite + if self.callInvite != nil { + self.sendPhoneCallEvents(description: "LOG|Cannot make call - there's a pending incoming call", isError: false) + let ferror: FlutterError = FlutterError(code: "CALL_IN_PROGRESS", message: "Cannot make call while there's a pending incoming call", details: nil) + _result?(ferror) + return + } + // Cancel the previous call before making another one. if (self.call != nil) { self.userInitiatedDisconnect = true @@ -549,8 +578,10 @@ public class SwiftTwilioVoicePlugin: NSObject, FlutterPlugin, FlutterStreamHand self.sendPhoneCallEvents(description: "LOG|Successfully unregistered from VoIP push notifications.", isError: false) } } + //DO NOT REMOVE DEVICE TOKEN , AS IT IS UNNECESSARY AND USER WILL HAVE TO RESTART THE APP TO GET NEW DEVICE TOKEN + //IF WE REMOVED FROM HERE , WHICH WILL CAUSE TO FAILURE IN REGISTRATION //UserDefaults.standard.removeObject(forKey: kCachedDeviceToken) - + // Remove the cached binding as credentials are invalidated //UserDefaults.standard.removeObject(forKey: kCachedBindingDate) } @@ -562,6 +593,12 @@ public class SwiftTwilioVoicePlugin: NSObject, FlutterPlugin, FlutterStreamHand public func pushRegistry(_ registry: PKPushRegistry, didReceiveIncomingPushWith payload: PKPushPayload, for type: PKPushType) { self.sendPhoneCallEvents(description: "LOG|pushRegistry:didReceiveIncomingPushWithPayload:forType:", isError: false) + // Check if user is already on a call or has a pending call invite + if self.call != nil || self.callInvite != nil { + self.sendPhoneCallEvents(description: "LOG|Ignoring incoming push - user is already on a call or has pending call invite", isError: false) + return + } + if (type == PKPushType.voIP) { TwilioVoiceSDK.handleNotification(payload.dictionaryPayload, delegate: self, delegateQueue: nil) } @@ -573,6 +610,14 @@ public class SwiftTwilioVoicePlugin: NSObject, FlutterPlugin, FlutterStreamHand */ public func pushRegistry(_ registry: PKPushRegistry, didReceiveIncomingPushWith payload: PKPushPayload, for type: PKPushType, completion: @escaping () -> Void) { self.sendPhoneCallEvents(description: "LOG|pushRegistry:didReceiveIncomingPushWithPayload:forType:completion:", isError: false) + + // Check if user is already on a call or has a pending call invite + if self.call != nil || self.callInvite != nil { + self.sendPhoneCallEvents(description: "LOG|Ignoring incoming push - user is already on a call or has pending call invite", isError: false) + completion() + return + } + // Save for later when the notification is properly handled. // self.incomingPushCompletionCallback = completion @@ -602,12 +647,12 @@ public class SwiftTwilioVoicePlugin: NSObject, FlutterPlugin, FlutterStreamHand activeCalls[uuid] = call // Add or update call } } - + // Check if a call with a given UUID exists func isCallActive(uuid: UUID) -> Bool { return activeCalls[uuid] != nil } - + func incomingPushHandled() { if let completion = self.incomingPushCompletionCallback { self.incomingPushCompletionCallback = nil @@ -619,6 +664,19 @@ public class SwiftTwilioVoicePlugin: NSObject, FlutterPlugin, FlutterStreamHand public func callInviteReceived(callInvite: CallInvite) { self.sendPhoneCallEvents(description: "LOG|callInviteReceived:", isError: false) + // Check if user is already on a call or has a pending call invite + if self.call != nil { + self.sendPhoneCallEvents(description: "LOG|Rejecting incoming call - user is already on an active call", isError: false) + callInvite.reject() + return + } + + if self.callInvite != nil { + self.sendPhoneCallEvents(description: "LOG|Rejecting incoming call - user already has a pending call invite", isError: false) + callInvite.reject() + return + } + /** * The TTL of a registration is 1 year. The TTL for registration for this device/identity * pair is reset to 1 year whenever a new registration occurs or a push notification is @@ -626,14 +684,36 @@ public class SwiftTwilioVoicePlugin: NSObject, FlutterPlugin, FlutterStreamHand */ UserDefaults.standard.set(Date(), forKey: kCachedBindingDate) - var from:String = callInvite.from ?? defaultCaller - from = from.replacingOccurrences(of: "client:", with: "") - - self.sendPhoneCallEvents(description: "Ringing|\(from)|\(callInvite.to)|Incoming\(formatCustomParams(params: callInvite.customParameters))", isError: false) - reportIncomingCall(from: from, uuid: callInvite.uuid) - self.callInvite = callInvite + let incomingCallerDetails:String = callInvite.from ?? defaultCaller + let userNumber:String = extractUserNumber(from: incomingCallerDetails) + let client:String = callInvite.customParameters?["client_name"] ?? userNumber + var from:String = callInvite.from ?? defaultCaller + from = userNumber + + self.sendPhoneCallEvents(description: "Ringing|\(from)|\(callInvite.to)|Incoming\(formatCustomParams(params: callInvite.customParameters))", isError: false) + reportIncomingCall(from: client, uuid: callInvite.uuid) + self.callInvite = callInvite + } + + func extractUserNumber(from input: String) -> String { + // Define the regular expression pattern to match the user_number part + let pattern = #"user_number:([^\s:]+)"# + + // Create a regular expression object + let regex = try? NSRegularExpression(pattern: pattern) + + // Search for the first match in the input string + if let match = regex?.firstMatch(in: input, range: NSRange(location: 0, length: input.utf16.count)) { + // Extract the matched part (user_number:+11230123) + if let range = Range(match.range(at: 1), in: input) { + return String(input[range]) + } + } + // Return the input if no match is found + return input } - + + func formatCustomParams(params: [String:Any]?)->String{ guard let customParameters = params else{return ""} do{ @@ -650,7 +730,8 @@ public class SwiftTwilioVoicePlugin: NSObject, FlutterPlugin, FlutterStreamHand public func cancelledCallInviteReceived(cancelledCallInvite: CancelledCallInvite, error: Error) { self.sendPhoneCallEvents(description: "Missed Call", isError: false) self.sendPhoneCallEvents(description: "LOG|cancelledCallInviteCanceled:", isError: false) - self.showMissedCallNotification(from: cancelledCallInvite.from, to: cancelledCallInvite.to) + //no need to notification for easify + // self.showMissedCallNotification(from: cancelledCallInvite.from, to: cancelledCallInvite.to) if (self.callInvite == nil) { self.sendPhoneCallEvents(description: "LOG|No pending call invite", isError: false) return @@ -700,16 +781,21 @@ public class SwiftTwilioVoicePlugin: NSObject, FlutterPlugin, FlutterStreamHand // MARK: TVOCallDelegate public func callDidStartRinging(call: Call) { let direction = (self.callOutgoing ? "Outgoing" : "Incoming") - let from = (call.from ?? self.identity) + let from = self.callOutgoing ? call.from ?? self.identity : extractUserNumber(from: (call.from ?? "")) let to = (call.to ?? self.callTo) - self.sendPhoneCallEvents(description: "Ringing|\(from)|\(to)|\(direction)", isError: false) + self.sendPhoneCallEvents(description: "Ringing|\(String(describing: from))|\(to)|\(direction)", isError: false) + + // Try to apply speaker setting early if audio session is ready + if audioDevice.isEnabled && desiredSpeakerState { + applySpeakerSetting(toSpeaker: desiredSpeakerState) + } //self.placeCallButton.setTitle("Ringing", for: .normal) } public func callDidConnect(call: Call) { let direction = (self.callOutgoing ? "Outgoing" : "Incoming") - let from = (call.from ?? self.identity) + let from = extractUserNumber(from:(call.from ?? self.identity)) let to = (call.to ?? self.callTo) self.sendPhoneCallEvents(description: "Connected|\(from)|\(to)|\(direction)", isError: false) @@ -717,7 +803,8 @@ public class SwiftTwilioVoicePlugin: NSObject, FlutterPlugin, FlutterStreamHand callKitCompletionCallback(true) } - toggleAudioRoute(toSpeaker: false) + // Apply the desired speaker state now that call is connected + applySpeakerSetting(toSpeaker: desiredSpeakerState) } public func call(call: Call, isReconnectingWithError error: Error) { @@ -777,9 +864,16 @@ public class SwiftTwilioVoicePlugin: NSObject, FlutterPlugin, FlutterStreamHand self.callOutgoing = false self.userInitiatedDisconnect = false + // Reset speaker state when call ends + desiredSpeakerState = false } func isSpeakerOn() -> Bool { + // If no active call, return the desired state + guard self.call != nil else { + return desiredSpeakerState + } + // Source: https://stackoverflow.com/a/51759708/4628115 let currentRoute = AVAudioSession.sharedInstance().currentRoute for output in currentRoute.outputs { @@ -787,7 +881,7 @@ public class SwiftTwilioVoicePlugin: NSObject, FlutterPlugin, FlutterStreamHand case AVAudioSession.Port.builtInSpeaker: return true; default: - return false; + continue; // Check other outputs } } return false; @@ -800,17 +894,33 @@ public class SwiftTwilioVoicePlugin: NSObject, FlutterPlugin, FlutterStreamHand // MARK: AVAudioSession func toggleAudioRoute(toSpeaker: Bool) { + // Store the desired speaker state + desiredSpeakerState = toSpeaker + + // If no active call, just store the preference + guard self.call != nil else { + self.sendPhoneCallEvents(description: "LOG|Storing speaker preference: \(toSpeaker) - no active call", isError: false) + return + } + + // Apply the speaker setting immediately + applySpeakerSetting(toSpeaker: toSpeaker) + } + + private func applySpeakerSetting(toSpeaker: Bool) { // The mode set by the Voice SDK is "VoiceChat" so the default audio route is the built-in receiver. Use port override to switch the route. audioDevice.block = { DefaultAudioDevice.DefaultAVAudioSessionConfigurationBlock() do { if (toSpeaker) { try AVAudioSession.sharedInstance().overrideOutputAudioPort(.speaker) + self.sendPhoneCallEvents(description: "LOG|Successfully set audio to speaker", isError: false) } else { try AVAudioSession.sharedInstance().overrideOutputAudioPort(.none) + self.sendPhoneCallEvents(description: "LOG|Successfully set audio to earpiece", isError: false) } } catch { - self.sendPhoneCallEvents(description: "LOG|\(error.localizedDescription)", isError: false) + self.sendPhoneCallEvents(description: "LOG|Failed to set audio route: \(error.localizedDescription)", isError: false) } } audioDevice.block() @@ -829,6 +939,11 @@ public class SwiftTwilioVoicePlugin: NSObject, FlutterPlugin, FlutterStreamHand public func provider(_ provider: CXProvider, didActivate audioSession: AVAudioSession) { self.sendPhoneCallEvents(description: "LOG|provider:didActivateAudioSession:", isError: false) audioDevice.isEnabled = true + + // Apply the desired speaker state when audio session is activated + if self.call != nil { + applySpeakerSetting(toSpeaker: desiredSpeakerState) + } } public func provider(_ provider: CXProvider, didDeactivate audioSession: AVAudioSession) { @@ -924,7 +1039,7 @@ public class SwiftTwilioVoicePlugin: NSObject, FlutterPlugin, FlutterStreamHand let callUpdate = CXCallUpdate() callUpdate.remoteHandle = callHandle - callUpdate.localizedCallerName = self.clients[handle] ?? self.clients["defaultCaller"] ?? self.defaultCaller + callUpdate.localizedCallerName = self.outgoingCallerName callUpdate.supportsDTMF = false callUpdate.supportsHolding = true callUpdate.supportsGrouping = false @@ -940,7 +1055,8 @@ public class SwiftTwilioVoicePlugin: NSObject, FlutterPlugin, FlutterStreamHand let callUpdate = CXCallUpdate() callUpdate.remoteHandle = callHandle - callUpdate.localizedCallerName = clients[from] ?? self.clients["defaultCaller"] ?? defaultCaller + // If the client is not registered, USE THE THE FROM NUMBER + callUpdate.localizedCallerName = formatUSPhoneNumber(from) callUpdate.supportsDTMF = true callUpdate.supportsHolding = true callUpdate.supportsGrouping = false @@ -956,6 +1072,29 @@ public class SwiftTwilioVoicePlugin: NSObject, FlutterPlugin, FlutterStreamHand } } + // Format the phone number to US format if it is a valid US number else return the number as is + func formatUSPhoneNumber(_ number: String) -> String { + // Ensure the number starts with "+1" and has exactly 12 characters + guard number.hasPrefix("+1"), number.count == 12 else { + return number + } + + // Extract the digits after "+1" + let digits = number.suffix(10) + + // Check if all characters are digits + guard digits.allSatisfy({ $0.isNumber }) else { + return number + } + + // Format the number + let areaCode = digits.prefix(3) + let middle = digits.dropFirst(3).prefix(3) + let last = digits.suffix(4) + + return "+1 (\(areaCode)) \(middle)-\(last)" + } + func performEndCallAction(uuid: UUID) { self.sendPhoneCallEvents(description: "LOG|performEndCallAction method invoked", isError: false) @@ -966,7 +1105,7 @@ public class SwiftTwilioVoicePlugin: NSObject, FlutterPlugin, FlutterStreamHand self.sendPhoneCallEvents(description: "Call Ended", isError: false) return } - + let endCallAction = CXEndCallAction(call: uuid) let transaction = CXTransaction(action: endCallAction) @@ -985,6 +1124,11 @@ public class SwiftTwilioVoicePlugin: NSObject, FlutterPlugin, FlutterStreamHand return } + // Send Connecting event before initiating the call + let from = self.identity + let to = self.callTo + self.sendPhoneCallEvents(description: "Connecting|\(from)|\(to)|Outgoing", isError: false) + let connectOptions: ConnectOptions = ConnectOptions(accessToken: token) { (builder) in for (key, value) in self.callArgs { if (key != "From") { @@ -1005,7 +1149,7 @@ public class SwiftTwilioVoicePlugin: NSObject, FlutterPlugin, FlutterStreamHand } self.sendPhoneCallEvents(description: "LOG|performAnswerVoiceCall: answering call", isError: false) let theCall = ci.accept(options: acceptOptions, delegate: self) - self.sendPhoneCallEvents(description: "Answer|\(theCall.from!)|\(theCall.to!)|Incoming\(formatCustomParams(params: ci.customParameters))", isError:false) + self.sendPhoneCallEvents(description: "Answer|\(String(describing: extractUserNumber(from: theCall.from!)))|\(theCall.to!)|Incoming\(formatCustomParams(params: ci.customParameters))", isError:false) self.call = theCall self.callKitCompletionCallback = completionHandler self.callInvite = nil diff --git a/lib/_internal/method_channel/twilio_call_method_channel.dart b/lib/_internal/method_channel/twilio_call_method_channel.dart index c1f1265d..e277c909 100644 --- a/lib/_internal/method_channel/twilio_call_method_channel.dart +++ b/lib/_internal/method_channel/twilio_call_method_channel.dart @@ -23,12 +23,18 @@ class MethodChannelTwilioCall extends TwilioCallPlatform { /// /// [extraOptions] will be added to the callPayload sent to your server @override - Future place({required String from, required String to, Map? extraOptions}) { - _activeCall = ActiveCall(from: from, to: to, callDirection: CallDirection.outgoing); + Future place( + {required String from, + required String to, + required String callerName, + Map? extraOptions}) { + _activeCall = + ActiveCall(from: from, to: to, callDirection: CallDirection.outgoing); var options = extraOptions ?? {}; options['From'] = from; options['To'] = to; + options['CallerName'] = callerName; return _channel.invokeMethod('makeCall', options); } @@ -41,13 +47,15 @@ class MethodChannelTwilioCall extends TwilioCallPlatform { /// Checks if there is an ongoing call @override Future isOnCall() { - return _channel.invokeMethod('isOnCall', {}).then((bool? value) => value ?? false); + return _channel.invokeMethod('isOnCall', + {}).then((bool? value) => value ?? false); } /// Gets the active call's SID. This will be null until the first Ringing event occurs @override Future getSid() { - return _channel.invokeMethod('call-sid', {}).then((String? value) => value); + return _channel.invokeMethod('call-sid', + {}).then((String? value) => value); } /// Answers incoming call @@ -61,7 +69,8 @@ class MethodChannelTwilioCall extends TwilioCallPlatform { /// In future, native mobile will also respect the [holdCall] value. @override Future holdCall({bool holdCall = true}) { - return _channel.invokeMethod('holdCall', {"shouldHold": holdCall}); + return _channel + .invokeMethod('holdCall', {"shouldHold": holdCall}); } /// Query's active call holding state @@ -73,7 +82,8 @@ class MethodChannelTwilioCall extends TwilioCallPlatform { /// Toggles mute state to provided value @override Future toggleMute(bool isMuted) { - return _channel.invokeMethod('toggleMute', {"muted": isMuted}); + return _channel + .invokeMethod('toggleMute', {"muted": isMuted}); } /// Query's mute status of call, true if call is muted @@ -85,7 +95,8 @@ class MethodChannelTwilioCall extends TwilioCallPlatform { /// Toggles speaker state to provided value @override Future toggleSpeaker(bool speakerIsOn) { - return _channel.invokeMethod('toggleSpeaker', {"speakerIsOn": speakerIsOn}); + return _channel.invokeMethod( + 'toggleSpeaker', {"speakerIsOn": speakerIsOn}); } /// Switches Audio Device @@ -101,12 +112,14 @@ class MethodChannelTwilioCall extends TwilioCallPlatform { @override Future sendDigits(String digits) { - return _channel.invokeMethod('sendDigits', {"digits": digits}); + return _channel + .invokeMethod('sendDigits', {"digits": digits}); } @override Future toggleBluetooth({bool bluetoothOn = true}) { - return _channel.invokeMethod('toggleBluetooth', {"bluetoothOn": bluetoothOn}); + return _channel.invokeMethod( + 'toggleBluetooth', {"bluetoothOn": bluetoothOn}); } @override @@ -122,4 +135,11 @@ class MethodChannelTwilioCall extends TwilioCallPlatform { }; return _channel.invokeMethod('connect', options); } + + //getActiveCallOnResumeFromTerminatedState + @override + Future getActiveCallOnResumeFromTerminatedState() { + return _channel.invokeMethod( + 'getActiveCallOnResumeFromTerminatedState', {}); + } } diff --git a/lib/_internal/method_channel/twilio_voice_method_channel.dart b/lib/_internal/method_channel/twilio_voice_method_channel.dart index ecbbcf4f..0e23ac3e 100644 --- a/lib/_internal/method_channel/twilio_voice_method_channel.dart +++ b/lib/_internal/method_channel/twilio_voice_method_channel.dart @@ -28,7 +28,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!; } @@ -42,7 +44,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. @@ -50,7 +55,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 @@ -58,7 +64,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 @@ -67,7 +74,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 @@ -87,7 +95,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 @@ -98,7 +107,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 @@ -109,7 +119,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 @@ -120,13 +131,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 @@ -143,7 +156,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 @@ -165,7 +179,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 @@ -187,7 +202,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 @@ -209,7 +225,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 @@ -248,7 +265,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. @@ -259,7 +278,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 @@ -276,7 +296,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 @@ -284,13 +305,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 @@ -298,7 +321,8 @@ 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 @@ -338,7 +362,9 @@ class MethodChannelTwilioVoice extends TwilioVoicePlatform { // // https://www.twilio.com/docs/api/errors/31486 // The callee is busy. - if (tokens[1].contains("31600") || tokens[1].contains("31603") || tokens[1].contains("31486")) { + if (tokens[1].contains("31600") || + tokens[1].contains("31603") || + tokens[1].contains("31486")) { call.activeCall = null; return CallEvent.declined; } else if (tokens.toString().toLowerCase().contains("call rejected")) { @@ -355,6 +381,14 @@ class MethodChannelTwilioVoice extends TwilioVoicePlatform { return CallEvent.declined; } return CallEvent.log; + } else if (state.startsWith("Connecting|")) { + call.activeCall = createCallFromState(state); + if (kDebugMode) { + printDebug( + 'Connecting - From: ${call.activeCall!.from}, To: ${call.activeCall!.to}, Direction: ${call.activeCall!.callDirection}'); + } + call.activeCall = createCallFromState(state, initiated: true); + return CallEvent.connecting; } else if (state.startsWith("Connected|")) { call.activeCall = createCallFromState(state, initiated: true); if (kDebugMode) { @@ -364,10 +398,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; @@ -375,22 +411,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; @@ -446,9 +487,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_call_platform_interface.dart b/lib/_internal/platform_interface/twilio_call_platform_interface.dart index 63f94c05..537b3207 100644 --- a/lib/_internal/platform_interface/twilio_call_platform_interface.dart +++ b/lib/_internal/platform_interface/twilio_call_platform_interface.dart @@ -29,7 +29,11 @@ abstract class TwilioCallPlatform extends SharedPlatformInterface { /// Places new call /// /// [extraOptions] will be added to the callPayload sent to your server - Future place({required String from, required String to, Map? extraOptions}); + Future place( + {required String from, + required String to, + required String callerName, + Map? extraOptions}); /// Places new call using raw parameters passed directly to Twilio's REST API endpoint 'makeCall'. Returns true if successful, false otherwise. /// @@ -77,4 +81,7 @@ abstract class TwilioCallPlatform extends SharedPlatformInterface { /// Send digits to active call Future sendDigits(String digits); + + //getActiveCallOnResumeFromTerminatedState + Future getActiveCallOnResumeFromTerminatedState(); } diff --git a/lib/_internal/twilio_voice_web.dart b/lib/_internal/twilio_voice_web.dart index e85c273f..93faa302 100644 --- a/lib/_internal/twilio_voice_web.dart +++ b/lib/_internal/twilio_voice_web.dart @@ -1,28 +1,24 @@ import 'dart:async'; import 'dart:convert'; import 'dart:developer'; - // TODO(cybex-dev) implement package:web // ignore: deprecated_member_use import 'dart:html' as html; import 'package:flutter/foundation.dart'; import 'package:flutter_web_plugins/flutter_web_plugins.dart'; - // Added as temporary measure till sky_engine includes js_util (allowInterop()) // TODO(cybex-dev) implement js_interop for allowInterop function // ignore: deprecated_member_use import 'package:js/js.dart' as js; - // TODO(cybex-dev) implement js_interop for js_util package // ignore: deprecated_member_use import 'package:js/js_util.dart' as js_util; -import 'package:twilio_voice/_internal/js/call/call_status.dart'; - // This is required for JS interop, do not remove even though linter complains // TODO(cybex-dev) implement js_interop for js_util package // ignore: unused_import,deprecated_member_use import 'package:js/js_util.dart'; +import 'package:twilio_voice/_internal/js/call/call_status.dart'; import 'package:twilio_voice/_internal/platform_interface/twilio_voice_platform_interface.dart'; import 'package:web_callkit/web_callkit_web.dart'; @@ -51,8 +47,10 @@ class Logger { /// The event will be sent as a String with the following format: /// - (if prefix is not empty): "prefix|description", where '|' is separator /// - (if prefix is empty): "description" - static void logLocalEventEntries(List entries, {String prefix = "LOG", String separator = "|"}) { - logLocalEvent(entries.join(separator), prefix: prefix, separator: separator); + static void logLocalEventEntries(List entries, + {String prefix = "LOG", String separator = "|"}) { + logLocalEvent(entries.join(separator), + prefix: prefix, separator: separator); } /// Logs event to EventChannel. @@ -60,9 +58,11 @@ class Logger { /// The event will be sent as a String with the following format: /// - (if prefix is not empty): "prefix|description", where '|' is separator /// - (if prefix is empty): "description" - static void logLocalEvent(String description, {String prefix = "LOG", String separator = "|"}) async { + static void logLocalEvent(String description, + {String prefix = "LOG", String separator = "|"}) async { if (!kIsWeb) { - throw UnimplementedError("Use eventChannel() via sendPhoneEvents on platform implementation"); + throw UnimplementedError( + "Use eventChannel() via sendPhoneEvents on platform implementation"); } // eventChannel.binaryMessenger.handlePlatformMessage( // _kEventChannelName, @@ -83,7 +83,6 @@ class Logger { /// The web implementation of [TwilioVoicePlatform]. class TwilioVoiceWeb extends MethodChannelTwilioVoice { - static const _codecs = ["opus", "pcmu"]; static const _closeProtection = true; final Map _soundMap = {}; @@ -113,7 +112,8 @@ class TwilioVoiceWeb extends MethodChannelTwilioVoice { webCallkit.setOnCallActionHandler(_onCallkitCallActionListener); } - void _onCallkitCallActionListener(String uuid, CKCallAction action, CKActionSource source) { + void _onCallkitCallActionListener( + String uuid, CKCallAction action, CKActionSource source) { printDebug("CallKit action: $action"); switch (action) { case CKCallAction.answer: @@ -161,9 +161,11 @@ class TwilioVoiceWeb extends MethodChannelTwilioVoice { html.Navigator get _webNavigatorDelegate => html.window.navigator; - html.Permissions? get _webPermissionsDelegate => _webNavigatorDelegate.permissions; + html.Permissions? get _webPermissionsDelegate => + _webNavigatorDelegate.permissions; - html.MediaDevices? get _webMediaDevicesDelegate => _webNavigatorDelegate.mediaDevices; + html.MediaDevices? get _webMediaDevicesDelegate => + _webNavigatorDelegate.mediaDevices; late final Call _call = Call(); @@ -223,11 +225,14 @@ class TwilioVoiceWeb extends MethodChannelTwilioVoice { Future requestMicAccess() async { Logger.logLocalEvent("requesting mic permission"); try { - final isSafariOrFirefox = RegExp(r'^((?!chrome|android).)*safari|firefox', caseSensitive: false).hasMatch(_webNavigatorDelegate.userAgent); + final isSafariOrFirefox = + RegExp(r'^((?!chrome|android).)*safari|firefox', caseSensitive: false) + .hasMatch(_webNavigatorDelegate.userAgent); if (isSafariOrFirefox && _webPermissionsDelegate != null) { try { - final result = await _webPermissionsDelegate!.request({"name": "microphone"}); + final result = + await _webPermissionsDelegate!.request({"name": "microphone"}); if (result.state == "granted") return true; } catch (e) { printDebug("Failed to request microphone permission"); @@ -239,7 +244,9 @@ class TwilioVoiceWeb extends MethodChannelTwilioVoice { /// This dirty hack to get media stream. Request (to show permissions popup on Chrome /// and other browsers, then stop the stream to release the permission) /// TODO(cybex-dev) - check supported media streams - html.MediaStream mediaStream = await _webMediaDevicesDelegate?.getUserMedia({"audio": true}) ?? await _webNavigatorDelegate.getUserMedia(audio: true); + html.MediaStream mediaStream = + await _webMediaDevicesDelegate?.getUserMedia({"audio": true}) ?? + await _webNavigatorDelegate.getUserMedia(audio: true); mediaStream.getTracks().forEach((track) => track.stop()); return hasMicAccess(); } catch (e) { @@ -333,7 +340,8 @@ class TwilioVoiceWeb extends MethodChannelTwilioVoice { void _clearCalls() { webCallkit.getCalls().toList().forEach((element) { - webCallkit.reportCallDisconnected(element.uuid, response: CKDisconnectResponse.local); + webCallkit.reportCallDisconnected(element.uuid, + response: CKDisconnectResponse.local); }); } @@ -341,7 +349,8 @@ class TwilioVoiceWeb extends MethodChannelTwilioVoice { /// See [twilio_js.Device.new] /// Note: [deviceToken] is ignored for web @override - Future setTokens({required String accessToken, String? deviceToken}) async { + Future setTokens( + {required String accessToken, String? deviceToken}) async { // TODO use updateOptions for Twilio device assert(accessToken.isNotEmpty, "Access token cannot be empty"); // assert(deviceToken != null && deviceToken.isNotEmpty, "Device token cannot be null or empty"); @@ -462,7 +471,9 @@ class TwilioVoiceWeb extends MethodChannelTwilioVoice { final from = params["From"] ?? ""; if (from.startsWith("client:")) { final clientName = from.substring(7); - return _localStorage.getRegisteredClient(clientName) ?? _localStorage.getRegisteredClient("defaultCaller") ?? clientName; + return _localStorage.getRegisteredClient(clientName) ?? + _localStorage.getRegisteredClient("defaultCaller") ?? + clientName; } else { return from; } @@ -514,7 +525,7 @@ class TwilioVoiceWeb extends MethodChannelTwilioVoice { final name = soundName.jsName; - if(url == null || url.isEmpty) { + if (url == null || url.isEmpty) { _soundMap.remove(name); } else { // Use provided url @@ -530,7 +541,7 @@ class TwilioVoiceWeb extends MethodChannelTwilioVoice { /// Documentation: https://www.twilio.com/docs/voice/sdks/javascript/twiliodevice#deviceoptionssounds-properties-and-default-sounds @override Future updateSounds({Map? sounds}) async { - if(device == null) { + if (device == null) { printDebug("Device is not initialized, cannot update sounds"); return; } @@ -609,7 +620,8 @@ class Call extends MethodChannelTwilioCall { /// Not currently implemented for web @override Future toggleSpeaker(bool speakerIsOn) async { - Logger.logLocalEvent(speakerIsOn ? "Speaker On" : "Speaker Off", prefix: ""); + Logger.logLocalEvent(speakerIsOn ? "Speaker On" : "Speaker Off", + prefix: ""); return Future.value(false); } @@ -725,7 +737,8 @@ class Call extends MethodChannelTwilioCall { CallStatus callStatus = getCallStatus(_jsCall!); // reject incoming call that is both outbound ringing or inbound pending - if (callStatus == CallStatus.ringing || callStatus == CallStatus.pending) { + if (callStatus == CallStatus.ringing || + callStatus == CallStatus.pending) { _jsCall!.reject(); } else { _jsCall!.disconnect(); @@ -750,13 +763,20 @@ class Call extends MethodChannelTwilioCall { /// /// See [twilio_js.Device.connect] @override - Future place({required String from, required String to, Map? extraOptions}) async { - assert(device != null, "Twilio device is null, make sure you have initialized the device first by calling [ setTokens({required String accessToken, String? deviceToken}) ] "); + Future place( + {required String callerName, + required String from, + required String to, + Map? extraOptions}) async { + assert(device != null, + "Twilio device is null, make sure you have initialized the device first by calling [ setTokens({required String accessToken, String? deviceToken}) ] "); assert(from.isNotEmpty, "'from' cannot be empty"); assert(to.isNotEmpty, "'to' cannot be empty"); final options = (extraOptions ?? {}); - assert(!options.keys.contains("From"), "'from' cannot be passed in 'extraOptions'"); - assert(!options.keys.contains("To"), "'to' cannot be passed in 'extraOptions'"); + assert(!options.keys.contains("From"), + "'from' cannot be passed in 'extraOptions'"); + assert(!options.keys.contains("To"), + "'to' cannot be passed in 'extraOptions'"); Logger.logLocalEvent("Making new call"); // handle parameters @@ -792,7 +812,8 @@ class Call extends MethodChannelTwilioCall { /// See [twilio_js.Device.connect] @override Future connect({Map? extraOptions}) async { - assert(device != null, "Twilio device is null, make sure you have initialized the device first by calling [ setTokens({required String accessToken, String? deviceToken}) ] "); + assert(device != null, + "Twilio device is null, make sure you have initialized the device first by calling [ setTokens({required String accessToken, String? deviceToken}) ] "); Logger.logLocalEvent("Making new call with Connect"); // handle parameters @@ -861,13 +882,15 @@ class Call extends MethodChannelTwilioCall { final params = getCallParams(_jsCall!); final from = params["From"] ?? ""; final to = params["To"] ?? ""; - final direction = _jsCall!.direction == "INCOMING" ? "Incoming" : "Outgoing"; + final direction = + _jsCall!.direction == "INCOMING" ? "Incoming" : "Outgoing"; Logger.logLocalEventEntries( ["Ringing", from, to, direction], prefix: "", ); final sid = _getSid(); - webCallkit.reportOutgoingCall(uuid: sid!, handle: to, metadata: params, data: params); + webCallkit.reportOutgoingCall( + uuid: sid!, handle: to, metadata: params, data: params); } } @@ -903,8 +926,9 @@ class Call extends MethodChannelTwilioCall { await webCallkit.requestPermissions(); final params = getCallParams(call); final callSid = params["CallSid"]; - if(callSid != null) { - webCallkit.reportCallDisconnected(callSid, response: CKDisconnectResponse.remote); + if (callSid != null) { + webCallkit.reportCallDisconnected(callSid, + response: CKDisconnectResponse.remote); } } @@ -925,11 +949,15 @@ class Call extends MethodChannelTwilioCall { // reject incoming call that is both outbound ringing or inbound pending // TODO(cybex-dev): check call status for call disconnects - if (callStatus == CallStatus.ringing || callStatus == CallStatus.pending || callStatus == CallStatus.closed) { + if (callStatus == CallStatus.ringing || + callStatus == CallStatus.pending || + callStatus == CallStatus.closed) { Logger.logLocalEvent("Missed Call", prefix: ""); - webCallkit.reportCallDisconnected(callSid!, response: CKDisconnectResponse.missed); + webCallkit.reportCallDisconnected(callSid!, + response: CKDisconnectResponse.missed); } else { - webCallkit.reportCallDisconnected(callSid!, response: CKDisconnectResponse.local); + webCallkit.reportCallDisconnected(callSid!, + response: CKDisconnectResponse.local); } } @@ -942,7 +970,8 @@ class Call extends MethodChannelTwilioCall { nativeCall = null; } Logger.logLocalEvent("Call Rejected"); - webCallkit.reportCallDisconnected(callSid!, response: CKDisconnectResponse.rejected); + webCallkit.reportCallDisconnected(callSid!, + response: CKDisconnectResponse.rejected); } /// On reject (inbound) call @@ -985,7 +1014,8 @@ class Call extends MethodChannelTwilioCall { return parseCallStatus(status); } - Future _toggleAttribute(bool value, String uuid, CKCallAttributes attribute) { + Future _toggleAttribute( + bool value, String uuid, CKCallAttributes attribute) { if (value) { return _addAttribute(uuid, attribute); } else { @@ -1012,7 +1042,8 @@ Map _getCustomCallParameters(dynamic callParameters) { final list = toArray(callParameters) as List; final entries = list.map((e) { final entry = e as List; - return MapEntry(entry.first.toString(), entry.last.toString()); + return MapEntry( + entry.first.toString(), entry.last.toString()); }); return Map.fromEntries(entries); } @@ -1025,7 +1056,8 @@ Map getCallParams(twilio_js.Call call) { return Map.from(customParams)..addAll(params); } -ActiveCall activeCallFromNativeJsCall(twilio_js.Call call, {DateTime? initiated}) { +ActiveCall activeCallFromNativeJsCall(twilio_js.Call call, + {DateTime? initiated}) { final params = getCallParams(call); final from = params["From"] ?? params["from"] ?? ""; final to = params["To"] ?? params["to"] ?? ""; @@ -1042,7 +1074,9 @@ ActiveCall activeCallFromNativeJsCall(twilio_js.Call call, {DateTime? initiated} // call.customParameters["To"] ?? "", customParams: params, //call.customParameters as Map?, - callDirection: direction == "INCOMING" ? CallDirection.incoming : CallDirection.outgoing, + callDirection: direction == "INCOMING" + ? CallDirection.incoming + : CallDirection.outgoing, initiated: date, ); } diff --git a/lib/models/call_event.dart b/lib/models/call_event.dart index 28859afb..b2a061e0 100644 --- a/lib/models/call_event.dart +++ b/lib/models/call_event.dart @@ -1,5 +1,6 @@ enum CallEvent { incoming, + connecting, ringing, connected, reconnected,