rns-nativecall 1.2.1 → 1.2.3

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
@@ -133,30 +133,28 @@ export default function App() {
133
133
  | Method | Platform | Description |
134
134
  | :--- | :--- | :--- |
135
135
  | **registerHeadlessTask(callback)** | All | Registers the background task. `eventType` is 'INCOMING_CALL', 'BUSY', or 'ABORTED_CALL'. |
136
- | **displayCall(uuid, name, type)** | All | Android: Launches full-screen Activity. iOS: Reports incoming call to CallKit. |
137
- | **checkCallValidity(uuid)** | All | Android only: Returns {isValid, isCanceled} to prevent ghost/canceled calls. |
138
- | **checkCallStatus(uuid)** | All | Android only: Returns {isCanceled, isActive, shouldDisplay} for UI syncing. |
136
+ | **displayCall(uuid, name, type)** | All | Launches full-screen Activity. iOS: Reports incoming call to CallKit. |
137
+ | **checkCallValidity(uuid)** | All | Returns {isValid, isCanceled} to prevent ghost/canceled calls. |
138
+ | **checkCallStatus(uuid)** | All | Returns {isCanceled, isActive, shouldDisplay} for UI syncing. |
139
+ | **showMissedCall(uuid, name, type)** | All | Shows miss call on the deivce notification tray|
140
+ | **destroyNativeCallUI(uuid)** | All | Stops ringtone/Activity. iOS: Ends CallKit (Handoff vs Hangup logic). |
141
+ | **getInitialCallData()** | All | Retrieves the call payload if the app was cold-started via a notification Answer. |
142
+ | **subscribe(onAccept, onReject, onFailed)** | All | Listens for Answer/Decline button presses and system-level bridge errors. |
139
143
  | **checkOverlayPermission()** | Android | Android only: Returns true if app can draw over other apps while unlocked. |
140
144
  | **checkFullScreenPermission()** | Android | Android 14+: Checks if app can trigger full-screen intents on lockscreen. |
141
145
  | **requestOverlayPermission()** | Android | Android only: Navigates user to "Draw over other apps" system settings. |
142
146
  | **requestFullScreenSettings()** | Android | Android 14+: Navigates user to "Full Screen Intent" system settings. |
143
- | **destroyNativeCallUI(uuid)** | All | Android: Stops ringtone/Activity. iOS: Ends CallKit (Handoff vs Hangup logic). |
144
- | **getInitialCallData()** | All | Retrieves the call payload if the app was cold-started via a notification Answer. |
145
- | **subscribe(onAccept, onReject, onFailed)** | All | Listens for Answer/Decline button presses and system-level bridge errors. |
146
147
  ---
147
148
 
148
149
  # Implementation Notes
149
150
 
150
- 1. Android Persistence:
151
- Because this library uses a Foreground Service on Android, the notification will persist and show a "Call Pill" in the status bar. To remove this after the call ends or connects, you MUST call 'CallHandler.stopForegroundService()'.
152
-
153
- 2. Android Overlay:
151
+ 1. Android Overlay:
154
152
  For your React Native call screen to show up when the phone is locked, the user must grant the "Overlay Permission". Use 'checkOverlayPermission()' and 'requestOverlayPermission()' during your app's onboarding or call initiation.
155
153
 
156
- 3. iOS CallKit:
154
+ 2. iOS CallKit:
157
155
  On iOS, 'displayCall' uses the native system CallKit UI. This works automatically in the background and on the lockscreen without extra overlay permissions.
158
156
 
159
- 4. Single Call Gate:
157
+ 3. Single Call Gate:
160
158
  The library automatically prevents multiple overlapping native UIs. If a call is already active, subsequent calls will trigger the 'BUSY' event in your Headless Task.
161
159
  ---
162
160
 
@@ -162,6 +162,27 @@ class CallModule(
162
162
  promise.resolve(map)
163
163
  }
164
164
 
165
+ @ReactMethod
166
+ fun showMissedCall(
167
+ uuid: String,
168
+ name: String,
169
+ callType: String,
170
+ promise: Promise,
171
+ ) {
172
+ try {
173
+ val data =
174
+ mapOf(
175
+ "callUuid" to uuid,
176
+ "name" to name,
177
+ "callType" to callType,
178
+ )
179
+ NativeCallManager.showMissedCallNotification(reactApplicationContext, data)
180
+ promise.resolve(true)
181
+ } catch (e: Exception) {
182
+ promise.reject("MISSED_CALL_ERROR", e.message)
183
+ }
184
+ }
185
+
165
186
  @ReactMethod
166
187
  fun endNativeCall(uuid: String) {
167
188
  NativeCallManager.dismissIncomingCall(reactApplicationContext, uuid)
@@ -14,7 +14,7 @@ import androidx.core.app.Person
14
14
  import androidx.core.content.ContextCompat
15
15
 
16
16
  object NativeCallManager {
17
- const val channelId = "CALL_CHANNEL_V15_URGENT"
17
+ const val channelId = "CALL_CHANNEL_V0_URGENT"
18
18
  private var currentCallData: Map<String, String>? = null
19
19
 
20
20
  @JvmStatic internal var pendingCallNotification: Notification? = null
@@ -28,10 +28,13 @@ object NativeCallManager {
28
28
  data: Map<String, String>,
29
29
  ) {
30
30
  Handler(Looper.getMainLooper()).post {
31
- val uuid = data["callUuid"] ?: return@post
32
31
  this.currentCallData = data
33
- val name = data["name"] ?: "Incoming Call"
32
+ val uuid = data["callUuid"] ?: return@post
33
+ val name = data["name"] ?: "Someone"
34
+ val callType = data["callType"] ?: "audio"
35
+ val isVideo = callType.equals("video", ignoreCase = true)
34
36
  val notificationId = uuid.hashCode()
37
+
35
38
  val ringtoneUri = RingtoneManager.getDefaultUri(RingtoneManager.TYPE_RINGTONE)
36
39
 
37
40
  val pendingFlags =
@@ -86,6 +89,29 @@ object NativeCallManager {
86
89
  notificationManager.createNotificationChannel(channel)
87
90
  }
88
91
 
92
+ val iconId =
93
+ context.resources
94
+ .getIdentifier("notification_icon", "drawable", context.packageName)
95
+ .let { id ->
96
+ if (id != 0) {
97
+ id // Found the one you saw in the folder!
98
+ } else {
99
+ // Fallback chain if notification_icon is missing
100
+ val shellId = context.resources.getIdentifier("shell_notification_icon", "drawable", context.packageName)
101
+ if (shellId != 0) shellId else context.applicationInfo.icon
102
+ }
103
+ }
104
+
105
+ val colorId = context.resources.getIdentifier("notification_icon_color", "color", context.packageName)
106
+
107
+ val iconColor =
108
+ if (colorId != 0) {
109
+ ContextCompat.getColor(context, colorId)
110
+ } else {
111
+ // Fallback to a default blue or grey if they didn't provide a color
112
+ Color.parseColor("#ffffff")
113
+ }
114
+
89
115
  val caller =
90
116
  Person
91
117
  .Builder()
@@ -93,15 +119,38 @@ object NativeCallManager {
93
119
  .setImportant(true)
94
120
  .build()
95
121
 
122
+ val incomingCallTemplate =
123
+ NotificationCompat.CallStyle.forIncomingCall(
124
+ caller,
125
+ rejectPendingIntent,
126
+ answerPendingIntent,
127
+ )
128
+
129
+ if (isVideo) {
130
+ try {
131
+ incomingCallTemplate.setIsVideo(true)
132
+ } catch (e: Exception) {
133
+ // Some versions of Android may throw here; ignore
134
+ }
135
+ }
136
+
96
137
  val builder =
97
138
  NotificationCompat
98
139
  .Builder(context, channelId)
99
- .setSmallIcon(context.applicationInfo.icon)
140
+ .setSmallIcon(iconId)
141
+ // 1. FOR ANDROID 8/9: They prioritize ContentTitle over the Style's internal logic.
142
+ // We include the name here so it's impossible to miss.
143
+ .setContentTitle("Incoming $callType Call from $name")
144
+ // 2. FOR ALL VERSIONS: This helps fill the "Status" line below the name.
145
+ .setContentText("Incoming $callType Call")
146
+ // 3. FOR ANDROID 12+: This places the text in the header next to the App Name.
147
+ .setSubText("Incoming $callType Call")
148
+ .setColor(iconColor)
100
149
  .setPriority(NotificationCompat.PRIORITY_MAX)
101
150
  .setCategory(NotificationCompat.CATEGORY_CALL)
102
151
  .setOngoing(true)
103
152
  .setFullScreenIntent(fullScreenPendingIntent, true)
104
- .setStyle(NotificationCompat.CallStyle.forIncomingCall(caller, rejectPendingIntent, answerPendingIntent))
153
+ .setStyle(incomingCallTemplate)
105
154
  .setForegroundServiceBehavior(NotificationCompat.FOREGROUND_SERVICE_IMMEDIATE)
106
155
 
107
156
  val notification = builder.build()
@@ -125,6 +174,66 @@ object NativeCallManager {
125
174
  manager.cancel(uuid.hashCode())
126
175
  }
127
176
 
177
+ fun showMissedCallNotification(
178
+ context: Context,
179
+ data: Map<String, String>,
180
+ ) {
181
+ val uuid = data["callUuid"] ?: return
182
+ val name = data["name"] ?: "Unknown"
183
+ val callType = data["callType"] ?: "video"
184
+ val channelId = "missed_calls"
185
+
186
+ val notificationManager =
187
+ context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
188
+
189
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
190
+ val channel =
191
+ NotificationChannel(
192
+ channelId,
193
+ "Missed Calls",
194
+ NotificationManager.IMPORTANCE_DEFAULT,
195
+ ).apply { description = "Missed call notifications" }
196
+ notificationManager.createNotificationChannel(channel)
197
+ }
198
+
199
+ // Use your custom notification icon if available, fallback to system missed call icon
200
+ val iconResId =
201
+ context.resources
202
+ .getIdentifier("notification_icon", "drawable", context.packageName)
203
+ .takeIf { it != 0 } ?: android.R.drawable.sym_call_missed
204
+
205
+ val appName = context.applicationInfo.loadLabel(context.packageManager).toString()
206
+
207
+ val launchIntent = context.packageManager.getLaunchIntentForPackage(context.packageName)
208
+ launchIntent?.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_SINGLE_TOP)
209
+
210
+ val pendingIntent =
211
+ PendingIntent.getActivity(
212
+ context,
213
+ uuid.hashCode(),
214
+ launchIntent,
215
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
216
+ PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
217
+ } else {
218
+ PendingIntent.FLAG_UPDATE_CURRENT
219
+ },
220
+ )
221
+
222
+ val builder =
223
+ NotificationCompat
224
+ .Builder(context, channelId)
225
+ .setSmallIcon(iconResId)
226
+ .setContentTitle("Missed $callType call")
227
+ .setContentText("You missed a call from $name")
228
+ .setPriority(NotificationCompat.PRIORITY_HIGH)
229
+ .setAutoCancel(true)
230
+ .setCategory(NotificationCompat.CATEGORY_MISSED_CALL)
231
+ .setVisibility(NotificationCompat.VISIBILITY_PUBLIC)
232
+ .setContentIntent(pendingIntent)
233
+
234
+ notificationManager.notify(uuid.hashCode(), builder.build())
235
+ }
236
+
128
237
  fun dismissIncomingCall(
129
238
  context: Context,
130
239
  uuid: String?,
package/index.d.ts CHANGED
@@ -62,6 +62,15 @@ export interface CallHandlerType {
62
62
  callType?: 'audio' | 'video',
63
63
  ): Promise<boolean>;
64
64
 
65
+ /**
66
+ * Displays the full-screen Native Incoming Call UI.
67
+ */
68
+ showMissedCall(
69
+ uuid: string,
70
+ name: string,
71
+ callType?: 'audio' | 'video',
72
+ ): Promise<boolean>;
73
+
65
74
  /** * Checks if a call is still valid and has not been canceled.
66
75
  */
67
76
  checkCallValidity(uuid: string): Promise<ValidityStatus>;
package/index.js CHANGED
@@ -78,6 +78,11 @@ export const CallHandler = {
78
78
  return await CallModule.checkCallStatus(uuid.toLowerCase().trim());
79
79
  },
80
80
 
81
+ showMissedCall: async (uuid, name, callType) => {
82
+ if (!CallModule?.showMissedCall) return false;
83
+ return await CallModule.showMissedCall(uuid.toLowerCase().trim(), name, callType);
84
+ },
85
+
81
86
  //--------------------------------------------------------------------------------------
82
87
 
83
88
  requestOverlayPermission: async () => {
package/ios/CallModule.m CHANGED
@@ -4,7 +4,8 @@
4
4
  @interface CallModule ()
5
5
  @property (nonatomic, strong) NSString *pendingCallUuid;
6
6
  @property (nonatomic, strong) NSMutableDictionary *pendingEvents;
7
- @property (nonatomic, assign) BOOL isCallActive; // Track state for handoff vs hangup
7
+ @property (nonatomic, assign) BOOL isCallActive;
8
+ @property (nonatomic, strong) CXCallObserver *callObserver;
8
9
  @end
9
10
 
10
11
  @implementation CallModule
@@ -21,9 +22,10 @@ RCT_EXPORT_MODULE();
21
22
  self = [super init];
22
23
  if (self) {
23
24
  self.pendingEvents = [NSMutableDictionary new];
25
+ self.callObserver = [[CXCallObserver alloc] init];
26
+
24
27
  NSString *appName = [[NSBundle mainBundle] objectForInfoDictionaryKey:@"CFBundleDisplayName"] ?: [[NSBundle mainBundle] objectForInfoDictionaryKey:@"CFBundleName"];
25
28
 
26
- // Corrected initialization for modern iOS
27
29
  CXProviderConfiguration *config = [[CXProviderConfiguration alloc] initWithLocalizedName:appName];
28
30
  config.supportsVideo = YES;
29
31
  config.maximumCallGroups = 1;
@@ -38,65 +40,47 @@ RCT_EXPORT_MODULE();
38
40
  return self;
39
41
  }
40
42
 
41
- /**
42
- * Modern helper to find the active UIWindow without using deprecated .keyWindow or .windows
43
- */
44
43
  - (UIWindow *)getActiveWindow {
45
44
  if (@available(iOS 13.0, *)) {
46
45
  NSSet<UIScene *> *scenes = [[UIApplication sharedApplication] connectedScenes];
47
46
  for (UIScene *scene in scenes) {
48
- // We look for a foreground active window scene
49
47
  if (scene.activationState == UISceneActivationStateForegroundActive &&
50
48
  [scene isKindOfClass:[UIWindowScene class]]) {
51
49
  UIWindowScene *windowScene = (UIWindowScene *)scene;
52
-
53
- // On iOS 15+, we use windowScene.keyWindow if available,
54
- // otherwise iterate the windows array of that scene
55
50
  if (@available(iOS 15.0, *)) {
56
51
  if (windowScene.keyWindow) return windowScene.keyWindow;
57
52
  }
58
-
59
53
  for (UIWindow *window in windowScene.windows) {
60
54
  if (window.isKeyWindow) return window;
61
55
  }
62
56
  }
63
57
  }
64
58
  }
65
-
66
- // Fallback for older devices or edge cases where scene is not yet active
67
59
  #pragma clang diagnostic push
68
60
  #pragma clang diagnostic ignored "-Wdeprecated-declarations"
69
61
  return [UIApplication sharedApplication].keyWindow;
70
62
  #pragma clang diagnostic pop
71
63
  }
72
64
 
73
- // MARK: - Unified Export Method
65
+ // MARK: - Exported Methods
74
66
 
75
67
  RCT_EXPORT_METHOD(endNativeCall:(NSString *)uuidString) {
76
68
  NSUUID *uuid = [[NSUUID alloc] initWithUUIDString:uuidString];
77
69
  if (!uuid) return;
78
-
70
+
79
71
  dispatch_async(dispatch_get_main_queue(), ^{
80
72
  if (self.isCallActive) {
81
- // REAL HANG UP
82
- [self.provider reportCallWithUUID:uuid
83
- endedAtDate:[NSDate date]
84
- reason:CXCallEndedReasonRemoteEnded];
73
+ [self.provider reportCallWithUUID:uuid endedAtDate:[NSDate date] reason:CXCallEndedReasonRemoteEnded];
85
74
  self.isCallActive = NO;
86
75
  } else {
87
- // HANDOFF (Hide the CallKit banner/UI)
88
- [self.provider reportCallWithUUID:uuid
89
- endedAtDate:[NSDate date]
90
- reason:CXCallEndedReasonAnsweredElsewhere];
76
+ [self.provider reportCallWithUUID:uuid endedAtDate:[NSDate date] reason:CXCallEndedReasonAnsweredElsewhere];
91
77
  self.isCallActive = YES;
92
78
  }
93
79
  self.currentCallUUID = nil;
94
80
  self.pendingCallUuid = nil;
95
- });
81
+ }); // <--- FIXED: Added the missing ');' here
96
82
  }
97
83
 
98
- // MARK: - Core Logic
99
-
100
84
  RCT_EXPORT_METHOD(displayIncomingCall:(NSString *)uuidString
101
85
  name:(NSString *)name
102
86
  callType:(NSString *)callType
@@ -128,15 +112,6 @@ RCT_EXPORT_METHOD(displayIncomingCall:(NSString *)uuidString
128
112
  }];
129
113
  }
130
114
 
131
- RCT_EXPORT_METHOD(getInitialCallData:(RCTPromiseResolveBlock)resolve reject:(RCTPromiseRejectBlock)reject) {
132
- NSMutableDictionary *result = [NSMutableDictionary new];
133
- if (self.pendingCallUuid) {
134
- result[@"default"] = @{@"callUuid": self.pendingCallUuid};
135
- self.pendingCallUuid = nil;
136
- }
137
- resolve(result.count > 0 ? result : [NSNull null]);
138
- }
139
-
140
115
  RCT_EXPORT_METHOD(checkCallValidity:(NSString *)uuidString
141
116
  resolve:(RCTPromiseResolveBlock)resolve
142
117
  reject:(RCTPromiseRejectBlock)reject)
@@ -147,12 +122,9 @@ RCT_EXPORT_METHOD(checkCallValidity:(NSString *)uuidString
147
122
  return;
148
123
  }
149
124
 
150
- // On iOS, we check if the call is currently known to the system observer
151
- CXCallObserver *callObserver = [[CXCallObserver alloc] init];
152
125
  BOOL found = NO;
153
-
154
- for (CXCall *call in callObserver.calls) {
155
- if ([call.uuid.UUIDString isEqualToString:uuid.UUIDString]) {
126
+ for (CXCall *call in self.callObserver.calls) {
127
+ if ([call.UUID.UUIDString.lowercaseString isEqualToString:uuidString.lowercaseString]) {
156
128
  if (!call.hasEnded) {
157
129
  found = YES;
158
130
  break;
@@ -160,8 +132,6 @@ RCT_EXPORT_METHOD(checkCallValidity:(NSString *)uuidString
160
132
  }
161
133
  }
162
134
 
163
- // isValid: True if CallKit is currently tracking this call
164
- // isCanceled: False if the call is active and valid
165
135
  resolve(@{
166
136
  @"isValid": @(found),
167
137
  @"isCanceled": @(!found)
@@ -172,22 +142,11 @@ RCT_EXPORT_METHOD(checkCallStatus:(NSString *)uuidString
172
142
  resolve:(RCTPromiseResolveBlock)resolve
173
143
  reject:(RCTPromiseRejectBlock)reject)
174
144
  {
175
- NSUUID *uuid = [[NSUUID alloc] initWithUUIDString:uuidString];
176
- if (!uuid) {
177
- resolve(@{
178
- @"isCanceled": @YES,
179
- @"isActive": @NO,
180
- @"shouldDisplay": @NO
181
- });
182
- return;
183
- }
184
-
185
- CXCallObserver *callObserver = [[CXCallObserver alloc] init];
186
145
  BOOL isActive = NO;
187
146
  BOOL isCanceled = YES;
188
147
 
189
- for (CXCall *call in callObserver.calls) {
190
- if ([call.uuid.UUIDString isEqualToString:uuid.UUIDString]) {
148
+ for (CXCall *call in self.callObserver.calls) {
149
+ if ([call.UUID.UUIDString.lowercaseString isEqualToString:uuidString.lowercaseString]) {
191
150
  if (!call.hasEnded) {
192
151
  isActive = YES;
193
152
  isCanceled = NO;
@@ -206,22 +165,13 @@ RCT_EXPORT_METHOD(checkCallStatus:(NSString *)uuidString
206
165
  // MARK: - CXProviderDelegate
207
166
 
208
167
  - (void)provider:(CXProvider *)provider performAnswerCallAction:(CXAnswerCallAction *)action {
209
- AVAudioSession *session = [AVAudioSession sharedInstance];
210
- [session setCategory:AVAudioSessionCategoryPlayAndRecord mode:AVAudioSessionModeVoiceChat options:AVAudioSessionCategoryOptionAllowBluetoothHFP | AVAudioSessionCategoryOptionDefaultToSpeaker error:nil];
211
- [session setActive:YES error:nil];
212
-
213
168
  [action fulfill];
214
-
215
169
  NSString *uuidStr = [action.callUUID.UUIDString lowercaseString];
216
170
  self.pendingCallUuid = uuidStr;
217
171
 
218
172
  dispatch_async(dispatch_get_main_queue(), ^{
219
- // Use our non-deprecated helper to bring app to front
220
173
  UIWindow *window = [self getActiveWindow];
221
- if (window) {
222
- [window makeKeyAndVisible];
223
- }
224
-
174
+ if (window) [window makeKeyAndVisible];
225
175
  [self sendEventWithName:@"onCallAccepted" body:@{@"callUuid": uuidStr}];
226
176
  });
227
177
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "rns-nativecall",
3
- "version": "1.2.1",
3
+ "version": "1.2.3",
4
4
  "description": "High-performance React Native module for handling native VoIP call UI on Android and iOS.",
5
5
  "main": "index.js",
6
6
  "types": "index.d.ts",
@@ -1,4 +1,4 @@
1
- const { withAndroidManifest, withInfoPlist, withPlugins, withMainActivity, withAppDelegate } = require('@expo/config-plugins');
1
+ const { withAndroidStyles, AndroidConfig, withAndroidManifest, withInfoPlist, withPlugins, withMainActivity, withAppDelegate } = require('@expo/config-plugins');
2
2
 
3
3
  function withMainActivityDataFix(config) {
4
4
  return withMainActivity(config, (config) => {
@@ -69,6 +69,21 @@ function withMainActivityDataFix(config) {
69
69
  return config;
70
70
  });
71
71
  }
72
+ function withNotificationColor(config) {
73
+ // 1. Find the color in app.json (fallback to #218aff)
74
+ const notificationPlugin = config.plugins?.find(p => Array.isArray(p) && p[0] === 'expo-notifications');
75
+ const iconColor = notificationPlugin?.[1]?.color || '#218aff';
76
+
77
+ return withAndroidStyles(config, (config) => {
78
+ config.modResults = AndroidConfig.Styles.assignStylesValue(config.modResults, {
79
+ name: 'notification_icon_color',
80
+ value: iconColor,
81
+ parent: 'AppTheme', // or your primary theme
82
+ type: 'color',
83
+ });
84
+ return config;
85
+ });
86
+ }
72
87
  /** 2. ANDROID MANIFEST CONFIG **/
73
88
  function withAndroidConfig(config) {
74
89
  return withAndroidManifest(config, (config) => {
@@ -210,7 +225,8 @@ module.exports = (config, props) => {
210
225
  return withPlugins(config, [
211
226
  withAndroidConfig,
212
227
  withMainActivityDataFix,
213
- withIosAppDelegateMod, // <--- ADDED THIS HERE
228
+ withNotificationColor,
229
+ withIosAppDelegateMod,
214
230
  [withIosConfig, props]
215
231
  ]);
216
232
  };