rns-nativecall 0.8.0 → 0.8.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.
@@ -1,73 +1,88 @@
1
1
  package com.rnsnativecall
2
2
 
3
+ import android.app.NotificationManager
3
4
  import android.content.BroadcastReceiver
4
5
  import android.content.Context
5
6
  import android.content.Intent
6
- import android.app.NotificationManager
7
7
  import android.os.Bundle
8
+ import android.util.Log
8
9
 
9
10
  class CallActionReceiver : BroadcastReceiver() {
10
- override fun onReceive(context: Context, intent: Intent) {
11
- // Stop sound immediately
12
- NativeCallManager.stopRingtone()
13
-
11
+ override fun onReceive(
12
+ context: Context,
13
+ intent: Intent,
14
+ ) {
14
15
  val uuid = intent.getStringExtra("EXTRA_CALL_UUID") ?: return
15
16
  val action = intent.action ?: ""
16
- val notificationManager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
17
17
 
18
- // 1. Reconstruct the full data map from Intent Extras
19
18
  val fullDataMap = mutableMapOf<String, String>()
20
19
  intent.extras?.keySet()?.forEach { key ->
21
20
  intent.extras?.get(key)?.let { fullDataMap[key] = it.toString() }
22
21
  }
23
22
 
24
- val name = fullDataMap["name"] ?: "Someone"
25
- val callType = fullDataMap["callType"] ?: "audio"
26
-
27
23
  // --- HANDLE REJECT ---
28
24
  if (action == "ACTION_REJECT") {
25
+ NativeCallManager.dismissIncomingCall(context, uuid)
26
+ CallForegroundService.stop(context)
27
+
29
28
  if (CallModule.isReady()) {
30
29
  CallModule.sendEventToJS("onCallRejected", fullDataMap)
31
- notificationManager.cancel(uuid.hashCode())
32
- CallForegroundService.stop(context)
33
30
  } else {
34
31
  CallModule.setPendingCallData("onCallRejected_pending", fullDataMap)
35
- NativeCallManager.aborting(context, uuid, name, callType)
36
32
  }
33
+ // Just close the shade
34
+ collapseNotificationShade(context)
37
35
  }
38
36
 
39
37
  // --- HANDLE ANSWER ---
40
38
  if (action == "ACTION_ANSWER") {
41
- // Dismiss the "Incoming" pill immediately
42
- notificationManager.cancel(uuid.hashCode())
39
+ NativeCallManager.dismissIncomingCall(context, uuid)
43
40
 
44
41
  if (CallModule.isReady()) {
45
- // Inform JS with full payload
46
42
  CallModule.sendEventToJS("onCallAccepted", fullDataMap)
47
- CallForegroundService.stop(context)
48
-
49
- // Open the app
50
- launchApp(context, intent.extras)
51
43
  } else {
52
- // Cold start: Queue full payload and show connecting
53
44
  CallModule.setPendingCallData("onCallAccepted_pending", fullDataMap)
54
- NativeCallManager.connecting(context, uuid, name, callType)
55
-
56
- launchApp(context, intent.extras)
57
45
  }
46
+
47
+ // This will open your app and the system usually collapses the shade automatically
48
+ launchApp(context, intent.extras)
49
+ collapseNotificationShade(context)
58
50
  }
59
51
  }
60
52
 
53
+ private fun launchApp(
54
+ context: Context,
55
+ extras: Bundle?,
56
+ ) {
57
+ CallForegroundService.stop(context)
61
58
 
62
- private fun launchApp(context: Context, extras: Bundle?) {
63
- val intent = Intent(context, AcceptCallActivity::class.java).apply {
64
- addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or
65
- Intent.FLAG_ACTIVITY_SINGLE_TOP or
66
- Intent.FLAG_ACTIVITY_CLEAR_TOP)
67
-
68
- if (extras != null) putExtras(extras)
59
+ val intent =
60
+ Intent(context, AcceptCallActivity::class.java).apply {
61
+ // Use these flags to ensure the app jumps to the front
62
+ addFlags(
63
+ Intent.FLAG_ACTIVITY_NEW_TASK or
64
+ Intent.FLAG_ACTIVITY_SINGLE_TOP or
65
+ Intent.FLAG_ACTIVITY_REORDER_TO_FRONT,
66
+ )
67
+
68
+ if (extras != null) putExtras(extras)
69
+ }
70
+ context.startActivity(intent)
69
71
  }
70
- context.startActivity(intent)
71
- }
72
72
 
73
- }
73
+ private fun collapseNotificationShade(context: Context) {
74
+ try {
75
+ // Using reflection for StatusBarManager is safe because we wrap it
76
+ // in a try-catch. If it fails, it just won't close the shade, but won't crash.
77
+ val statusBarService = context.getSystemService("statusbar")
78
+ val statusBarManager = Class.forName("android.app.StatusBarManager")
79
+ val method = statusBarManager.getMethod("collapsePanels")
80
+ method.isAccessible = true
81
+ method.invoke(statusBarService)
82
+ } catch (e: Exception) {
83
+ // Log it, but don't do anything else.
84
+ // DO NOT send ACTION_CLOSE_SYSTEM_DIALOGS here.
85
+ Log.e("CallActionReceiver", "Safe collapse failed: ${e.message}")
86
+ }
87
+ }
88
+ }
@@ -8,14 +8,13 @@ import android.content.IntentFilter
8
8
  import android.content.pm.ServiceInfo
9
9
  import android.os.Build
10
10
  import android.os.Bundle
11
+ import android.os.Handler
11
12
  import android.os.IBinder
13
+ import android.os.Looper
12
14
  import androidx.core.app.NotificationCompat
13
15
  import com.facebook.react.HeadlessJsTaskService
14
- import android.os.Handler
15
- import android.os.Looper
16
16
 
17
17
  class CallForegroundService : Service() {
18
-
19
18
  private var unlockReceiver: UnlockReceiver? = null // Store reference for unregistering
20
19
 
21
20
  companion object {
@@ -30,65 +29,91 @@ class CallForegroundService : Service() {
30
29
 
31
30
  override fun onCreate() {
32
31
  super.onCreate()
33
-
32
+
34
33
  // --- DYNAMIC REGISTRATION FIX ---
35
34
  // Registering here makes the system allow the USER_PRESENT broadcast
36
35
  // because the app is already in the Foreground (via this service).
37
36
  unlockReceiver = UnlockReceiver()
38
37
  val filter = IntentFilter(Intent.ACTION_USER_PRESENT)
39
-
38
+
40
39
  if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
41
- // Android 13+ requires the RECEIVER_EXPORTED flag for system broadcasts
42
- registerReceiver(unlockReceiver, filter, Context.RECEIVER_EXPORTED)
40
+ // Prefer NOT_EXPORTED for dynamically-registered receivers to avoid
41
+ // background delivery issues and to restrict broadcasts to our process.
42
+ registerReceiver(unlockReceiver, filter, Context.RECEIVER_NOT_EXPORTED)
43
43
  } else {
44
44
  registerReceiver(unlockReceiver, filter)
45
45
  }
46
46
  }
47
47
 
48
- override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
48
+ override fun onStartCommand(
49
+ intent: Intent?,
50
+ flags: Int,
51
+ startId: Int,
52
+ ): Int {
49
53
  val data = intent?.extras
50
54
  val name = data?.getString("name") ?: "Someone"
51
-
55
+
52
56
  createNotificationChannel()
53
57
 
54
- val notification = NotificationCompat.Builder(this, CHANNEL_ID)
55
- .setContentTitle(name)
56
- .setContentText("Incoming Call...")
57
- .setSmallIcon(applicationInfo.icon)
58
- .setCategory(NotificationCompat.CATEGORY_CALL)
59
- .setPriority(NotificationCompat.PRIORITY_HIGH)
60
- .setOngoing(true)
61
- .build()
58
+ val notification =
59
+ NotificationCompat
60
+ .Builder(this, CHANNEL_ID)
61
+ .setContentTitle(name)
62
+ .setContentText("Incoming Call...")
63
+ .setSmallIcon(applicationInfo.icon)
64
+ .setCategory(NotificationCompat.CATEGORY_CALL)
65
+ .setPriority(NotificationCompat.PRIORITY_HIGH)
66
+ .setOngoing(true)
67
+ .build()
62
68
 
63
69
  if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
64
70
  startForeground(
65
- NOTIFICATION_ID,
66
- notification,
67
- ServiceInfo.FOREGROUND_SERVICE_TYPE_PHONE_CALL
71
+ NOTIFICATION_ID,
72
+ notification,
73
+ ServiceInfo.FOREGROUND_SERVICE_TYPE_PHONE_CALL,
68
74
  )
69
75
  } else {
70
76
  startForeground(NOTIFICATION_ID, notification)
71
77
  }
72
78
 
73
79
  // Launch the Headless Task
74
- val headlessIntent = Intent(this, CallHeadlessTask::class.java).apply {
75
- val bundle = Bundle()
76
- data?.let { b ->
77
- for (key in b.keySet()) {
78
- bundle.putString(key, b.get(key)?.toString())
80
+ val headlessIntent =
81
+ Intent(this, CallHeadlessTask::class.java).apply {
82
+ val bundle = Bundle()
83
+ data?.let { b ->
84
+ for (key in b.keySet()) {
85
+ bundle.putString(key, b.get(key)?.toString())
86
+ }
79
87
  }
88
+ putExtras(bundle)
80
89
  }
81
- putExtras(bundle)
82
- }
83
-
90
+
84
91
  try {
85
92
  this.startService(headlessIntent)
86
93
  HeadlessJsTaskService.acquireWakeLockNow(this)
87
- } catch (e: Exception) { e.printStackTrace() }
94
+ } catch (e: Exception) {
95
+ e.printStackTrace()
96
+ }
97
+
98
+ // // Trigger the incoming call notification UI handled by NativeCallManager
99
+ // try {
100
+ // val map = mutableMapOf<String, String>()
101
+ // data?.let { b ->
102
+ // for (key in b.keySet()) {
103
+ // map[key] = b.get(key)?.toString() ?: ""
104
+ // }
105
+ // }
106
+ // NativeCallManager.handleIncomingPush(this, map)
107
+ // } catch (e: Exception) {
108
+ // e.printStackTrace()
109
+ // }
88
110
 
89
111
  // Auto-stop after 30s
90
112
  Handler(Looper.getMainLooper()).postDelayed({
91
- try { stopSelf() } catch (e: Exception) {}
113
+ try {
114
+ stopSelf()
115
+ } catch (e: Exception) {
116
+ }
92
117
  }, 30000)
93
118
 
94
119
  return START_NOT_STICKY
@@ -99,29 +124,35 @@ class CallForegroundService : Service() {
99
124
  // Unregister to prevent memory leaks once the call ends or service stops
100
125
  try {
101
126
  unlockReceiver?.let { unregisterReceiver(it) }
102
- } catch (e: Exception) { e.printStackTrace() }
103
-
104
- try { stopForeground(true) } catch (_: Exception) {}
127
+ } catch (e: Exception) {
128
+ e.printStackTrace()
129
+ }
130
+
131
+ try {
132
+ stopForeground(true)
133
+ } catch (_: Exception) {
134
+ }
105
135
  super.onDestroy()
106
136
  }
107
137
 
108
138
  private fun createNotificationChannel() {
109
139
  if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
110
- val channel = NotificationChannel(
111
- CHANNEL_ID,
112
- "Call Service",
113
- NotificationManager.IMPORTANCE_LOW
114
- ).apply {
115
- description = "Handles incoming call connection state"
116
- setSound(null, null)
117
- enableVibration(false)
118
- setBypassDnd(true)
119
- lockscreenVisibility = NotificationCompat.VISIBILITY_PUBLIC
120
- }
140
+ val channel =
141
+ NotificationChannel(
142
+ CHANNEL_ID,
143
+ "Call Service",
144
+ NotificationManager.IMPORTANCE_LOW,
145
+ ).apply {
146
+ description = "Incoming call connection state"
147
+ setSound(null, null)
148
+ enableVibration(false)
149
+ setBypassDnd(true)
150
+ lockscreenVisibility = NotificationCompat.VISIBILITY_PUBLIC
151
+ }
121
152
  val manager = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
122
153
  manager.createNotificationChannel(channel)
123
154
  }
124
155
  }
125
156
 
126
157
  override fun onBind(intent: Intent?): IBinder? = null
127
- }
158
+ }
@@ -5,170 +5,186 @@ import android.app.NotificationManager
5
5
  import android.app.PendingIntent
6
6
  import android.content.Context
7
7
  import android.content.Intent
8
+ import android.graphics.Color
9
+ import android.media.AudioAttributes
10
+ import android.media.RingtoneManager
8
11
  import android.os.Build
12
+ import android.os.Handler
13
+ import android.os.Looper
9
14
  import androidx.core.app.NotificationCompat
10
- import android.media.Ringtone
11
- import android.media.RingtoneManager
12
- import android.graphics.Color
13
- import android.app.KeyguardManager
15
+ import androidx.core.app.Person
14
16
 
15
17
  object NativeCallManager {
18
+ // Note: We no longer need the private 'ringtone' variable here
19
+ // because the system NotificationManager handles the sound.
16
20
 
17
- private var ringtone: Ringtone? = null
18
- const val channelId = "CALL_CHANNEL_ID"
19
-
21
+ // Incrementing version to V3 to force fresh channel settings on the device
22
+ const val channelId = "CALL_CHANNEL_V5_URGENT"
20
23
  private var currentCallData: Map<String, String>? = null
21
24
 
22
25
  fun getCurrentCallData(): Map<String, String>? = currentCallData
23
26
 
24
- fun handleIncomingPush(context: Context, data: Map<String, String>) {
25
- val uuid = data["callUuid"] ?: return
26
- this.currentCallData = data
27
- stopRingtone()
28
-
29
- val name = data["name"] ?: "Incoming Call"
30
- val callType = data["callType"] ?: "audio"
31
- val notificationId = uuid.hashCode()
32
-
33
- val pendingFlags = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
34
- PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_MUTABLE
35
- } else {
36
- PendingIntent.FLAG_UPDATE_CURRENT
37
- }
38
-
39
- // Dummy intent for content click
40
- val noOpIntent = PendingIntent.getActivity(context, notificationId + 3, Intent(), pendingFlags)
41
-
42
- // 1. Define the flags properly (DO NOT MIX THEM)
43
- val mutableFlags = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
44
- PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_MUTABLE
45
- } else {
46
- PendingIntent.FLAG_UPDATE_CURRENT
47
- }
48
-
49
- val immutableFlags = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
50
- PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
51
- } else {
52
- PendingIntent.FLAG_UPDATE_CURRENT
53
- }
54
-
55
- // 2. Setup the Overlay Intent
56
- val overlayIntent = Intent(context, NotificationOverlayActivity::class.java).apply {
57
- this.putExtra("EXTRA_CALL_UUID", uuid)
58
- data.forEach { (k, v) -> this.putExtra(k, v) }
59
- // No User Action flag prevents the intent from triggering an auto-dismiss of notifications
60
- addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_NO_USER_ACTION)
61
- }
62
-
63
- // 3. Create the Full Screen PendingIntent (Use IMMUTABLE)
64
- val fullScreenPendingIntent = PendingIntent.getActivity(
65
- context,
66
- notificationId,
67
- overlayIntent,
68
- immutableFlags // <--- Use the immutable version here
69
- )
70
-
27
+ fun handleIncomingPush(
28
+ context: Context,
29
+ data: Map<String, String>,
30
+ ) {
31
+ Handler(Looper.getMainLooper()).post {
32
+ val uuid = data["callUuid"] ?: return@post
33
+ this.currentCallData = data
71
34
 
35
+ val name = data["name"] ?: "Incoming Call"
36
+ val notificationId = uuid.hashCode()
37
+ val ringtoneUri = RingtoneManager.getDefaultUri(RingtoneManager.TYPE_RINGTONE)
72
38
 
73
- // --- ANSWER ACTION ---
74
- val answerIntent = Intent(context, CallActionReceiver::class.java).apply {
75
- this.action = "ACTION_ANSWER"
76
- this.putExtra("EXTRA_CALL_UUID", uuid)
77
-
78
- data.forEach { (key, value) -> this.putExtra(key, value) } // Pass full data
79
- }
80
-
81
- val answerPendingIntent = PendingIntent.getBroadcast(context, notificationId + 1, answerIntent, mutableFlags)
39
+ val pendingFlags =
40
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
41
+ PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
42
+ } else {
43
+ PendingIntent.FLAG_UPDATE_CURRENT
44
+ }
45
+
46
+ // 1. Content Intent (Tap notification)
47
+ val noOpIntent =
48
+ Intent("com.rnsnativecall.ACTION_NOTIFICATION_TAP_NOOP").apply {
49
+ `package` = context.packageName
50
+ putExtra("EXTRA_CALL_UUID", uuid)
51
+ }
52
+ val contentIntent = PendingIntent.getBroadcast(context, notificationId + 3, noOpIntent, pendingFlags)
53
+
54
+ // 2. Full Screen Intent (Wake lockscreen)
55
+ val overlayIntent =
56
+ Intent(context, NotificationOverlayActivity::class.java).apply {
57
+ putExtra("EXTRA_CALL_UUID", uuid)
58
+ data.forEach { (k, v) -> putExtra(k, v) }
59
+ addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_NO_USER_ACTION)
60
+ }
61
+ val fullScreenPendingIntent = PendingIntent.getActivity(context, notificationId, overlayIntent, pendingFlags)
62
+
63
+ // 3. Answer Action
64
+ val answerIntent =
65
+ Intent(context, CallActionReceiver::class.java).apply {
66
+ action = "ACTION_ANSWER"
67
+ putExtra("EXTRA_CALL_UUID", uuid)
68
+ data.forEach { (k, v) -> putExtra(k, v) }
69
+ }
70
+ val answerPendingIntent = PendingIntent.getBroadcast(context, notificationId + 1, answerIntent, pendingFlags)
71
+
72
+ // 4. Reject Action
73
+ val rejectIntent =
74
+ Intent(context, CallActionReceiver::class.java).apply {
75
+ action = "ACTION_REJECT"
76
+ putExtra("EXTRA_CALL_UUID", uuid)
77
+ data.forEach { (k, v) -> putExtra(k, v) }
78
+ }
79
+ val rejectPendingIntent = PendingIntent.getBroadcast(context, notificationId + 2, rejectIntent, pendingFlags)
80
+
81
+ // Setup Channel with System-Managed Sound
82
+ val notificationManager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
83
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
84
+ val channel =
85
+ NotificationChannel(channelId, "Incoming Calls", NotificationManager.IMPORTANCE_HIGH).apply {
86
+ lockscreenVisibility = NotificationCompat.VISIBILITY_PUBLIC
87
+ enableVibration(true)
88
+ setBypassDnd(true)
89
+ // System-managed sound ensures the "pill" pops up correctly
90
+ setSound(
91
+ ringtoneUri,
92
+ AudioAttributes
93
+ .Builder()
94
+ .setUsage(AudioAttributes.USAGE_NOTIFICATION_RINGTONE)
95
+ .setContentType(AudioAttributes.CONTENT_TYPE_SONIFICATION)
96
+ .build(),
97
+ )
98
+ }
99
+ notificationManager.createNotificationChannel(channel)
100
+ }
82
101
 
83
- // --- REJECT ACTION ---
84
- val rejectIntent = Intent(context, CallActionReceiver::class.java).apply {
85
- this.action = "ACTION_REJECT"
86
- this.putExtra("EXTRA_CALL_UUID", uuid)
87
- data.forEach { (key, value) -> this.putExtra(key, value) } // Pass full data
102
+ val caller =
103
+ Person
104
+ .Builder()
105
+ .setName(name)
106
+ .setImportant(true)
107
+ .build()
108
+
109
+ val builder =
110
+ NotificationCompat
111
+ .Builder(context, channelId)
112
+ .setSmallIcon(context.applicationInfo.icon)
113
+ .setPriority(NotificationCompat.PRIORITY_MAX)
114
+ .setCategory(NotificationCompat.CATEGORY_CALL)
115
+ .setOngoing(true)
116
+ .setAutoCancel(false)
117
+ .setVisibility(NotificationCompat.VISIBILITY_PUBLIC)
118
+ .setContentIntent(contentIntent)
119
+ .setFullScreenIntent(fullScreenPendingIntent, true)
120
+ .setStyle(
121
+ NotificationCompat.CallStyle
122
+ .forIncomingCall(caller, rejectPendingIntent, answerPendingIntent)
123
+ .setAnswerButtonColorHint(Color.GREEN)
124
+ .setDeclineButtonColorHint(Color.RED),
125
+ ).setForegroundServiceBehavior(NotificationCompat.FOREGROUND_SERVICE_IMMEDIATE)
126
+
127
+ // Sending this notification will now trigger the system ringtone automatically
128
+ notificationManager.notify(notificationId, builder.build())
88
129
  }
89
-
90
- val rejectPendingIntent = PendingIntent.getBroadcast(context, notificationId + 2, rejectIntent, mutableFlags)
130
+ }
91
131
 
132
+ /**
133
+ * Stop the ringtone by canceling the notification itself.
134
+ * The system manages the sound, so when the notification dies, the sound dies.
135
+ */
136
+ fun dismissIncomingCall(
137
+ context: Context,
138
+ uuid: String?,
139
+ ) {
140
+ this.currentCallData = null
92
141
  val notificationManager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
93
-
94
- if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
95
- val channel = NotificationChannel(channelId, "Incoming Calls", NotificationManager.IMPORTANCE_HIGH).apply {
96
- enableVibration(true)
97
- vibrationPattern = longArrayOf(0, 500, 500, 500)
98
- lightColor = Color.GREEN
99
- setBypassDnd(true)
100
- lockscreenVisibility = NotificationCompat.VISIBILITY_PUBLIC
101
- setSound(null, null)
102
- }
103
- notificationManager.createNotificationChannel(channel)
142
+ if (uuid != null) {
143
+ notificationManager.cancel(uuid.hashCode())
104
144
  }
105
-
106
- val keyguardManager = context.getSystemService(Context.KEYGUARD_SERVICE) as KeyguardManager
107
- val isLocked = keyguardManager.isKeyguardLocked
108
-
109
-
110
- val builder = NotificationCompat.Builder(context, channelId)
111
- .setSmallIcon(context.applicationInfo.icon)
112
- .setContentTitle("Incoming $callType call")
113
- .setContentText(name)
114
- .setPriority(NotificationCompat.PRIORITY_MAX)
115
- .setCategory(NotificationCompat.CATEGORY_CALL)
116
- .setOngoing(true)
117
- .setAutoCancel(false)
118
- .setVisibility(NotificationCompat.VISIBILITY_PUBLIC)
119
- .setContentIntent(noOpIntent)
120
- .addAction(0, "Answer", answerPendingIntent)
121
- .addAction(0, "Decline", rejectPendingIntent)
122
- .setDefaults(NotificationCompat.DEFAULT_ALL)
123
-
124
- builder.setFullScreenIntent(fullScreenPendingIntent, true)
125
-
126
-
127
- notificationManager.notify(notificationId, builder.build())
128
-
129
- try {
130
- val ringtoneUri = RingtoneManager.getDefaultUri(RingtoneManager.TYPE_RINGTONE)
131
- ringtone = RingtoneManager.getRingtone(context, ringtoneUri)
132
- if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) ringtone?.isLooping = true
133
- ringtone?.play()
134
- } catch (e: Exception) { e.printStackTrace() }
135
- }
136
-
137
- fun stopRingtone() {
138
- try {
139
- ringtone?.let { if (it.isPlaying) it.stop() }
140
- ringtone = null
141
- } catch (e: Exception) { ringtone = null }
142
145
  }
143
146
 
144
- fun connecting(context: Context, uuid: String, name: String, callType: String) {
147
+ fun connecting(
148
+ context: Context,
149
+ uuid: String,
150
+ name: String,
151
+ callType: String,
152
+ ) {
145
153
  val notificationManager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
146
- val builder = NotificationCompat.Builder(context, channelId)
147
- .setSmallIcon(context.applicationInfo.icon)
148
- .setContentTitle("Incoming $callType call")
149
- .setContentText("Connecting…")
150
- .setPriority(NotificationCompat.PRIORITY_MAX)
151
- .setOngoing(true)
152
- .setProgress(0, 0, true)
154
+ val builder =
155
+ NotificationCompat
156
+ .Builder(context, channelId)
157
+ .setSmallIcon(context.applicationInfo.icon)
158
+ .setContentTitle(name)
159
+ .setContentText("Connecting…")
160
+ .setPriority(NotificationCompat.PRIORITY_MAX)
161
+ .setCategory(NotificationCompat.CATEGORY_CALL)
162
+ .setOngoing(true)
163
+ .setStyle(NotificationCompat.BigTextStyle().bigText("Connecting…"))
153
164
  notificationManager.notify(uuid.hashCode(), builder.build())
154
165
  }
155
166
 
156
- fun aborting(context: Context, uuid: String, name: String, callType: String) {
167
+ fun aborting(
168
+ context: Context,
169
+ uuid: String,
170
+ name: String,
171
+ callType: String,
172
+ ) {
157
173
  val notificationManager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
158
- val builder = NotificationCompat.Builder(context, channelId)
159
- .setSmallIcon(context.applicationInfo.icon)
160
- .setContentTitle("Incoming $callType call")
161
- .setContentText("Aborting…")
162
- .setPriority(NotificationCompat.PRIORITY_MAX)
163
- .setOngoing(true)
164
- .setProgress(0, 0, true)
174
+ val builder =
175
+ NotificationCompat
176
+ .Builder(context, channelId)
177
+ .setSmallIcon(context.applicationInfo.icon)
178
+ .setContentTitle(name)
179
+ .setContentText("Aborting…")
180
+ .setPriority(NotificationCompat.PRIORITY_MAX)
181
+ .setCategory(NotificationCompat.CATEGORY_CALL)
182
+ .setOngoing(true)
165
183
  notificationManager.notify(uuid.hashCode(), builder.build())
166
184
  }
167
185
 
168
- fun dismissIncomingCall(context: Context, uuid: String?) {
169
- this.currentCallData = null
170
- stopRingtone()
171
- val notificationManager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
172
- if (uuid != null) notificationManager.cancel(uuid.hashCode())
186
+ // Deprecated manual method - kept for signature compatibility but does nothing
187
+ fun stopRingtone() {
188
+ // No-op: Sound is now managed by the Notification Channel life-cycle
173
189
  }
174
- }
190
+ }
package/index.d.ts CHANGED
@@ -80,6 +80,8 @@ export interface CallHandlerType {
80
80
 
81
81
  requestOverlayPermission(): Promise<boolean>;
82
82
 
83
+ reportRemoteEnded(uuid: string, reason: number): void;
84
+
83
85
  checkOverlayPermission(): Promise<boolean>;
84
86
 
85
87
  /**
package/index.js CHANGED
@@ -47,6 +47,11 @@ export const CallHandler = {
47
47
  },
48
48
  // ------------------------------------
49
49
 
50
+ reportRemoteEnded: async (uuid, endReason) => {
51
+ if (!CallModule?.reportRemoteEnded) return;
52
+ await CallModule.reportRemoteEnded(uuid.toLowerCase().trim(), endReason);
53
+ },
54
+
50
55
  requestOverlayPermission: async () => {
51
56
  if (!CallModule?.requestOverlayPermission) return false;
52
57
  return await CallModule.requestOverlayPermission();
package/ios/CallModule.m CHANGED
@@ -3,6 +3,7 @@
3
3
 
4
4
  @interface CallModule ()
5
5
  @property (nonatomic, strong) NSString *pendingCallUuid;
6
+ @property (nonatomic, strong) NSMutableDictionary *pendingEvents;
6
7
  @end
7
8
 
8
9
  @implementation CallModule
@@ -20,6 +21,7 @@ RCT_EXPORT_MODULE();
20
21
  - (instancetype)init {
21
22
  self = [super init];
22
23
  if (self) {
24
+ self.pendingEvents = [NSMutableDictionary new];
23
25
  NSString *appName = [[NSBundle mainBundle] objectForInfoDictionaryKey:@"CFBundleDisplayName"] ?: [[NSBundle mainBundle] objectForInfoDictionaryKey:@"CFBundleName"];
24
26
 
25
27
  CXProviderConfiguration *config = [[CXProviderConfiguration alloc] initWithLocalizedName:appName];
@@ -35,6 +37,34 @@ RCT_EXPORT_MODULE();
35
37
  return self;
36
38
  }
37
39
 
40
+ // MARK: - New Parity Methods (Synced with Android)
41
+
42
+ RCT_EXPORT_METHOD(checkCallValidity:(NSString *)uuidString resolve:(RCTPromiseResolveBlock)resolve reject:(RCTPromiseRejectBlock)reject) {
43
+ BOOL isCurrent = [uuidString isEqualToString:self.currentCallUUID.UUIDString];
44
+ resolve(@{
45
+ @"isValid": @(isCurrent),
46
+ @"isCanceled": @(!isCurrent)
47
+ });
48
+ }
49
+
50
+ RCT_EXPORT_METHOD(getInitialCallData:(RCTPromiseResolveBlock)resolve reject:(RCTPromiseRejectBlock)reject) {
51
+ NSMutableDictionary *result = [NSMutableDictionary new];
52
+
53
+ if (self.pendingCallUuid) {
54
+ result[@"default"] = @{@"callUuid": self.pendingCallUuid};
55
+ self.pendingCallUuid = nil;
56
+ }
57
+
58
+ if (self.pendingEvents.count > 0) {
59
+ [result addEntriesFromDictionary:self.pendingEvents];
60
+ [self.pendingEvents removeAllObjects];
61
+ }
62
+
63
+ resolve(result.count > 0 ? result : [NSNull null]);
64
+ }
65
+
66
+ // MARK: - Core Logic
67
+
38
68
  RCT_EXPORT_METHOD(displayIncomingCall:(NSString *)uuidString
39
69
  name:(NSString *)name
40
70
  callType:(NSString *)callType
@@ -50,9 +80,9 @@ RCT_EXPORT_METHOD(displayIncomingCall:(NSString *)uuidString
50
80
  self.currentCallUUID = uuid;
51
81
 
52
82
  AVAudioSession *session = [AVAudioSession sharedInstance];
53
- [session setCategory:AVAudioSessionCategoryPlayAndRecord
54
- mode:AVAudioSessionModeVoiceChat
55
- options:AVAudioSessionCategoryOptionAllowBluetooth | AVAudioSessionCategoryOptionDefaultToSpeaker
83
+ [session setCategory:AVAudioSessionCategoryPlayAndRecord
84
+ mode:AVAudioSessionModeVoiceChat
85
+ options:AVAudioSessionCategoryOptionAllowBluetoothHFP | AVAudioSessionCategoryOptionDefaultToSpeaker
56
86
  error:nil];
57
87
 
58
88
  CXCallUpdate *update = [[CXCallUpdate alloc] init];
@@ -68,38 +98,35 @@ RCT_EXPORT_METHOD(displayIncomingCall:(NSString *)uuidString
68
98
  }];
69
99
  }
70
100
 
71
- RCT_EXPORT_METHOD(endNativeCall:(NSString *)uuidString)
72
- {
101
+ // Handles programmatic ending (e.g., via FCM "CANCEL")
102
+ RCT_EXPORT_METHOD(reportRemoteEnded:(NSString *)uuidString reason:(NSInteger)reason) {
73
103
  NSUUID *uuid = [[NSUUID alloc] initWithUUIDString:uuidString];
74
104
  if (!uuid) return;
75
105
 
76
- [self.provider reportCallWithUUID:uuid
77
- endedAtDate:[NSDate date]
78
- reason:CXCallEndedReasonRemoteEnded];
79
- self.currentCallUUID = nil;
80
- self.pendingCallUuid = nil;
81
- }
82
-
83
- RCT_EXPORT_METHOD(getInitialCallData:(RCTPromiseResolveBlock)resolve reject:(RCTPromiseRejectBlock)reject) {
84
- if (self.pendingCallUuid) {
85
- resolve(@{@"callUuid": self.pendingCallUuid});
86
- self.pendingCallUuid = nil;
87
- } else {
88
- resolve([NSNull null]);
106
+ // Report to CallKit that the remote user ended the call
107
+ [self.provider reportCallWithUUID:uuid
108
+ endedAtDate:[NSDate date]
109
+ reason:(CXCallEndedReason)reason];
110
+
111
+ // Clear local tracking
112
+ if ([uuid isEqual:self.currentCallUUID]) {
113
+ self.currentCallUUID = nil;
114
+ self.pendingCallUuid = nil;
89
115
  }
90
116
  }
91
117
 
92
- RCT_EXPORT_METHOD(checkTelecomPermissions:(RCTPromiseResolveBlock)resolve reject:(RCTPromiseRejectBlock)reject) {
93
- resolve(@YES);
118
+ RCT_EXPORT_METHOD(endNativeCall:(NSString *)uuidString)
119
+ {
120
+ [self reportRemoteEnded:uuidString reason:CXCallEndedReasonRemoteEnded];
94
121
  }
95
122
 
96
123
  // MARK: - CXProviderDelegate
97
124
 
98
125
  - (void)provider:(CXProvider *)provider performAnswerCallAction:(CXAnswerCallAction *)action {
99
126
  AVAudioSession *session = [AVAudioSession sharedInstance];
100
- [session setCategory:AVAudioSessionCategoryPlayAndRecord
101
- mode:AVAudioSessionModeVoiceChat
102
- options:AVAudioSessionCategoryOptionAllowBluetooth | AVAudioSessionCategoryOptionDefaultToSpeaker
127
+ [session setCategory:AVAudioSessionCategoryPlayAndRecord
128
+ mode:AVAudioSessionModeVoiceChat
129
+ options:AVAudioSessionCategoryOptionAllowBluetoothHFP | AVAudioSessionCategoryOptionDefaultToSpeaker
103
130
  error:nil];
104
131
  [session setActive:YES error:nil];
105
132
 
@@ -108,9 +135,12 @@ RCT_EXPORT_METHOD(checkTelecomPermissions:(RCTPromiseResolveBlock)resolve reject
108
135
  NSString *uuidStr = [action.callUUID.UUIDString lowercaseString];
109
136
  self.pendingCallUuid = uuidStr;
110
137
 
111
- dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(0.5 * NSEC_PER_SEC)), dispatch_get_main_queue(), ^{
112
- // NUDGE: Force the window to become visible.
113
- // This helps transition from "Ghost Mode" to "Active UI" once the user unlocks.
138
+ // Switch from system UI to App UI
139
+ dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(0.1 * NSEC_PER_SEC)), dispatch_get_main_queue(), ^{
140
+ [self.provider reportCallWithUUID:action.callUUID
141
+ endedAtDate:[NSDate date]
142
+ reason:CXCallEndedReasonAnsweredElsewhere];
143
+
114
144
  dispatch_async(dispatch_get_main_queue(), ^{
115
145
  [[[UIApplication sharedApplication] keyWindow] makeKeyAndVisible];
116
146
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "rns-nativecall",
3
- "version": "0.8.0",
3
+ "version": "0.8.2",
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",
@@ -9,7 +9,9 @@ function withMainActivityDataFix(config) {
9
9
  'import android.content.Intent',
10
10
  'import android.os.Bundle',
11
11
  'import android.view.WindowManager',
12
- 'import android.os.Build'
12
+ 'import android.os.Build',
13
+ 'import androidx.core.app.ActivityCompat', // ADDED
14
+ 'import android.Manifest' // ADDED
13
15
  ];
14
16
 
15
17
  // Add imports if they don't exist
@@ -22,8 +24,18 @@ function withMainActivityDataFix(config) {
22
24
  const onCreateCode = `
23
25
  override fun onCreate(savedInstanceState: Bundle?) {
24
26
  super.onCreate(savedInstanceState)
27
+
28
+ // Request Notification Permissions for Android 13+ (Required for Pill UI)
29
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
30
+ ActivityCompat.requestPermissions(
31
+ this,
32
+ arrayOf(Manifest.permission.POST_NOTIFICATIONS),
33
+ 101
34
+ )
35
+ }
36
+
25
37
  if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O_MR1) {
26
- setShowWhenLocked(false)
38
+ setShowWhenLocked(true) // Set to TRUE so call UI can show over lockscreen
27
39
  setTurnScreenOn(true)
28
40
  } else {
29
41
  window.addFlags(
@@ -32,6 +44,7 @@ function withMainActivityDataFix(config) {
32
44
  WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON
33
45
  )
34
46
  }
47
+
35
48
  if (intent.getBooleanExtra("background_wake", false)) {
36
49
  moveTaskToBack(true)
37
50
  }
@@ -43,7 +56,6 @@ function withMainActivityDataFix(config) {
43
56
  setIntent(intent)
44
57
  }`;
45
58
 
46
- // Use a more flexible regex for the class definition
47
59
  const classRegex = /class MainActivity\s*:\s*ReactActivity\(\)\s*\{/;
48
60
 
49
61
  if (!contents.includes('override fun onCreate')) {
@@ -51,7 +63,6 @@ function withMainActivityDataFix(config) {
51
63
  }
52
64
 
53
65
  if (!contents.includes('override fun onNewIntent')) {
54
- // Re-run match check because contents might have changed from onCreate injection
55
66
  contents = contents.replace(classRegex, (match) => `${match}${onNewIntentCode}`);
56
67
  }
57
68
 
@@ -70,7 +81,7 @@ function withAndroidConfig(config) {
70
81
  const mainActivity = application.activity.find((a) => a.$['android:name'] === '.MainActivity');
71
82
  if (mainActivity) {
72
83
  mainActivity.$['android:launchMode'] = 'singleTop';
73
- mainActivity.$['android:showWhenLocked'] = 'false';
84
+ mainActivity.$['android:showWhenLocked'] = 'true'; // Changed to true
74
85
  mainActivity.$['android:turnScreenOn'] = 'true';
75
86
  }
76
87
 
@@ -82,19 +93,17 @@ function withAndroidConfig(config) {
82
93
  'android.permission.POST_NOTIFICATIONS',
83
94
  'android.permission.WAKE_LOCK',
84
95
  'android.permission.DISABLE_KEYGUARD',
85
- 'android.permission.SYSTEM_ALERT_WINDOW'
96
+ 'android.permission.MANAGE_ONGOING_CALLS'
86
97
  ];
87
98
 
88
- // Initialize permissions array if missing
89
99
  manifest.manifest['uses-permission'] = manifest.manifest['uses-permission'] || [];
90
-
91
100
  permissions.forEach((perm) => {
92
101
  if (!manifest.manifest['uses-permission'].some((p) => p.$['android:name'] === perm)) {
93
102
  manifest.manifest['uses-permission'].push({ $: { 'android:name': perm } });
94
103
  }
95
104
  });
96
105
 
97
- // Initialize components if missing
106
+ // Components Setup
98
107
  application.service = application.service || [];
99
108
  application.activity = application.activity || [];
100
109
  application.receiver = application.receiver || [];
@@ -117,7 +126,7 @@ function withAndroidConfig(config) {
117
126
  }
118
127
  });
119
128
 
120
- // 2. Activities (Trampoline & Overlay)
129
+ // 2. Activities (NotificationOverlayActivity is critical for Foldables)
121
130
  const activities = [
122
131
  {
123
132
  name: 'com.rnsnativecall.AcceptCallActivity',
@@ -158,26 +167,6 @@ function withAndroidConfig(config) {
158
167
  });
159
168
  }
160
169
 
161
- // ADD THIS: UnlockReceiver with USER_PRESENT filter
162
- if (!application.receiver.some(r => r.$['android:name'] === 'com.rnsnativecall.UnlockReceiver')) {
163
- application.receiver.push({
164
- $: {
165
- 'android:name': 'com.rnsnativecall.UnlockReceiver',
166
- 'android:exported': 'true',
167
- 'android:enabled': 'true',
168
- 'android:directBootAware': 'true', // Helps bypass some background restrictions
169
- },
170
- 'intent-filter': [
171
- {
172
- $: { 'android:priority': '1000' },
173
- action: [
174
- { $: { 'android:name': 'android.intent.action.USER_PRESENT' } }
175
- ]
176
- }
177
- ]
178
- });
179
- }
180
-
181
170
  return config;
182
171
  });
183
172
  }
@@ -203,4 +192,4 @@ module.exports = (config) => {
203
192
  withMainActivityDataFix,
204
193
  withIosConfig
205
194
  ]);
206
- };
195
+ };