@capgo/capacitor-twilio-voice 7.3.2 → 7.4.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -8,6 +8,10 @@
8
8
 
9
9
  A Capacitor plugin for integrating Twilio Voice calling functionality into iOS and Android applications.
10
10
 
11
+ ## Documentation
12
+
13
+ The most complete doc is available here: https://capgo.app/docs/plugins/twilio-voice/
14
+
11
15
  ## Installation
12
16
 
13
17
  ```bash
@@ -2,6 +2,9 @@ package ee.forgr.capacitor_twilio_voice;
2
2
 
3
3
  import android.Manifest;
4
4
  import android.app.Activity;
5
+ import android.app.AlertDialog;
6
+ import android.app.KeyguardManager;
7
+ import android.app.KeyguardManager;
5
8
  import android.app.NotificationChannel;
6
9
  import android.app.NotificationManager;
7
10
  import android.app.PendingIntent;
@@ -21,13 +24,18 @@ import android.os.Build;
21
24
  import android.os.IBinder;
22
25
  import android.os.VibrationEffect;
23
26
  import android.os.Vibrator;
27
+ import android.provider.Settings;
24
28
  import android.util.Base64;
25
29
  import android.util.Log;
30
+ import android.widget.Toast;
31
+ import androidx.activity.result.ActivityResultLauncher;
32
+ import androidx.activity.result.contract.ActivityResultContracts;
26
33
  import androidx.annotation.NonNull;
27
34
  import androidx.annotation.Nullable;
28
35
  import androidx.core.app.ActivityCompat;
29
36
  import androidx.core.app.NotificationCompat;
30
37
  import androidx.core.app.NotificationManagerCompat;
38
+ import androidx.core.content.ContextCompat;
31
39
  import com.getcapacitor.JSArray;
32
40
  import com.getcapacitor.JSObject;
33
41
  import com.getcapacitor.Plugin;
@@ -67,11 +75,13 @@ import org.json.JSONObject;
67
75
  )
68
76
  public class CapacitorTwilioVoicePlugin extends Plugin {
69
77
 
70
- private final String PLUGIN_VERSION = "7.3.2";
78
+ private final String PLUGIN_VERSION = "7.4.2";
71
79
 
72
80
  private static final String TAG = "CapacitorTwilioVoice";
73
81
  private static final String PREF_ACCESS_TOKEN = "twilio_access_token";
74
82
  private static final String PREF_FCM_TOKEN = "twilio_fcm_token";
83
+ private static final String PREFS_NAME = "capacitor_twilio_voice_prefs";
84
+ private static final String PREF_MIC_PERMISSION_REQUESTED = "mic_permission_requested";
75
85
 
76
86
  public static CapacitorTwilioVoicePlugin instance;
77
87
 
@@ -103,6 +113,21 @@ public class CapacitorTwilioVoicePlugin extends Plugin {
103
113
  private static final int REQUEST_CODE_RECORD_AUDIO_FOR_ACCEPT = 2001;
104
114
  private String pendingCallSidForPermission;
105
115
 
116
+ private enum PendingPermissionAction {
117
+ NONE,
118
+ OUTGOING_CALL,
119
+ ACCEPT_CALL
120
+ }
121
+
122
+ private PendingPermissionAction pendingPermissionAction = PendingPermissionAction.NONE;
123
+ private PluginCall pendingOutgoingCall;
124
+ private String pendingOutgoingTo;
125
+ private PluginCall pendingPermissionCall;
126
+ private long permissionRequestTimestamp = 0L;
127
+ private int permissionAttemptCount = 0;
128
+ private boolean awaitingSettingsResult = false;
129
+ private ActivityResultLauncher<String[]> micPermissionLauncher;
130
+
106
131
  // Voice Call Service
107
132
  private VoiceCallService voiceCallService;
108
133
  private boolean isServiceBound = false;
@@ -150,6 +175,7 @@ public class CapacitorTwilioVoicePlugin extends Plugin {
150
175
  data.put("error", error.getMessage());
151
176
  }
152
177
  notifyListeners("callDisconnected", data);
178
+ moveAppToBackgroundIfLocked();
153
179
  }
154
180
 
155
181
  @Override
@@ -240,6 +266,11 @@ public class CapacitorTwilioVoicePlugin extends Plugin {
240
266
  bindToVoiceCallService();
241
267
 
242
268
  Log.d(TAG, "CapacitorTwilioVoice plugin loaded");
269
+
270
+ micPermissionLauncher = getBridge().registerForActivityResult(
271
+ new ActivityResultContracts.RequestMultiplePermissions(),
272
+ (permissions) -> handleMicPermissionResult(permissions)
273
+ );
243
274
  }
244
275
 
245
276
  private void bindToVoiceCallService() {
@@ -356,6 +387,44 @@ public class CapacitorTwilioVoicePlugin extends Plugin {
356
387
  Log.d(TAG, "CapacitorTwilioVoice plugin destroyed");
357
388
  }
358
389
 
390
+ @Override
391
+ protected void handleOnResume() {
392
+ super.handleOnResume();
393
+ Log.d(
394
+ TAG,
395
+ "handleOnResume: hasPermission=" +
396
+ hasMicrophonePermission() +
397
+ ", awaitingSettings=" +
398
+ awaitingSettingsResult +
399
+ ", pendingCall=" +
400
+ (pendingPermissionCall != null) +
401
+ ", pendingAction=" +
402
+ pendingPermissionAction
403
+ );
404
+ if (hasMicrophonePermission()) {
405
+ if (pendingPermissionAction != PendingPermissionAction.NONE || pendingPermissionCall != null) {
406
+ awaitingSettingsResult = false;
407
+ Log.d(TAG, "handleOnResume: permission granted, resuming pending flow");
408
+ handleMicrophonePermissionGranted();
409
+ }
410
+ } else if (awaitingSettingsResult && pendingPermissionCall != null) {
411
+ awaitingSettingsResult = false;
412
+ Log.d(TAG, "handleOnResume: permission still denied after returning from settings");
413
+ JSObject ret = new JSObject();
414
+ ret.put("granted", false);
415
+ pendingPermissionCall.setKeepAlive(false);
416
+ pendingPermissionCall.resolve(ret);
417
+ pendingPermissionCall = null;
418
+ } else if (awaitingSettingsResult && pendingPermissionAction == PendingPermissionAction.ACCEPT_CALL) {
419
+ awaitingSettingsResult = false;
420
+ Log.d(TAG, "handleOnResume: settings return without permission for accept flow");
421
+ handlePermissionFailure();
422
+ } else if (pendingPermissionCall != null) {
423
+ Log.d(TAG, "handleOnResume: permission denied from dialog, invoking fallback handling");
424
+ handleMicrophonePermissionDenied();
425
+ }
426
+ }
427
+
359
428
  public void setInjectedContext(Context injectedContext) {
360
429
  this.injectedContext = injectedContext;
361
430
  }
@@ -388,33 +457,19 @@ public class CapacitorTwilioVoicePlugin extends Plugin {
388
457
  }
389
458
 
390
459
  private void ensureMicPermissionThenAccept(String callSid) {
391
- Context context = getSafeContext();
392
- boolean granted =
393
- ActivityCompat.checkSelfPermission(context, Manifest.permission.RECORD_AUDIO) == PackageManager.PERMISSION_GRANTED;
394
- if (granted) {
460
+ Log.d(TAG, "ensureMicPermissionThenAccept: callSid=" + callSid);
461
+ if (hasMicrophonePermission()) {
462
+ Log.d(TAG, "ensureMicPermissionThenAccept: permission granted, proceeding");
395
463
  proceedAcceptCall(callSid);
396
464
  return;
397
465
  }
398
466
 
399
- // Remember callSid and request via Activity runtime permission API
400
467
  pendingCallSidForPermission = callSid;
401
- Activity activity = getActivity();
402
- if (activity != null) {
403
- ActivityCompat.requestPermissions(
404
- activity,
405
- new String[] { Manifest.permission.RECORD_AUDIO },
406
- REQUEST_CODE_RECORD_AUDIO_FOR_ACCEPT
407
- );
408
- } else if (mainActivityClass != null) {
409
- // No current activity; bring app to foreground where permission can be requested
410
- Intent launchIntent = new Intent(context, mainActivityClass);
411
- launchIntent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK | Intent.FLAG_ACTIVITY_CLEAR_TOP | Intent.FLAG_ACTIVITY_SINGLE_TOP);
412
- launchIntent.putExtra("AUTO_ACCEPT_CALL", true);
413
- launchIntent.putExtra(EXTRA_CALL_SID, callSid);
414
- context.startActivity(launchIntent);
415
- } else {
416
- Log.w(TAG, "No activity available to request RECORD_AUDIO permission");
417
- }
468
+ pendingPermissionAction = PendingPermissionAction.ACCEPT_CALL;
469
+ permissionAttemptCount = 0;
470
+ awaitingSettingsResult = false;
471
+ Log.d(TAG, "ensureMicPermissionThenAccept: requesting permission before accepting call");
472
+ requestMicrophonePermission();
418
473
  }
419
474
 
420
475
  // Helper to actually start the service once permission is granted
@@ -435,6 +490,12 @@ public class CapacitorTwilioVoicePlugin extends Plugin {
435
490
  Log.d(TAG, "Call acceptance started via service (permission granted)");
436
491
  } catch (Exception e) {
437
492
  Log.e(TAG, "Error accepting call via service", e);
493
+ } finally {
494
+ pendingCallSidForPermission = null;
495
+ if (pendingPermissionAction == PendingPermissionAction.ACCEPT_CALL) {
496
+ pendingPermissionAction = PendingPermissionAction.NONE;
497
+ }
498
+ permissionAttemptCount = 0;
438
499
  }
439
500
  }
440
501
 
@@ -683,11 +744,32 @@ public class CapacitorTwilioVoicePlugin extends Plugin {
683
744
  return;
684
745
  }
685
746
 
747
+ if (pendingOutgoingCall != null) {
748
+ pendingOutgoingCall.setKeepAlive(false);
749
+ pendingOutgoingCall.reject("Another call is awaiting microphone permission.");
750
+ clearOutgoingPermissionState();
751
+ }
752
+
686
753
  String to = call.getString("to");
687
754
  if (to == null) {
688
755
  to = ""; // Empty string for echo test
689
756
  }
690
757
 
758
+ if (hasMicrophonePermission()) {
759
+ startOutgoingCall(call, to);
760
+ return;
761
+ }
762
+
763
+ pendingOutgoingCall = call;
764
+ pendingOutgoingTo = to;
765
+ pendingPermissionAction = PendingPermissionAction.OUTGOING_CALL;
766
+ permissionAttemptCount = 0;
767
+ call.setKeepAlive(true);
768
+ requestMicrophonePermission();
769
+ }
770
+
771
+ private void startOutgoingCall(PluginCall call, String to) {
772
+ Log.d(TAG, "startOutgoingCall: to=" + to);
691
773
  // Start call via the foreground service
692
774
  Intent serviceIntent = new Intent(getSafeContext(), VoiceCallService.class);
693
775
  serviceIntent.setAction(VoiceCallService.ACTION_START_CALL);
@@ -700,11 +782,366 @@ public class CapacitorTwilioVoicePlugin extends Plugin {
700
782
  JSObject ret = new JSObject();
701
783
  ret.put("success", true);
702
784
  ret.put("callSid", "pending"); // Will be updated when service connects
785
+ call.setKeepAlive(false);
703
786
  call.resolve(ret);
704
787
  } catch (Exception e) {
788
+ call.setKeepAlive(false);
705
789
  Log.e(TAG, "Error starting call service", e);
706
790
  call.reject("Failed to start call: " + e.getMessage());
791
+ } finally {
792
+ clearOutgoingPermissionState();
793
+ }
794
+ }
795
+
796
+ private boolean hasMicrophonePermission() {
797
+ return ContextCompat.checkSelfPermission(getSafeContext(), Manifest.permission.RECORD_AUDIO) == PackageManager.PERMISSION_GRANTED;
798
+ }
799
+
800
+ private SharedPreferences getPrefs() {
801
+ return getSafeContext().getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE);
802
+ }
803
+
804
+ private void requestMicrophonePermission() {
805
+ Activity activity = getActivity();
806
+ permissionRequestTimestamp = System.currentTimeMillis();
807
+ permissionAttemptCount++;
808
+
809
+ if (activity != null) {
810
+ activity.runOnUiThread(() -> {
811
+ Log.d(TAG, "requestMicrophonePermission: requesting RECORD_AUDIO (attempt " + permissionAttemptCount + ")");
812
+ if (micPermissionLauncher != null) {
813
+ micPermissionLauncher.launch(new String[] { Manifest.permission.RECORD_AUDIO });
814
+ } else {
815
+ ActivityCompat.requestPermissions(
816
+ activity,
817
+ new String[] { Manifest.permission.RECORD_AUDIO },
818
+ REQUEST_CODE_RECORD_AUDIO_FOR_ACCEPT
819
+ );
820
+ }
821
+ });
822
+ return;
823
+ }
824
+
825
+ if (mainActivityClass != null) {
826
+ Context context = getSafeContext();
827
+ Intent launchIntent = new Intent(context, mainActivityClass);
828
+ launchIntent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK | Intent.FLAG_ACTIVITY_CLEAR_TOP | Intent.FLAG_ACTIVITY_SINGLE_TOP);
829
+ if (pendingPermissionAction == PendingPermissionAction.ACCEPT_CALL && pendingCallSidForPermission != null) {
830
+ launchIntent.putExtra("AUTO_ACCEPT_CALL", true);
831
+ launchIntent.putExtra(EXTRA_CALL_SID, pendingCallSidForPermission);
832
+ }
833
+ Log.d(TAG, "requestMicrophonePermission: launching activity to request permission");
834
+ context.startActivity(launchIntent);
835
+ } else {
836
+ Log.w(TAG, "Unable to request microphone permission - no activity available");
837
+ handlePermissionFailure();
838
+ }
839
+ }
840
+
841
+ private void handleMicPermissionResult(Map<String, Boolean> permissions) {
842
+ Boolean granted = permissions.get(Manifest.permission.RECORD_AUDIO);
843
+ Log.d(TAG, "handleMicPermissionResult: granted=" + granted + ", pendingAction=" + pendingPermissionAction);
844
+ if (granted != null && granted) {
845
+ handleMicrophonePermissionGranted();
846
+ } else {
847
+ handleMicrophonePermissionDenied();
848
+ }
849
+ }
850
+
851
+ private void handleMicrophonePermissionGranted() {
852
+ Log.d(
853
+ TAG,
854
+ "handleMicrophonePermissionGranted: pendingAction=" +
855
+ pendingPermissionAction +
856
+ ", pendingCall=" +
857
+ (pendingPermissionCall != null)
858
+ );
859
+ permissionAttemptCount = 0;
860
+
861
+ if (pendingPermissionAction == PendingPermissionAction.OUTGOING_CALL && pendingOutgoingCall != null) {
862
+ PluginCall call = pendingOutgoingCall;
863
+ String to = pendingOutgoingTo != null ? pendingOutgoingTo : "";
864
+ pendingOutgoingCall = null;
865
+ pendingOutgoingTo = null;
866
+ pendingPermissionAction = PendingPermissionAction.NONE;
867
+ startOutgoingCall(call, to);
868
+ return;
869
+ }
870
+
871
+ if (pendingPermissionAction == PendingPermissionAction.ACCEPT_CALL) {
872
+ if (pendingCallSidForPermission != null) {
873
+ String callSid = pendingCallSidForPermission;
874
+ pendingCallSidForPermission = null;
875
+ pendingPermissionAction = PendingPermissionAction.NONE;
876
+ proceedAcceptCall(callSid);
877
+ return;
878
+ }
879
+
880
+ pendingPermissionAction = PendingPermissionAction.NONE;
881
+ awaitingSettingsResult = false;
882
+ permissionAttemptCount = 0;
883
+ return;
884
+ }
885
+
886
+ if (pendingPermissionCall != null) {
887
+ JSObject ret = new JSObject();
888
+ ret.put("granted", true);
889
+ pendingPermissionCall.setKeepAlive(false);
890
+ pendingPermissionCall.resolve(ret);
891
+ pendingPermissionCall = null;
892
+ awaitingSettingsResult = false;
893
+ }
894
+
895
+ pendingPermissionAction = PendingPermissionAction.NONE;
896
+ }
897
+
898
+ private void handleMicrophonePermissionDenied() {
899
+ Log.d(TAG, "handleMicrophonePermissionDenied invoked");
900
+ Activity activity = getActivity();
901
+ if (activity == null) {
902
+ Log.w(TAG, "handleMicrophonePermissionDenied: no activity");
903
+ handlePermissionFailure();
904
+ return;
905
+ }
906
+
907
+ boolean canRequestAgain = ActivityCompat.shouldShowRequestPermissionRationale(activity, Manifest.permission.RECORD_AUDIO);
908
+ Log.d(
909
+ TAG,
910
+ "handleMicrophonePermissionDenied: canRequestAgain=" +
911
+ canRequestAgain +
912
+ ", attempt=" +
913
+ permissionAttemptCount +
914
+ ", pendingAction=" +
915
+ pendingPermissionAction +
916
+ ", standaloneCall=" +
917
+ (pendingPermissionCall != null)
918
+ );
919
+
920
+ if (pendingPermissionAction == PendingPermissionAction.NONE && pendingPermissionCall != null) {
921
+ if (canRequestAgain && permissionAttemptCount <= 1) {
922
+ showStandalonePermissionRationaleDialog(activity, pendingPermissionCall);
923
+ } else {
924
+ showStandalonePermissionSettingsDialog(activity, pendingPermissionCall);
925
+ }
926
+ } else if (canRequestAgain && permissionAttemptCount <= 1) {
927
+ showPermissionRationaleDialog(activity);
928
+ } else {
929
+ showPermissionSettingsDialog(activity);
930
+ }
931
+ }
932
+
933
+ private void showPermissionRationaleDialog(Activity activity) {
934
+ Log.d(TAG, "showPermissionRationaleDialog");
935
+ new AlertDialog.Builder(activity)
936
+ .setTitle("Microphone required")
937
+ .setMessage("Microphone access is required to place and receive calls.")
938
+ .setPositiveButton("Retry", (dialog, which) -> {
939
+ dialog.dismiss();
940
+ requestMicrophonePermission();
941
+ })
942
+ .setNegativeButton("Cancel", (dialog, which) -> {
943
+ dialog.dismiss();
944
+ handlePermissionFailure();
945
+ })
946
+ .setCancelable(false)
947
+ .show();
948
+ }
949
+
950
+ private void showStandalonePermissionRationaleDialog(Activity activity, PluginCall call) {
951
+ Log.d(TAG, "showStandalonePermissionRationaleDialog");
952
+ new AlertDialog.Builder(activity)
953
+ .setTitle("Microphone required")
954
+ .setMessage("Microphone access is required to place and receive calls.")
955
+ .setPositiveButton("Retry", (dialog, which) -> {
956
+ dialog.dismiss();
957
+ requestMicrophonePermission();
958
+ })
959
+ .setNegativeButton("Cancel", (dialog, which) -> {
960
+ dialog.dismiss();
961
+ JSObject ret = new JSObject();
962
+ ret.put("granted", false);
963
+ call.setKeepAlive(false);
964
+ call.resolve(ret);
965
+ pendingPermissionCall = null;
966
+ awaitingSettingsResult = false;
967
+ permissionAttemptCount = 0;
968
+ })
969
+ .setCancelable(false)
970
+ .show();
971
+ }
972
+
973
+ private void showPermissionSettingsDialog(Activity activity) {
974
+ Log.d(TAG, "showPermissionSettingsDialog");
975
+ new AlertDialog.Builder(activity)
976
+ .setTitle("Enable microphone")
977
+ .setMessage("You can enable the microphone in Settings to use calling features.")
978
+ .setPositiveButton("Open Settings", (dialog, which) -> {
979
+ dialog.dismiss();
980
+ ensureUnlockedThenOpenSettings();
981
+ })
982
+ .setNegativeButton("Cancel", (dialog, which) -> {
983
+ dialog.dismiss();
984
+ handlePermissionFailure();
985
+ })
986
+ .setCancelable(false)
987
+ .show();
988
+ }
989
+
990
+ private void showStandalonePermissionSettingsDialog(Activity activity, PluginCall call) {
991
+ Log.d(TAG, "showStandalonePermissionSettingsDialog");
992
+ new AlertDialog.Builder(activity)
993
+ .setTitle("Enable microphone")
994
+ .setMessage("Microphone access is required. Open Settings to enable the permission.")
995
+ .setPositiveButton("Open Settings", (dialog, which) -> {
996
+ dialog.dismiss();
997
+ pendingPermissionCall = call;
998
+ call.setKeepAlive(true);
999
+ awaitingSettingsResult = true;
1000
+ ensureUnlockedThenOpenSettings();
1001
+ })
1002
+ .setNegativeButton("Cancel", (dialog, which) -> {
1003
+ dialog.dismiss();
1004
+ JSObject ret = new JSObject();
1005
+ ret.put("granted", false);
1006
+ call.setKeepAlive(false);
1007
+ call.resolve(ret);
1008
+ pendingPermissionCall = null;
1009
+ awaitingSettingsResult = false;
1010
+ permissionAttemptCount = 0;
1011
+ })
1012
+ .setCancelable(false)
1013
+ .show();
1014
+ }
1015
+
1016
+ private void openAppSettings() {
1017
+ Context context = getSafeContext();
1018
+ if (pendingPermissionCall != null || pendingPermissionAction != PendingPermissionAction.NONE) {
1019
+ awaitingSettingsResult = true;
1020
+ }
1021
+ Log.d(
1022
+ TAG,
1023
+ "openAppSettings: awaitingSettingsResult=" +
1024
+ awaitingSettingsResult +
1025
+ ", pendingAction=" +
1026
+ pendingPermissionAction +
1027
+ ", pendingCall=" +
1028
+ (pendingPermissionCall != null)
1029
+ );
1030
+ Intent intent = new Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS);
1031
+ intent.setData(Uri.fromParts("package", context.getPackageName(), null));
1032
+ intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
1033
+ context.startActivity(intent);
1034
+ Toast.makeText(context, "Enable microphone permission and return to the app", Toast.LENGTH_LONG).show();
1035
+ }
1036
+
1037
+ private boolean isDeviceLocked() {
1038
+ KeyguardManager km = (KeyguardManager) getSafeContext().getSystemService(Context.KEYGUARD_SERVICE);
1039
+ return km != null && km.isKeyguardLocked();
1040
+ }
1041
+
1042
+ private void ensureUnlockedThenOpenSettings() {
1043
+ Activity activity = getActivity();
1044
+ if (activity == null) {
1045
+ Log.w(TAG, "ensureUnlockedThenOpenSettings: no activity available");
1046
+ openAppSettings();
1047
+ return;
1048
+ }
1049
+
1050
+ KeyguardManager km = (KeyguardManager) getSafeContext().getSystemService(Context.KEYGUARD_SERVICE);
1051
+ if (km == null || !km.isKeyguardLocked()) {
1052
+ openAppSettings();
1053
+ return;
1054
+ }
1055
+
1056
+ km.requestDismissKeyguard(
1057
+ activity,
1058
+ new KeyguardManager.KeyguardDismissCallback() {
1059
+ @Override
1060
+ public void onDismissSucceeded() {
1061
+ Log.d(TAG, "Keyguard dismissed, opening settings");
1062
+ openAppSettings();
1063
+ }
1064
+
1065
+ @Override
1066
+ public void onDismissCancelled() {
1067
+ Log.d(TAG, "Keyguard dismissal cancelled");
1068
+ }
1069
+
1070
+ @Override
1071
+ public void onDismissError() {
1072
+ Log.w(TAG, "Keyguard dismissal error");
1073
+ }
1074
+ }
1075
+ );
1076
+ }
1077
+
1078
+ private void moveAppToBackgroundIfLocked() {
1079
+ Activity activity = getActivity();
1080
+ if (activity != null && isDeviceLocked()) {
1081
+ Log.d(TAG, "moveAppToBackgroundIfLocked: moving task to back");
1082
+ activity.moveTaskToBack(true);
1083
+ }
1084
+ }
1085
+
1086
+ private void handlePermissionFailure() {
1087
+ Log.d(
1088
+ TAG,
1089
+ "handlePermissionFailure: pendingAction=" + pendingPermissionAction + ", pendingCall=" + (pendingPermissionCall != null)
1090
+ );
1091
+ if (pendingPermissionAction == PendingPermissionAction.OUTGOING_CALL) {
1092
+ if (pendingOutgoingCall != null) {
1093
+ pendingOutgoingCall.setKeepAlive(false);
1094
+ pendingOutgoingCall.reject("Microphone permission is required to place a call.");
1095
+ }
1096
+ clearOutgoingPermissionState();
1097
+ } else if (pendingPermissionAction == PendingPermissionAction.ACCEPT_CALL) {
1098
+ if (pendingCallSidForPermission != null) {
1099
+ CallInvite invite = activeCallInvites.get(pendingCallSidForPermission);
1100
+ if (invite != null) {
1101
+ dismissIncomingCallNotification();
1102
+ activeCallInvites.remove(pendingCallSidForPermission);
1103
+ try {
1104
+ invite.reject(getSafeContext());
1105
+ } catch (Exception ex) {
1106
+ Log.w(TAG, "handlePermissionFailure: failed to reject invite", ex);
1107
+ }
1108
+ }
1109
+ JSObject data = new JSObject();
1110
+ data.put("callSid", pendingCallSidForPermission);
1111
+ data.put("reason", "microphone_permission_denied");
1112
+ if (invite != null) {
1113
+ if (invite.getFrom() != null) {
1114
+ data.put("from", invite.getFrom().replace("client:", ""));
1115
+ }
1116
+ if (invite.getTo() != null) {
1117
+ data.put("to", invite.getTo());
1118
+ }
1119
+ }
1120
+ notifyListeners("callDisconnected", data);
1121
+ }
1122
+ pendingCallSidForPermission = null;
1123
+ pendingPermissionAction = PendingPermissionAction.NONE;
1124
+ awaitingSettingsResult = false;
1125
+ pendingPermissionCall = null;
1126
+ moveAppToBackgroundIfLocked();
1127
+ } else if (pendingPermissionCall != null) {
1128
+ JSObject ret = new JSObject();
1129
+ ret.put("granted", false);
1130
+ pendingPermissionCall.setKeepAlive(false);
1131
+ pendingPermissionCall.resolve(ret);
1132
+ pendingPermissionCall = null;
1133
+ awaitingSettingsResult = false;
707
1134
  }
1135
+ permissionAttemptCount = 0;
1136
+ }
1137
+
1138
+ private void clearOutgoingPermissionState() {
1139
+ pendingOutgoingCall = null;
1140
+ pendingOutgoingTo = null;
1141
+ if (pendingPermissionAction == PendingPermissionAction.OUTGOING_CALL) {
1142
+ pendingPermissionAction = PendingPermissionAction.NONE;
1143
+ }
1144
+ permissionAttemptCount = 0;
708
1145
  }
709
1146
 
710
1147
  // Call parameter creation is now handled by VoiceCallService
@@ -754,6 +1191,7 @@ public class CapacitorTwilioVoicePlugin extends Plugin {
754
1191
  JSObject ret = new JSObject();
755
1192
  ret.put("success", true);
756
1193
  call.resolve(ret);
1194
+ moveAppToBackgroundIfLocked();
757
1195
  }
758
1196
 
759
1197
  @PluginMethod
@@ -847,25 +1285,82 @@ public class CapacitorTwilioVoicePlugin extends Plugin {
847
1285
 
848
1286
  @PluginMethod
849
1287
  public void requestMicrophonePermission(PluginCall call) {
850
- requestPermissions(call);
1288
+ Log.d(TAG, "requestMicrophonePermission invoked");
1289
+ if (hasMicrophonePermission()) {
1290
+ Log.d(TAG, "requestMicrophonePermission: already granted");
1291
+ JSObject ret = new JSObject();
1292
+ ret.put("granted", true);
1293
+ call.resolve(ret);
1294
+ return;
1295
+ }
1296
+
1297
+ Activity activity = getActivity();
1298
+ if (activity == null) {
1299
+ Log.w(TAG, "requestMicrophonePermission: no activity available");
1300
+ call.reject("Unable to request permission without an active activity");
1301
+ return;
1302
+ }
1303
+
1304
+ SharedPreferences prefs = getPrefs();
1305
+ boolean requestedBefore = prefs.getBoolean(PREF_MIC_PERMISSION_REQUESTED, false);
1306
+ boolean shouldShow = ActivityCompat.shouldShowRequestPermissionRationale(activity, Manifest.permission.RECORD_AUDIO);
1307
+ Log.d(
1308
+ TAG,
1309
+ "requestMicrophonePermission: requestedBefore=" +
1310
+ requestedBefore +
1311
+ ", shouldShow=" +
1312
+ shouldShow +
1313
+ ", pendingAction=" +
1314
+ pendingPermissionAction
1315
+ );
1316
+
1317
+ if (pendingPermissionAction == PendingPermissionAction.NONE && !shouldShow && requestedBefore) {
1318
+ showStandalonePermissionSettingsDialog(activity, call);
1319
+ return;
1320
+ }
1321
+
1322
+ prefs.edit().putBoolean(PREF_MIC_PERMISSION_REQUESTED, true).apply();
1323
+
1324
+ if (pendingPermissionAction == PendingPermissionAction.NONE) {
1325
+ pendingPermissionCall = call;
1326
+ call.setKeepAlive(true);
1327
+ }
1328
+ awaitingSettingsResult = false;
1329
+ permissionAttemptCount++;
1330
+ permissionRequestTimestamp = System.currentTimeMillis();
1331
+ Log.d(TAG, "requestMicrophonePermission: invoking ActivityCompat.requestPermissions (attempt " + permissionAttemptCount + ")");
1332
+
1333
+ ActivityCompat.requestPermissions(
1334
+ activity,
1335
+ new String[] { Manifest.permission.RECORD_AUDIO },
1336
+ REQUEST_CODE_RECORD_AUDIO_FOR_ACCEPT
1337
+ );
851
1338
  }
852
1339
 
853
1340
  @Override
854
1341
  protected void handleRequestPermissionsResult(int requestCode, String[] permissions, int[] grantResults) {
855
1342
  super.handleRequestPermissionsResult(requestCode, permissions, grantResults);
856
1343
 
857
- // If we were waiting to accept a call after mic permission
858
- if (pendingCallSidForPermission != null) {
859
- boolean granted =
860
- ActivityCompat.checkSelfPermission(getSafeContext(), Manifest.permission.RECORD_AUDIO) == PackageManager.PERMISSION_GRANTED;
861
- String callSid = pendingCallSidForPermission;
862
- pendingCallSidForPermission = null;
863
- if (granted) {
864
- Log.d(TAG, "RECORD_AUDIO granted from permission flow; proceeding to accept call");
865
- proceedAcceptCall(callSid);
866
- } else {
867
- Log.w(TAG, "RECORD_AUDIO denied; cannot accept call");
868
- }
1344
+ if (requestCode != REQUEST_CODE_RECORD_AUDIO_FOR_ACCEPT) {
1345
+ return;
1346
+ }
1347
+
1348
+ boolean granted = hasMicrophonePermission();
1349
+ Log.d(
1350
+ TAG,
1351
+ "handleRequestPermissionsResult: granted=" +
1352
+ granted +
1353
+ ", attempt=" +
1354
+ permissionAttemptCount +
1355
+ ", pendingAction=" +
1356
+ pendingPermissionAction +
1357
+ ", standaloneCall=" +
1358
+ (pendingPermissionCall != null)
1359
+ );
1360
+ if (granted) {
1361
+ handleMicrophonePermissionGranted();
1362
+ } else {
1363
+ handleMicrophonePermissionDenied();
869
1364
  }
870
1365
  }
871
1366
 
@@ -1085,13 +1580,8 @@ public class CapacitorTwilioVoicePlugin extends Plugin {
1085
1580
  }
1086
1581
  };*/
1087
1582
 
1088
- private void showIncomingCallNotification(CallInvite callInvite, String callSid) {
1583
+ private void showIncomingCallNotification(CallInvite callInvite, String callSid, String callerName) {
1089
1584
  try {
1090
- String callerName = callInvite.getFrom();
1091
- if (callerName != null && callerName.startsWith("client:")) {
1092
- callerName = callerName.substring(7); // Remove "client:" prefix
1093
- }
1094
-
1095
1585
  // Create intent for accepting the call
1096
1586
  PendingIntent acceptPendingIntent;
1097
1587
  if (this.bridge == null) {
@@ -1208,16 +1698,22 @@ public class CapacitorTwilioVoicePlugin extends Plugin {
1208
1698
  String callSid = UUID.randomUUID().toString(); // Generate a unique ID
1209
1699
  activeCallInvites.put(callSid, callInvite);
1210
1700
 
1701
+ Map<String, String> params = callInvite.getCustomParameters();
1702
+ String callerName = params.containsKey("CapacitorTwilioCallerName")
1703
+ ? params.get("CapacitorTwilioCallerName")
1704
+ : callInvite.getFrom();
1705
+
1211
1706
  // Create and show notification
1212
- showIncomingCallNotification(callInvite, callSid);
1707
+ showIncomingCallNotification(callInvite, callSid, callerName);
1213
1708
 
1214
1709
  // Start ringtone and vibration
1215
1710
  startRingtone();
1216
1711
 
1217
1712
  JSObject data = new JSObject();
1218
1713
  data.put("callSid", callSid);
1219
- data.put("from", callInvite.getFrom());
1714
+ data.put("from", callerName);
1220
1715
  data.put("to", callInvite.getTo());
1716
+ data.put("customParams", new JSONObject(params));
1221
1717
  notifyListeners("callInviteReceived", data);
1222
1718
  }
1223
1719
 
@@ -1278,6 +1774,7 @@ public class CapacitorTwilioVoicePlugin extends Plugin {
1278
1774
  notifyListeners("callDisconnected", data);
1279
1775
 
1280
1776
  Log.d(TAG, "Call rejected from notification");
1777
+ moveAppToBackgroundIfLocked();
1281
1778
  } else {
1282
1779
  Log.e(TAG, "Call invite not found for SID: " + callSid);
1283
1780
  }
package/dist/docs.json CHANGED
@@ -169,7 +169,7 @@
169
169
  },
170
170
  {
171
171
  "name": "addListener",
172
- "signature": "(eventName: 'callInviteReceived', listenerFunc: (data: { callSid: string; from: string; to: string; }) => void) => Promise<PluginListenerHandle>",
172
+ "signature": "(eventName: 'callInviteReceived', listenerFunc: (data: { callSid: string; from: string; to: string; customParams: Record<string, string>; }) => void) => Promise<PluginListenerHandle>",
173
173
  "parameters": [
174
174
  {
175
175
  "name": "eventName",
@@ -179,14 +179,15 @@
179
179
  {
180
180
  "name": "listenerFunc",
181
181
  "docs": "",
182
- "type": "(data: { callSid: string; from: string; to: string; }) => void"
182
+ "type": "(data: { callSid: string; from: string; to: string; customParams: Record<string, string>; }) => void"
183
183
  }
184
184
  ],
185
185
  "returns": "Promise<PluginListenerHandle>",
186
186
  "tags": [],
187
187
  "docs": "",
188
188
  "complexTypes": [
189
- "PluginListenerHandle"
189
+ "PluginListenerHandle",
190
+ "Record"
190
191
  ],
191
192
  "slug": "addlistenercallinvitereceived-"
192
193
  },
@@ -213,6 +214,75 @@
213
214
  ],
214
215
  "slug": "addlistenercallconnected-"
215
216
  },
217
+ {
218
+ "name": "addListener",
219
+ "signature": "(eventName: 'callInviteCancelled', listenerFunc: (data: { callSid: string; reason: 'user_declined' | 'remote_cancelled'; }) => void) => Promise<PluginListenerHandle>",
220
+ "parameters": [
221
+ {
222
+ "name": "eventName",
223
+ "docs": "",
224
+ "type": "'callInviteCancelled'"
225
+ },
226
+ {
227
+ "name": "listenerFunc",
228
+ "docs": "",
229
+ "type": "(data: { callSid: string; reason: 'user_declined' | 'remote_cancelled'; }) => void"
230
+ }
231
+ ],
232
+ "returns": "Promise<PluginListenerHandle>",
233
+ "tags": [],
234
+ "docs": "",
235
+ "complexTypes": [
236
+ "PluginListenerHandle"
237
+ ],
238
+ "slug": "addlistenercallinvitecancelled-"
239
+ },
240
+ {
241
+ "name": "addListener",
242
+ "signature": "(eventName: 'outgoingCallInitiated', listenerFunc: (data: { callSid: string; to: string; source: 'app' | 'system'; displayName?: string; }) => void) => Promise<PluginListenerHandle>",
243
+ "parameters": [
244
+ {
245
+ "name": "eventName",
246
+ "docs": "",
247
+ "type": "'outgoingCallInitiated'"
248
+ },
249
+ {
250
+ "name": "listenerFunc",
251
+ "docs": "",
252
+ "type": "(data: { callSid: string; to: string; source: 'app' | 'system'; displayName?: string | undefined; }) => void"
253
+ }
254
+ ],
255
+ "returns": "Promise<PluginListenerHandle>",
256
+ "tags": [],
257
+ "docs": "",
258
+ "complexTypes": [
259
+ "PluginListenerHandle"
260
+ ],
261
+ "slug": "addlisteneroutgoingcallinitiated-"
262
+ },
263
+ {
264
+ "name": "addListener",
265
+ "signature": "(eventName: 'outgoingCallFailed', listenerFunc: (data: { callSid: string; to: string; reason: 'missing_access_token' | 'connection_failed' | 'no_call_details' | 'microphone_permission_denied' | 'invalid_contact' | 'callkit_request_failed' | 'unsupported_intent'; displayName?: string; }) => void) => Promise<PluginListenerHandle>",
266
+ "parameters": [
267
+ {
268
+ "name": "eventName",
269
+ "docs": "",
270
+ "type": "'outgoingCallFailed'"
271
+ },
272
+ {
273
+ "name": "listenerFunc",
274
+ "docs": "",
275
+ "type": "(data: { callSid: string; to: string; reason: 'missing_access_token' | 'connection_failed' | 'no_call_details' | 'microphone_permission_denied' | 'invalid_contact' | 'callkit_request_failed' | 'unsupported_intent'; displayName?: string | undefined; }) => void"
276
+ }
277
+ ],
278
+ "returns": "Promise<PluginListenerHandle>",
279
+ "tags": [],
280
+ "docs": "",
281
+ "complexTypes": [
282
+ "PluginListenerHandle"
283
+ ],
284
+ "slug": "addlisteneroutgoingcallfailed-"
285
+ },
216
286
  {
217
287
  "name": "addListener",
218
288
  "signature": "(eventName: 'callDisconnected', listenerFunc: (data: { callSid: string; error?: string; }) => void) => Promise<PluginListenerHandle>",
@@ -425,6 +495,21 @@
425
495
  }
426
496
  ],
427
497
  "enums": [],
428
- "typeAliases": [],
498
+ "typeAliases": [
499
+ {
500
+ "name": "Record",
501
+ "slug": "record",
502
+ "docs": "Construct a type with a set of properties K of type T",
503
+ "types": [
504
+ {
505
+ "text": "{\r\n [P in K]: T;\r\n}",
506
+ "complexTypes": [
507
+ "K",
508
+ "T"
509
+ ]
510
+ }
511
+ ]
512
+ }
513
+ ],
429
514
  "pluginConfigs": []
430
515
  }
@@ -61,10 +61,27 @@ export interface CapacitorTwilioVoicePlugin {
61
61
  callSid: string;
62
62
  from: string;
63
63
  to: string;
64
+ customParams: Record<string, string>;
64
65
  }) => void): Promise<PluginListenerHandle>;
65
66
  addListener(eventName: 'callConnected', listenerFunc: (data: {
66
67
  callSid: string;
67
68
  }) => void): Promise<PluginListenerHandle>;
69
+ addListener(eventName: 'callInviteCancelled', listenerFunc: (data: {
70
+ callSid: string;
71
+ reason: 'user_declined' | 'remote_cancelled';
72
+ }) => void): Promise<PluginListenerHandle>;
73
+ addListener(eventName: 'outgoingCallInitiated', listenerFunc: (data: {
74
+ callSid: string;
75
+ to: string;
76
+ source: 'app' | 'system';
77
+ displayName?: string;
78
+ }) => void): Promise<PluginListenerHandle>;
79
+ addListener(eventName: 'outgoingCallFailed', listenerFunc: (data: {
80
+ callSid: string;
81
+ to: string;
82
+ reason: 'missing_access_token' | 'connection_failed' | 'no_call_details' | 'microphone_permission_denied' | 'invalid_contact' | 'callkit_request_failed' | 'unsupported_intent';
83
+ displayName?: string;
84
+ }) => void): Promise<PluginListenerHandle>;
68
85
  addListener(eventName: 'callDisconnected', listenerFunc: (data: {
69
86
  callSid: string;
70
87
  error?: string;
@@ -1 +1 @@
1
- {"version":3,"file":"definitions.js","sourceRoot":"","sources":["../../src/definitions.ts"],"names":[],"mappings":"","sourcesContent":["export interface CapacitorTwilioVoicePlugin {\n // Authentication\n login(options: { accessToken: string }): Promise<{ success: boolean }>;\n logout(): Promise<{ success: boolean }>;\n isLoggedIn(): Promise<{ isLoggedIn: boolean; hasValidToken: boolean; identity?: string }>;\n\n // Call Management\n makeCall(options: { to: string }): Promise<{ success: boolean; callSid?: string }>;\n acceptCall(options: { callSid: string }): Promise<{ success: boolean }>;\n rejectCall(options: { callSid: string }): Promise<{ success: boolean }>;\n endCall(options: { callSid?: string }): Promise<{ success: boolean }>;\n\n // Call Controls\n muteCall(options: { muted: boolean; callSid?: string }): Promise<{ success: boolean }>;\n setSpeaker(options: { enabled: boolean }): Promise<{ success: boolean }>;\n\n // Call Status\n getCallStatus(): Promise<{\n hasActiveCall: boolean;\n isOnHold: boolean;\n isMuted: boolean;\n callSid?: string;\n callState?: string;\n }>;\n\n // Audio Permissions\n checkMicrophonePermission(): Promise<{ granted: boolean }>;\n requestMicrophonePermission(): Promise<{ granted: boolean }>;\n\n // Listeners for events\n addListener(\n eventName: 'callInviteReceived',\n listenerFunc: (data: { callSid: string; from: string; to: string }) => void,\n ): Promise<PluginListenerHandle>;\n\n addListener(\n eventName: 'callConnected',\n listenerFunc: (data: { callSid: string }) => void,\n ): Promise<PluginListenerHandle>;\n\n addListener(\n eventName: 'callDisconnected',\n listenerFunc: (data: { callSid: string; error?: string }) => void,\n ): Promise<PluginListenerHandle>;\n\n addListener(\n eventName: 'callRinging',\n listenerFunc: (data: { callSid: string }) => void,\n ): Promise<PluginListenerHandle>;\n\n addListener(\n eventName: 'callReconnecting',\n listenerFunc: (data: { callSid: string; error?: string }) => void,\n ): Promise<PluginListenerHandle>;\n\n addListener(\n eventName: 'callReconnected',\n listenerFunc: (data: { callSid: string }) => void,\n ): Promise<PluginListenerHandle>;\n\n addListener(\n eventName: 'callQualityWarningsChanged',\n listenerFunc: (data: { callSid: string; currentWarnings: string[]; previousWarnings: string[] }) => void,\n ): Promise<PluginListenerHandle>;\n\n addListener(eventName: 'registrationSuccess', listenerFunc: () => void): Promise<PluginListenerHandle>;\n\n addListener(\n eventName: 'registrationFailure',\n listenerFunc: (data: { error: string }) => void,\n ): Promise<PluginListenerHandle>;\n\n removeAllListeners(): Promise<void>;\n\n /**\n * Get the native Capacitor plugin version\n *\n * @returns {Promise<{ version: string }>} a Promise with version for this plugin\n * @throws An error if something went wrong\n */\n getPluginVersion(): Promise<{ version: string }>;\n}\n\nexport interface PluginListenerHandle {\n remove(): Promise<void>;\n}\n"]}
1
+ {"version":3,"file":"definitions.js","sourceRoot":"","sources":["../../src/definitions.ts"],"names":[],"mappings":"","sourcesContent":["export interface CapacitorTwilioVoicePlugin {\n // Authentication\n login(options: { accessToken: string }): Promise<{ success: boolean }>;\n logout(): Promise<{ success: boolean }>;\n isLoggedIn(): Promise<{ isLoggedIn: boolean; hasValidToken: boolean; identity?: string }>;\n\n // Call Management\n makeCall(options: { to: string }): Promise<{ success: boolean; callSid?: string }>;\n acceptCall(options: { callSid: string }): Promise<{ success: boolean }>;\n rejectCall(options: { callSid: string }): Promise<{ success: boolean }>;\n endCall(options: { callSid?: string }): Promise<{ success: boolean }>;\n\n // Call Controls\n muteCall(options: { muted: boolean; callSid?: string }): Promise<{ success: boolean }>;\n setSpeaker(options: { enabled: boolean }): Promise<{ success: boolean }>;\n\n // Call Status\n getCallStatus(): Promise<{\n hasActiveCall: boolean;\n isOnHold: boolean;\n isMuted: boolean;\n callSid?: string;\n callState?: string;\n }>;\n\n // Audio Permissions\n checkMicrophonePermission(): Promise<{ granted: boolean }>;\n requestMicrophonePermission(): Promise<{ granted: boolean }>;\n\n // Listeners for events\n addListener(\n eventName: 'callInviteReceived',\n listenerFunc: (data: { callSid: string; from: string; to: string; customParams: Record<string, string> }) => void,\n ): Promise<PluginListenerHandle>;\n\n addListener(\n eventName: 'callConnected',\n listenerFunc: (data: { callSid: string }) => void,\n ): Promise<PluginListenerHandle>;\n\n addListener(\n eventName: 'callInviteCancelled',\n listenerFunc: (data: { callSid: string; reason: 'user_declined' | 'remote_cancelled' }) => void,\n ): Promise<PluginListenerHandle>;\n\n addListener(\n eventName: 'outgoingCallInitiated',\n listenerFunc: (data: { callSid: string; to: string; source: 'app' | 'system'; displayName?: string }) => void,\n ): Promise<PluginListenerHandle>;\n\n addListener(\n eventName: 'outgoingCallFailed',\n listenerFunc: (data: {\n callSid: string;\n to: string;\n reason:\n | 'missing_access_token'\n | 'connection_failed'\n | 'no_call_details'\n | 'microphone_permission_denied'\n | 'invalid_contact'\n | 'callkit_request_failed'\n | 'unsupported_intent';\n displayName?: string;\n }) => void,\n ): Promise<PluginListenerHandle>;\n\n addListener(\n eventName: 'callDisconnected',\n listenerFunc: (data: { callSid: string; error?: string }) => void,\n ): Promise<PluginListenerHandle>;\n\n addListener(\n eventName: 'callRinging',\n listenerFunc: (data: { callSid: string }) => void,\n ): Promise<PluginListenerHandle>;\n\n addListener(\n eventName: 'callReconnecting',\n listenerFunc: (data: { callSid: string; error?: string }) => void,\n ): Promise<PluginListenerHandle>;\n\n addListener(\n eventName: 'callReconnected',\n listenerFunc: (data: { callSid: string }) => void,\n ): Promise<PluginListenerHandle>;\n\n addListener(\n eventName: 'callQualityWarningsChanged',\n listenerFunc: (data: { callSid: string; currentWarnings: string[]; previousWarnings: string[] }) => void,\n ): Promise<PluginListenerHandle>;\n\n addListener(eventName: 'registrationSuccess', listenerFunc: () => void): Promise<PluginListenerHandle>;\n\n addListener(\n eventName: 'registrationFailure',\n listenerFunc: (data: { error: string }) => void,\n ): Promise<PluginListenerHandle>;\n\n removeAllListeners(): Promise<void>;\n\n /**\n * Get the native Capacitor plugin version\n *\n * @returns {Promise<{ version: string }>} a Promise with version for this plugin\n * @throws An error if something went wrong\n */\n getPluginVersion(): Promise<{ version: string }>;\n}\n\nexport interface PluginListenerHandle {\n remove(): Promise<void>;\n}\n"]}
@@ -4,6 +4,7 @@ import PushKit
4
4
  import CallKit
5
5
  import TwilioVoice
6
6
  import AVFoundation
7
+ import Intents
7
8
 
8
9
  let kRegistrationTTLInDays = 365
9
10
  let kCachedDeviceToken = "CachedDeviceToken"
@@ -26,7 +27,7 @@ public protocol PushKitEventDelegate: AnyObject {
26
27
  */
27
28
  @objc(CapacitorTwilioVoicePlugin)
28
29
  public class CapacitorTwilioVoicePlugin: CAPPlugin, CAPBridgedPlugin, PushKitEventDelegate {
29
- private let PLUGIN_VERSION: String = "7.3.2"
30
+ private let PLUGIN_VERSION: String = "7.4.2"
30
31
 
31
32
  public let identifier = "CapacitorTwilioVoicePlugin"
32
33
  public let jsName = "CapacitorTwilioVoice"
@@ -60,6 +61,13 @@ public class CapacitorTwilioVoicePlugin: CAPPlugin, CAPBridgedPlugin, PushKitEve
60
61
  private var callKitCompletionCallback: ((Bool) -> Void)?
61
62
  private var playCustomRingback = false
62
63
  private var ringtonePlayer: AVAudioPlayer?
64
+ private struct PendingOutgoingCall {
65
+ let to: String
66
+ let completion: (Bool) -> Void
67
+ let isSystemInitiated: Bool
68
+ let displayName: String?
69
+ }
70
+ private var pendingOutgoingCalls: [UUID: PendingOutgoingCall] = [:]
63
71
 
64
72
  deinit {
65
73
  // Remove observers
@@ -93,6 +101,8 @@ public class CapacitorTwilioVoicePlugin: CAPPlugin, CAPBridgedPlugin, PushKitEve
93
101
  let configuration = CXProviderConfiguration(localizedName: "Voice Call")
94
102
  configuration.maximumCallGroups = 2
95
103
  configuration.maximumCallsPerCallGroup = 1
104
+ configuration.supportedHandleTypes = [.generic, .phoneNumber]
105
+ configuration.includesCallsInRecents = true
96
106
  callKitProvider = CXProvider(configuration: configuration)
97
107
  if let provider = callKitProvider {
98
108
  provider.setDelegate(self, queue: nil)
@@ -374,13 +384,74 @@ public class CapacitorTwilioVoicePlugin: CAPPlugin, CAPBridgedPlugin, PushKitEve
374
384
  }
375
385
 
376
386
  let uuid = UUID()
377
- self?.performStartCallAction(uuid: uuid, handle: to, to: to) { success in
378
- if success {
379
- call.resolve(["success": true, "callSid": uuid.uuidString])
380
- } else {
381
- call.reject("Failed to start call")
382
- }
387
+ self?.performStartCallAction(uuid: uuid,
388
+ handle: to,
389
+ to: to,
390
+ isSystemInitiated: false,
391
+ completion: { success in
392
+ if success {
393
+ call.resolve(["success": true, "callSid": uuid.uuidString])
394
+ } else {
395
+ call.reject("Failed to start call")
396
+ }
397
+ })
398
+ }
399
+ }
400
+
401
+ @available(iOS 10.0, *)
402
+ public func handleStartCallIntent(intent: INIntent) {
403
+ if #available(iOS 13.0, *), let callIntent = intent as? INStartCallIntent {
404
+ processStartCallIntent(contact: callIntent.contacts?.first,
405
+ intentType: "INStartCallIntent")
406
+ return
407
+ }
408
+
409
+ if let audioIntent = intent as? INStartAudioCallIntent {
410
+ processStartCallIntent(contact: audioIntent.contacts?.first,
411
+ intentType: "INStartAudioCallIntent")
412
+ return
413
+ }
414
+
415
+ NSLog("Unsupported call intent type: \(type(of: intent))")
416
+ emitOutgoingCallFailed(to: "", displayName: nil, reason: "unsupported_intent")
417
+ }
418
+
419
+ @available(iOS 10.0, *)
420
+ private func processStartCallIntent(contact: INPerson?, intentType: String) {
421
+ guard let contact = contact else {
422
+ NSLog("\(intentType) received without contact information")
423
+ emitOutgoingCallFailed(to: "", displayName: nil, reason: "invalid_contact")
424
+ return
425
+ }
426
+
427
+ guard let handleValue = contact.personHandle?.value, !handleValue.isEmpty else {
428
+ let displayName = resolveDisplayName(from: contact)
429
+ NSLog("\(intentType) contact missing handle value")
430
+ emitOutgoingCallFailed(to: displayName ?? "", displayName: displayName, reason: "invalid_contact")
431
+ return
432
+ }
433
+
434
+ let displayName = resolveDisplayName(from: contact)
435
+ NSLog("Processing \(intentType) for handle: \(handleValue)")
436
+
437
+ checkRecordPermission { [weak self] permissionGranted in
438
+ guard let self = self else { return }
439
+
440
+ guard permissionGranted else {
441
+ NSLog("Microphone permission denied while processing \(intentType)")
442
+ self.emitOutgoingCallFailed(to: handleValue,
443
+ displayName: displayName,
444
+ reason: "microphone_permission_denied")
445
+ return
383
446
  }
447
+
448
+ NSLog("Initiating CallKit start call action for intent target: \(handleValue)")
449
+ self.performStartCallAction(uuid: UUID(),
450
+ handle: handleValue,
451
+ to: handleValue,
452
+ isSystemInitiated: true,
453
+ displayName: displayName,
454
+ completion: { _ in })
384
455
  }
385
456
  }
386
457
 
@@ -572,6 +643,44 @@ public class CapacitorTwilioVoicePlugin: CAPPlugin, CAPBridgedPlugin, PushKitEve
572
643
  }
573
644
  }
574
645
 
646
+ private func emitOutgoingCallFailed(uuid: UUID = UUID(),
647
+ to: String,
648
+ displayName: String?,
649
+ reason: String) {
650
+ var data: [String: Any] = [
651
+ "callSid": uuid.uuidString,
652
+ "to": to,
653
+ "reason": reason
654
+ ]
655
+
656
+ if let displayName = displayName {
657
+ data["displayName"] = displayName
658
+ }
659
+
660
+ DispatchQueue.main.async { [weak self] in
661
+ self?.notifyListeners("outgoingCallFailed", data: data)
662
+ }
663
+ }
664
+
665
+ @available(iOS 10.0, *)
666
+ private func resolveDisplayName(from contact: INPerson) -> String? {
667
+ if !contact.displayName.isEmpty {
668
+ return contact.displayName
669
+ }
670
+
671
+ let displayName = contact.displayName
672
+
673
+ if let nameComponents = contact.nameComponents {
674
+ let formatter = PersonNameComponentsFormatter()
675
+ let formattedName = formatter.string(from: nameComponents)
676
+ if !formattedName.isEmpty {
677
+ return formattedName
678
+ }
679
+ }
680
+
681
+ return contact.personHandle?.value
682
+ }
683
+
575
684
  private func toggleAudioRoute(toSpeaker: Bool) {
576
685
  audioDevice.block = {
577
686
  do {
@@ -700,12 +809,22 @@ public class CapacitorTwilioVoicePlugin: CAPPlugin, CAPBridgedPlugin, PushKitEve
700
809
 
701
810
  // MARK: - CallKit Actions
702
811
 
703
- private func performStartCallAction(uuid: UUID, handle: String, to: String, completion: @escaping (Bool) -> Void) {
812
+ private func performStartCallAction(uuid: UUID,
813
+ handle: String,
814
+ to: String,
815
+ isSystemInitiated: Bool,
816
+ displayName: String? = nil,
817
+ completion: @escaping (Bool) -> Void) {
704
818
  guard let provider = callKitProvider else {
705
819
  completion(false)
706
820
  return
707
821
  }
708
822
 
823
+ pendingOutgoingCalls[uuid] = PendingOutgoingCall(to: to,
824
+ completion: completion,
825
+ isSystemInitiated: isSystemInitiated,
826
+ displayName: displayName)
827
+
709
828
  let callHandle = CXHandle(type: .generic, value: handle)
710
829
  let startCallAction = CXStartCallAction(call: uuid, handle: callHandle)
711
830
  let transaction = CXTransaction(action: startCallAction)
@@ -713,7 +832,13 @@ public class CapacitorTwilioVoicePlugin: CAPPlugin, CAPBridgedPlugin, PushKitEve
713
832
  callKitCallController.request(transaction) { [weak self] error in
714
833
  if let error = error {
715
834
  NSLog("StartCallAction transaction request failed: \(error.localizedDescription)")
835
+ self?.pendingOutgoingCalls.removeValue(forKey: uuid)
716
836
  completion(false)
837
+
838
+ self?.emitOutgoingCallFailed(uuid: uuid,
839
+ to: to,
840
+ displayName: displayName,
841
+ reason: "callkit_request_failed")
717
842
  return
718
843
  }
719
844
 
@@ -726,12 +851,10 @@ public class CapacitorTwilioVoicePlugin: CAPPlugin, CAPBridgedPlugin, PushKitEve
726
851
  callUpdate.hasVideo = false
727
852
 
728
853
  provider.reportCall(with: uuid, updated: callUpdate)
729
-
730
- self?.performVoiceCall(uuid: uuid, to: to, completionHandler: completion)
731
854
  }
732
855
  }
733
856
 
734
- private func reportIncomingCall(from: String, uuid: UUID) {
857
+ private func reportIncomingCall(from: String, niceName: String, uuid: UUID) {
735
858
  guard let provider = callKitProvider else { return }
736
859
 
737
860
  let callHandle = CXHandle(type: .generic, value: from)
@@ -743,6 +866,7 @@ public class CapacitorTwilioVoicePlugin: CAPPlugin, CAPBridgedPlugin, PushKitEve
743
866
  callUpdate.supportsGrouping = false
744
867
  callUpdate.supportsUngrouping = false
745
868
  callUpdate.hasVideo = false
869
+ callUpdate.localizedCallerName = niceName
746
870
 
747
871
  provider.reportNewIncomingCall(with: uuid, update: callUpdate) { error in
748
872
  if let error = error {
@@ -838,13 +962,15 @@ extension CapacitorTwilioVoicePlugin: NotificationDelegate {
838
962
  UserDefaults.standard.set(Date(), forKey: kCachedBindingDate)
839
963
 
840
964
  let from = (callInvite.from ?? "Unknown").replacingOccurrences(of: "client:", with: "")
841
- reportIncomingCall(from: from, uuid: callInvite.uuid)
965
+ let niceName = callInvite.customParameters?["CapacitorTwilioCallerName"] ?? from
966
+ reportIncomingCall(from: from, niceName: niceName, uuid: callInvite.uuid)
842
967
  activeCallInvites[callInvite.uuid.uuidString] = callInvite
843
968
 
844
969
  notifyListeners("callInviteReceived", data: [
845
970
  "callSid": callInvite.uuid.uuidString,
846
971
  "from": from,
847
- "to": callInvite.to ?? ""
972
+ "to": callInvite.to,
973
+ "customParams": callInvite.customParameters ?? [:]
848
974
  ])
849
975
  }
850
976
 
@@ -858,6 +984,11 @@ extension CapacitorTwilioVoicePlugin: NotificationDelegate {
858
984
  if let callInvite = callInvite {
859
985
  performEndCallAction(uuid: callInvite.uuid)
860
986
  activeCallInvites.removeValue(forKey: callInvite.uuid.uuidString)
987
+
988
+ notifyListeners("callInviteCancelled", data: [
989
+ "callSid": callInvite.uuid.uuidString,
990
+ "reason": "remote_cancelled"
991
+ ])
861
992
  }
862
993
  }
863
994
  }
@@ -986,7 +1117,69 @@ extension CapacitorTwilioVoicePlugin: CXProviderDelegate {
986
1117
  }
987
1118
 
988
1119
  public func provider(_ provider: CXProvider, perform action: CXStartCallAction) {
989
- provider.reportOutgoingCall(with: action.callUUID, startedConnectingAt: Date())
1120
+ let uuid = action.callUUID
1121
+ let handleValue = action.handle.value
1122
+
1123
+ var pendingCall = pendingOutgoingCalls[uuid]
1124
+ if pendingCall == nil {
1125
+ let fallback = PendingOutgoingCall(to: handleValue,
1126
+ completion: { _ in },
1127
+ isSystemInitiated: true,
1128
+ displayName: nil)
1129
+ pendingOutgoingCalls[uuid] = fallback
1130
+ pendingCall = fallback
1131
+ }
1132
+
1133
+ guard let callDetails = pendingCall else {
1134
+ provider.reportCall(with: uuid, endedAt: Date(), reason: .failed)
1135
+ emitOutgoingCallFailed(uuid: uuid,
1136
+ to: handleValue,
1137
+ displayName: nil,
1138
+ reason: "no_call_details")
1139
+ action.fail()
1140
+ return
1141
+ }
1142
+
1143
+ let to = callDetails.to
1144
+ let source = callDetails.isSystemInitiated ? "system" : "app"
1145
+
1146
+ var initiatedData: [String: Any] = [
1147
+ "callSid": uuid.uuidString,
1148
+ "to": to,
1149
+ "source": source
1150
+ ]
1151
+
1152
+ if let displayName = callDetails.displayName {
1153
+ initiatedData["displayName"] = displayName
1154
+ }
1155
+
1156
+ notifyListeners("outgoingCallInitiated", data: initiatedData)
1157
+
1158
+ guard let accessToken = accessToken, isTokenValid(accessToken) else {
1159
+ pendingOutgoingCalls.removeValue(forKey: uuid)
1160
+ provider.reportCall(with: uuid, endedAt: Date(), reason: .failed)
1161
+ callDetails.completion(false)
1162
+ emitOutgoingCallFailed(uuid: uuid,
1163
+ to: to,
1164
+ displayName: callDetails.displayName,
1165
+ reason: "missing_access_token")
1166
+ action.fail()
1167
+ return
1168
+ }
1169
+
1170
+ provider.reportOutgoingCall(with: uuid, startedConnectingAt: Date())
1171
+
1172
+ performVoiceCall(uuid: uuid, to: to) { [weak self] success in
1173
+ callDetails.completion(success)
1174
+ if !success {
1175
+ self?.emitOutgoingCallFailed(uuid: uuid,
1176
+ to: to,
1177
+ displayName: callDetails.displayName,
1178
+ reason: "connection_failed")
1179
+ }
1180
+ }
1181
+
1182
+ pendingOutgoingCalls.removeValue(forKey: uuid)
990
1183
  action.fulfill()
991
1184
  }
992
1185
 
@@ -1001,6 +1194,11 @@ extension CapacitorTwilioVoicePlugin: CXProviderDelegate {
1001
1194
  if let invite = activeCallInvites[action.callUUID.uuidString] {
1002
1195
  invite.reject()
1003
1196
  activeCallInvites.removeValue(forKey: action.callUUID.uuidString)
1197
+
1198
+ notifyListeners("callInviteCancelled", data: [
1199
+ "callSid": action.callUUID.uuidString,
1200
+ "reason": "user_declined"
1201
+ ])
1004
1202
  } else if let call = activeCalls[action.callUUID.uuidString] {
1005
1203
  call.disconnect()
1006
1204
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@capgo/capacitor-twilio-voice",
3
- "version": "7.3.2",
3
+ "version": "7.4.2",
4
4
  "description": "Integrates the Twilio Voice SDK into Capacitor",
5
5
  "main": "dist/plugin.cjs.js",
6
6
  "module": "dist/esm/index.js",