@stream-io/react-native-callingx 0.1.0-beta.7 → 0.1.1-beta.1

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.
Files changed (45) hide show
  1. package/android/build.gradle +7 -1
  2. package/android/src/main/AndroidManifest.xml +31 -1
  3. package/android/src/main/java/io/getstream/rn/callingx/CallEventBroadcastReceiver.kt +17 -0
  4. package/android/src/main/java/io/getstream/rn/callingx/CallRegistrationStore.kt +145 -0
  5. package/android/src/main/java/io/getstream/rn/callingx/CallService.kt +301 -83
  6. package/android/src/main/java/io/getstream/rn/callingx/CallingxModuleImpl.kt +148 -390
  7. package/android/src/main/java/io/getstream/rn/callingx/StreamMessagingService.kt +48 -0
  8. package/android/src/main/java/io/getstream/rn/callingx/model/Call.kt +1 -0
  9. package/android/src/main/java/io/getstream/rn/callingx/notifications/CallNotificationManager.kt +188 -48
  10. package/android/src/main/java/io/getstream/rn/callingx/notifications/NotificationIntentFactory.kt +14 -8
  11. package/android/src/main/java/io/getstream/rn/callingx/notifications/NotificationReceiverActivity.kt +12 -1
  12. package/android/src/main/java/io/getstream/rn/callingx/notifications/NotificationReceiverService.kt +7 -0
  13. package/android/src/main/java/io/getstream/rn/callingx/repo/CallRepository.kt +38 -19
  14. package/android/src/main/java/io/getstream/rn/callingx/repo/LegacyCallRepository.kt +64 -55
  15. package/android/src/main/java/io/getstream/rn/callingx/repo/TelecomCallRepository.kt +241 -195
  16. package/android/src/main/java/io/getstream/rn/callingx/utils/CallEventBus.kt +61 -0
  17. package/android/src/main/java/io/getstream/rn/callingx/utils/SettingsStore.kt +51 -0
  18. package/android/src/newarch/java/io/getstream/rn/callingx/CallingxModule.kt +12 -3
  19. package/android/src/oldarch/java/io/getstream/rn/callingx/CallingxModule.kt +13 -3
  20. package/dist/module/CallingxModule.js +20 -24
  21. package/dist/module/CallingxModule.js.map +1 -1
  22. package/dist/module/spec/NativeCallingx.js.map +1 -1
  23. package/dist/module/utils/constants.js +24 -14
  24. package/dist/module/utils/constants.js.map +1 -1
  25. package/dist/typescript/src/CallingxModule.d.ts +4 -2
  26. package/dist/typescript/src/CallingxModule.d.ts.map +1 -1
  27. package/dist/typescript/src/spec/NativeCallingx.d.ts +7 -4
  28. package/dist/typescript/src/spec/NativeCallingx.d.ts.map +1 -1
  29. package/dist/typescript/src/types.d.ts +33 -5
  30. package/dist/typescript/src/types.d.ts.map +1 -1
  31. package/dist/typescript/src/utils/constants.d.ts +2 -3
  32. package/dist/typescript/src/utils/constants.d.ts.map +1 -1
  33. package/ios/AudioSessionManager.swift +2 -2
  34. package/ios/Callingx.mm +41 -17
  35. package/ios/CallingxImpl.swift +213 -83
  36. package/ios/Settings.swift +2 -2
  37. package/ios/UUIDStorage.swift +10 -10
  38. package/ios/VoipNotificationsManager.swift +8 -8
  39. package/package.json +4 -2
  40. package/src/CallingxModule.ts +20 -21
  41. package/src/spec/NativeCallingx.ts +10 -6
  42. package/src/types.ts +36 -4
  43. package/src/utils/constants.ts +23 -12
  44. /package/android/src/main/java/io/getstream/rn/callingx/{ResourceUtils.kt → utils/ResourceUtils.kt} +0 -0
  45. /package/android/src/main/java/io/getstream/rn/callingx/{Utils.kt → utils/Utils.kt} +0 -0
@@ -0,0 +1,48 @@
1
+ package io.getstream.rn.callingx
2
+
3
+ import android.annotation.SuppressLint
4
+ import com.google.firebase.messaging.RemoteMessage
5
+ import io.invertase.firebase.messaging.ReactNativeFirebaseMessagingService
6
+
7
+ /**
8
+ * Extends React Native Firebase's messaging service to start [CallService] when a
9
+ * data message contains "stream" (e.g. incoming call push), then delegates to the
10
+ * parent so setBackgroundMessageHandler() still runs in JS.
11
+ *
12
+ * Only compiled when the app has @react-native-firebase/app and @react-native-firebase/messaging
13
+ * as dependencies. The app must remove the default [io.invertase.firebase.messaging.ReactNativeFirebaseMessagingService] from
14
+ * the merged manifest so this service is the single FCM handler
15
+ */
16
+ @SuppressLint("MissingFirebaseInstanceTokenRefresh")
17
+ class StreamMessagingService : ReactNativeFirebaseMessagingService() {
18
+
19
+ companion object {
20
+ const val TAG = "[Callingx] StreamMessagingService"
21
+ }
22
+
23
+ override fun onMessageReceived(remoteMessage: RemoteMessage) {
24
+ val data = remoteMessage.data
25
+ debugLog(TAG, "onMessageReceived data = $data")
26
+
27
+ val isSupportedStreamVideoCallRing =
28
+ data["sender"] == "stream.video" && data["type"] == "call.ring"
29
+
30
+ if (isSupportedStreamVideoCallRing) {
31
+ val callCid = data["call_cid"]
32
+ if (callCid.isNullOrEmpty()) {
33
+ debugLog(
34
+ TAG,
35
+ "missing call_cid for call.ring, skipping CallService start",
36
+ )
37
+ } else {
38
+ CallService.startIncomingCallFromPush(applicationContext, data)
39
+ }
40
+ } else {
41
+ debugLog(TAG, "sender or type is not supported, skipping CallService start")
42
+ }
43
+
44
+ // Let React Native Firebase continue its normal processing so
45
+ // setBackgroundMessageHandler() still runs in JS.
46
+ super.onMessageReceived(remoteMessage)
47
+ }
48
+ }
@@ -27,6 +27,7 @@ sealed class Call {
27
27
  val isActive: Boolean,
28
28
  val isOnHold: Boolean,
29
29
  val isMuted: Boolean,
30
+ val isPending: Boolean,
30
31
  val errorCode: Int?,
31
32
  val currentCallEndpoint: CallEndpointCompat?,
32
33
  val availableCallEndpoints: List<CallEndpointCompat>,
@@ -20,11 +20,15 @@ import io.getstream.rn.callingx.debugLog
20
20
  import io.getstream.rn.callingx.getDisconnectCauseString
21
21
  import io.getstream.rn.callingx.model.Call
22
22
  import io.getstream.rn.callingx.repo.CallRepository
23
+ import io.getstream.rn.callingx.utils.SettingsStore
24
+ import androidx.core.graphics.toColorInt
23
25
 
24
26
  /**
25
27
  * Handles call status changes and updates the notification accordingly. For more guidance around
26
28
  * notifications check https://developer.android.com/develop/ui/views/notifications
27
29
  *
30
+ * Supports multiple simultaneous call notifications, each keyed by callId.
31
+ *
28
32
  * @see updateCallNotification
29
33
  */
30
34
  class CallNotificationManager(
@@ -35,110 +39,229 @@ class CallNotificationManager(
35
39
 
36
40
  internal companion object {
37
41
  private const val TAG = "[Callingx] CallNotificationManager"
38
-
39
- const val NOTIFICATION_ID = 200
42
+ private const val DISABLED_COLOR = "#757575" // NOTE: hint color might be ignored by OS
40
43
  }
41
44
 
45
+ enum class OptimisticState { NONE, ACCEPTING, REJECTING }
46
+
47
+ private val lock = Any()
48
+
42
49
  private var notificationsConfig = NotificationsConfig.loadNotificationsConfig(context)
43
50
 
44
51
  private var ringtone: Ringtone? = null
45
52
 
46
- private var hasBecameActive = false
53
+ /**
54
+ * Per-call notification state. Consolidates all per-call tracking into a single struct.
55
+ */
56
+ private data class CallNotificationState(
57
+ val optimisticState: OptimisticState = OptimisticState.NONE,
58
+ val lastSnapshot: NotificationSnapshot? = null,
59
+ val activeWhen: Long? = null,
60
+ val hasBecameActive: Boolean = false,
61
+ )
62
+
63
+ // Per-call state, all guarded by [lock]
64
+ private val notificationsState = mutableMapOf<String, CallNotificationState>()
47
65
 
48
- private var lastPostedSnapshot: NotificationSnapshot? = null
66
+ /** The callId whose notification was used for startForeground(). */
67
+ private var foregroundCallId: String? = null
68
+
69
+ /**
70
+ * Deterministic per-call notification ID.
71
+ *
72
+ * `NotificationManager.notify()`/`cancel()` require a stable integer id for updates/cancels.
73
+ * We derive it from `callId` so we don't need to allocate/store ids.
74
+ */
75
+ private fun getNotificationId(callId: String): Int {
76
+ // Keep the value non-negative (defensive for Android-side expectations).
77
+ return callId.hashCode() and 0x7fffffff
78
+ }
49
79
 
50
80
  /**
51
81
  * Snapshot of call state used to detect notification changes.
52
- * Using a data class ensures immutable comparison and avoids issues
53
- * with mutable Call.Registered objects.
54
82
  */
55
- private data class NotificationSnapshot(
83
+ data class NotificationSnapshot(
56
84
  val id: String,
57
85
  val isActive: Boolean,
58
86
  val isIncoming: Boolean,
87
+ val optimisticState: OptimisticState,
59
88
  val displayTitle: String?,
60
- val displaySubtitle: String?,
61
89
  val displayName: CharSequence,
62
90
  val address: Uri
63
91
  )
64
92
 
65
- private fun Call.Registered.toSnapshot() = NotificationSnapshot(
93
+ /**
94
+ * Creates a snapshot of the call state used to detect notification changes.
95
+ * @return NotificationSnapshot
96
+ */
97
+ private fun Call.Registered.toSnapshot(callId: String) = NotificationSnapshot(
66
98
  id = id,
67
99
  isActive = isActive,
68
100
  isIncoming = isIncoming(),
101
+ optimisticState = notificationsState[callId]?.optimisticState ?: OptimisticState.NONE,
69
102
  displayTitle = displayOptions?.getString(CallService.EXTRA_DISPLAY_TITLE),
70
- displaySubtitle = displayOptions?.getString(CallService.EXTRA_DISPLAY_SUBTITLE),
71
103
  displayName = callAttributes.displayName,
72
104
  address = callAttributes.address
73
105
  )
74
106
 
75
- fun createNotification(call: Call.Registered): Notification {
107
+ fun getOrCreateNotificationId(callId: String): Int = synchronized(lock) {
108
+ if (!notificationsState.containsKey(callId)) {
109
+ notificationsState[callId] = CallNotificationState()
110
+ }
111
+ if (foregroundCallId == null) {
112
+ foregroundCallId = callId
113
+ }
114
+ return@synchronized getNotificationId(callId)
115
+ }
116
+
117
+ /**
118
+ * Sets the optimistic state of the call notification.
119
+ * Optimistic state is used to update the notification text while the app is connecting or declining the call.
120
+ * @param state The optimistic state to set.
121
+ */
122
+ fun setOptimisticState(callId: String, state: OptimisticState) = synchronized(lock) {
123
+ // Be resilient to races where we receive optimistic actions before a notification state entry exists.
124
+ val current =
125
+ notificationsState[callId]
126
+ ?: CallNotificationState().also {
127
+ if (foregroundCallId == null) {
128
+ foregroundCallId = callId
129
+ }
130
+ }
131
+ notificationsState[callId] = if (state != OptimisticState.NONE) {
132
+ current.copy(optimisticState = state, lastSnapshot = null)
133
+ } else {
134
+ current.copy(optimisticState = state)
135
+ }
136
+ }
137
+
138
+ /**
139
+ * Creates a notification for the call.
140
+ * Notification is created based on the call state and optimistic state.
141
+ * @param call The call to create a notification for.
142
+ * @return The notification.
143
+ */
144
+ fun createNotification(callId: String, call: Call.Registered): Notification = synchronized(lock) {
76
145
  debugLog(TAG,"[notifications] createNotification: Creating notification for call ID: ${call.id}")
77
146
 
147
+ val state = notificationsState[callId]
148
+ val optimisticState = state?.optimisticState ?: OptimisticState.NONE
149
+
78
150
  val contentIntent =
79
151
  NotificationIntentFactory.getLaunchActivityIntent(
80
152
  context,
81
153
  CallingxModuleImpl.CALL_ANSWERED_ACTION,
82
154
  call.id
83
155
  )
84
- val callStyle = createCallStyle(call)
85
- val channelId = getChannelId(call)
156
+ val callStyle = createCallStyle(call, optimisticState)
157
+ val channelId = getChannelId(call, optimisticState)
86
158
  debugLog(TAG, "[notifications] createNotification: Channel ID: $channelId")
87
159
 
88
160
  val builder =
89
161
  NotificationCompat.Builder(context, channelId)
90
162
  .setContentIntent(contentIntent)
91
163
  .setFullScreenIntent(contentIntent, true)
92
- .setStyle(callStyle)
93
164
  .setSmallIcon(R.drawable.ic_round_call_24)
94
165
  .setCategory(NotificationCompat.CATEGORY_CALL)
95
166
  .setPriority(NotificationCompat.PRIORITY_MAX)
96
- .setOngoing(true)
167
+ .setOngoing(optimisticState != OptimisticState.REJECTING)
97
168
 
98
- if (!hasBecameActive && call.isActive) {
99
- debugLog(TAG, "[notifications] createNotification: Setting when to current time")
100
- builder.setWhen(System.currentTimeMillis())
169
+ builder.setStyle(callStyle)
170
+
171
+ // When call becomes active we need to set the when to current time and show the chronometer
172
+ if (call.isActive && optimisticState == OptimisticState.NONE && state != null) {
173
+ // We need to set the activation time once when call becomes active
174
+ if (!state.hasBecameActive) {
175
+ debugLog(TAG, "[notifications] createNotification: Setting when to current time for $callId")
176
+ val now = System.currentTimeMillis()
177
+ notificationsState[callId] = state.copy(activeWhen = now, hasBecameActive = true)
178
+ }
179
+ builder.setWhen(notificationsState[callId]?.activeWhen ?: System.currentTimeMillis())
101
180
  builder.setUsesChronometer(true)
102
181
  builder.setShowWhen(true)
103
- hasBecameActive = true
104
182
  }
105
183
 
106
- call.displayOptions?.let {
107
- if (it.containsKey(CallService.EXTRA_DISPLAY_SUBTITLE)) {
108
- builder.setContentText(it.getString(CallService.EXTRA_DISPLAY_SUBTITLE))
184
+ // If the call is not active and the optimistic state is not none, we need to set the notification text
185
+ // based on exact action that is being taken (accepting or rejecting)
186
+ if (optimisticState != OptimisticState.NONE && !call.isActive) {
187
+ val text = when (optimisticState) {
188
+ OptimisticState.ACCEPTING -> SettingsStore.getOptimisticAcceptingText(context)
189
+ OptimisticState.REJECTING -> SettingsStore.getOptimisticRejectingText(context)
190
+ else -> null
109
191
  }
192
+ if (text != null) builder.setContentText(text)
110
193
  }
111
194
 
112
195
  return builder.build()
113
196
  }
114
197
 
115
- fun updateCallNotification(call: Call) {
116
- when (call) {
117
- Call.None, is Call.Unregistered -> {
118
- debugLog(TAG, "[notifications] updateCallNotification: Dismissing notification (call is None or Unregistered)")
119
- notificationManager.cancel(NOTIFICATION_ID)
120
- lastPostedSnapshot = null
121
- hasBecameActive = false
122
- }
123
- is Call.Registered -> {
124
- val newSnapshot = call.toSnapshot()
125
- if (newSnapshot == lastPostedSnapshot) {
126
- debugLog(TAG, "[notifications] updateCallNotification: Skipping - no state change")
127
- return
128
- }
198
+ /**
199
+ * Updates the call notification.
200
+ * If the call is None or Unregistered, we need to dismiss the notification.
201
+ * If the call is active and the optimistic state is not none, we need to reset the optimistic state.
202
+ * If the call is active and the optimistic state is none, we need to create a new notification.
203
+ * @param call The call to update the notification for.
204
+ */
205
+ fun updateCallNotification(callId: String, call: Call.Registered) = synchronized(lock) {
206
+ val state = notificationsState[callId]
207
+ val optimisticState = state?.optimisticState ?: OptimisticState.NONE
208
+ if (call.isActive && optimisticState != OptimisticState.NONE && state != null) {
209
+ notificationsState[callId] = state.copy(optimisticState = OptimisticState.NONE)
210
+ debugLog(TAG, "[notifications] updateCallNotification[$callId]: Resetting optimistic state")
211
+ }
129
212
 
130
- lastPostedSnapshot = newSnapshot
131
- val notification = createNotification(call)
132
- notificationManager.notify(NOTIFICATION_ID, notification)
133
- debugLog(TAG, "[notifications] updateCallNotification: Notification posted successfully")
213
+ // If the new snapshot is the same as the last posted snapshot, we need to skip the update
214
+ val newSnapshot = call.toSnapshot(callId)
215
+ if (newSnapshot == notificationsState[callId]?.lastSnapshot) {
216
+ debugLog(TAG, "[notifications] updateCallNotification[$callId]: Skipping - no state change")
217
+ return@synchronized
218
+ }
219
+
220
+ val notificationId = getOrCreateNotificationId(callId)
221
+ notificationsState[callId] =
222
+ notificationsState[callId]?.copy(lastSnapshot = newSnapshot)
223
+ ?: CallNotificationState(lastSnapshot = newSnapshot)
224
+ val notification = createNotification(callId, call)
225
+ notificationManager.notify(notificationId, notification)
226
+ debugLog(TAG, "[notifications] updateCallNotification[$callId]: Notification posted (id=$notificationId)")
227
+ }
228
+
229
+ fun postNotification(callId: String, notification: Notification) = synchronized(lock) {
230
+ val notificationId = getOrCreateNotificationId(callId)
231
+ notificationManager.notify(notificationId, notification)
232
+ }
233
+
234
+ /**
235
+ * Returns a new foreground notification ID if the caller needs to call startForeground()
236
+ * to re-promote the service, or null if no action is needed.
237
+ */
238
+ fun cancelNotification(callId: String): Int? = synchronized(lock) {
239
+ debugLog(TAG, "[notifications] cancelNotification[$callId]")
240
+ val state = notificationsState.remove(callId)
241
+ val notificationId = getNotificationId(callId)
242
+ notificationManager.cancel(notificationId)
243
+ if (state != null) {
244
+ debugLog(TAG, "[notifications] cancelNotification[$callId]: Cancelled (id=$notificationId)")
245
+ }
246
+
247
+ if (foregroundCallId == callId) {
248
+ foregroundCallId = notificationsState.keys.firstOrNull()
249
+ // Return the new foreground notification ID so the service can re-promote
250
+ if (foregroundCallId != null) {
251
+ return@synchronized getNotificationId(foregroundCallId!!)
134
252
  }
135
253
  }
254
+ return@synchronized null
136
255
  }
137
256
 
138
- fun cancelNotifications() {
139
- notificationManager.cancel(NOTIFICATION_ID)
140
- hasBecameActive = false
141
- lastPostedSnapshot = null
257
+ fun getForegroundCallId(): String? = synchronized(lock) { foregroundCallId }
258
+
259
+ fun cancelAllNotifications() = synchronized(lock) {
260
+ for (callId in notificationsState.keys) {
261
+ notificationManager.cancel(getNotificationId(callId))
262
+ }
263
+ notificationsState.clear()
264
+ foregroundCallId = null
142
265
  }
143
266
 
144
267
  fun startRingtone() {
@@ -174,18 +297,24 @@ class CallNotificationManager(
174
297
  ringtone = null
175
298
  }
176
299
 
177
- private fun getChannelId(call: Call.Registered): String {
178
- return if (call.isIncoming() && !call.isActive) {
300
+ fun resetOptimisticState(callId: String) = synchronized(lock) {
301
+ debugLog(TAG, "[notifications] resetOptimisticState[$callId]: Resetting optimistic state")
302
+ val current = notificationsState[callId] ?: return@synchronized
303
+ notificationsState[callId] = current.copy(optimisticState = OptimisticState.NONE, lastSnapshot = null)
304
+ }
305
+
306
+ private fun getChannelId(call: Call.Registered, optimisticState: OptimisticState): String {
307
+ return if (call.isIncoming() && !call.isActive && optimisticState == OptimisticState.NONE) {
179
308
  notificationsConfig.incomingChannel.id
180
309
  } else {
181
310
  notificationsConfig.ongoingChannel.id
182
311
  }
183
312
  }
184
313
 
185
- private fun createCallStyle(call: Call.Registered): NotificationCompat.CallStyle {
314
+ private fun createCallStyle(call: Call.Registered, optimisticState: OptimisticState): NotificationCompat.CallStyle? {
186
315
  val caller = createPerson(call)
187
316
 
188
- if (call.isIncoming() && !call.isActive) {
317
+ if (call.isIncoming() && !call.isActive && optimisticState == OptimisticState.NONE) {
189
318
  return NotificationCompat.CallStyle.forIncomingCall(
190
319
  caller,
191
320
  NotificationIntentFactory.getPendingBroadcastIntent(
@@ -211,6 +340,17 @@ class CallNotificationManager(
211
340
  )
212
341
  }
213
342
 
343
+ if (optimisticState == OptimisticState.REJECTING) {
344
+ return NotificationCompat.CallStyle.forOngoingCall(
345
+ caller,
346
+ NotificationIntentFactory.getPendingBroadcastIntent(
347
+ context,
348
+ "io.getstream.CALL_END_NOOP",
349
+ call.id
350
+ ) ,
351
+ ).setDeclineButtonColorHint(DISABLED_COLOR.toColorInt())
352
+ }
353
+
214
354
  return NotificationCompat.CallStyle.forOngoingCall(
215
355
  caller,
216
356
  NotificationIntentFactory.getPendingBroadcastIntent(
@@ -5,12 +5,18 @@ import android.content.Context
5
5
  import android.content.Intent
6
6
  import android.os.Build
7
7
  import io.getstream.rn.callingx.CallingxModuleImpl
8
+ import kotlin.math.absoluteValue
8
9
 
9
10
  object NotificationIntentFactory {
10
- // Stable request codes for PendingIntents
11
+ // Base request codes for PendingIntents — combined with callId hash for uniqueness
11
12
  private const val REQUEST_CODE_LAUNCH_ACTIVITY = 1001
12
- private const val REQUEST_CODE_RECEIVER_ACTIVITY = 1002
13
- private const val REQUEST_CODE_SERVICE = 1003
13
+ private const val REQUEST_CODE_RECEIVER_ACTIVITY = 2001
14
+ private const val REQUEST_CODE_SERVICE = 3001
15
+
16
+ /** Generates a unique request code per callId + base offset to avoid PendingIntent collisions. */
17
+ private fun requestCodeFor(callId: String, base: Int): Int {
18
+ return (base + callId.hashCode()).absoluteValue
19
+ }
14
20
 
15
21
  fun getPendingNotificationIntent(
16
22
  context: Context,
@@ -35,7 +41,7 @@ object NotificationIntentFactory {
35
41
 
36
42
  return PendingIntent.getService(
37
43
  context,
38
- REQUEST_CODE_SERVICE,
44
+ requestCodeFor(callId, REQUEST_CODE_SERVICE),
39
45
  intent,
40
46
  PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_MUTABLE
41
47
  )
@@ -57,7 +63,7 @@ object NotificationIntentFactory {
57
63
 
58
64
  return PendingIntent.getActivities(
59
65
  context,
60
- REQUEST_CODE_RECEIVER_ACTIVITY,
66
+ requestCodeFor(callId, REQUEST_CODE_RECEIVER_ACTIVITY),
61
67
  arrayOf(launchActivityIntent, receiverIntent),
62
68
  PendingIntent.FLAG_MUTABLE or PendingIntent.FLAG_UPDATE_CURRENT
63
69
  )
@@ -75,7 +81,7 @@ object NotificationIntentFactory {
75
81
 
76
82
  return PendingIntent.getActivity(
77
83
  context,
78
- REQUEST_CODE_LAUNCH_ACTIVITY,
84
+ requestCodeFor(callId, REQUEST_CODE_LAUNCH_ACTIVITY),
79
85
  callIntent,
80
86
  PendingIntent.FLAG_MUTABLE or PendingIntent.FLAG_UPDATE_CURRENT,
81
87
  )
@@ -94,10 +100,10 @@ object NotificationIntentFactory {
94
100
  addExtras(this)
95
101
  }
96
102
 
97
- // Use action hashCode for unique request code per action type
103
+ // Use action + callId hash for unique request code per action per call
98
104
  return PendingIntent.getBroadcast(
99
105
  context,
100
- action.hashCode(),
106
+ (action.hashCode() + callId.hashCode()).absoluteValue,
101
107
  intent,
102
108
  PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT
103
109
  )
@@ -4,6 +4,7 @@ import android.app.Activity
4
4
  import android.content.Intent
5
5
  import android.os.Bundle
6
6
  import io.getstream.rn.callingx.CallingxModuleImpl
7
+ import io.getstream.rn.callingx.debugLog
7
8
 
8
9
  // For Android 12+
9
10
  class NotificationReceiverActivity : Activity() {
@@ -26,10 +27,20 @@ class NotificationReceiverActivity : Activity() {
26
27
  return
27
28
  }
28
29
 
29
- //we need it only for answered call event, as for cold start case we need to send broadcast event and to launch the app
30
30
  if (intent.action == CallingxModuleImpl.CALL_ANSWERED_ACTION) {
31
+ debugLog("[Callingx] NotificationReceiverActivity", "[receiver] answered call action")
31
32
  val callId = intent.getStringExtra(CallingxModuleImpl.EXTRA_CALL_ID)
32
33
  val source = intent.getStringExtra(CallingxModuleImpl.EXTRA_SOURCE)
34
+
35
+ if (callId != null) {
36
+ Intent(CallingxModuleImpl.CALL_OPTIMISTIC_ACCEPT_ACTION)
37
+ .apply {
38
+ setPackage(packageName)
39
+ putExtra(CallingxModuleImpl.EXTRA_CALL_ID, callId)
40
+ }
41
+ .also { sendBroadcast(it) }
42
+ }
43
+
33
44
  Intent(CallingxModuleImpl.CALL_ANSWERED_ACTION)
34
45
  .apply {
35
46
  setPackage(packageName)
@@ -34,6 +34,13 @@ class NotificationReceiverService : Service() {
34
34
  val source = intent.getStringExtra(CallingxModuleImpl.EXTRA_SOURCE)
35
35
  callId?.let {
36
36
  try {
37
+ Intent(CallingxModuleImpl.CALL_OPTIMISTIC_ACCEPT_ACTION)
38
+ .apply {
39
+ setPackage(packageName)
40
+ putExtra(CallingxModuleImpl.EXTRA_CALL_ID, it)
41
+ }
42
+ .also { it -> sendBroadcast(it) }
43
+
37
44
  NotificationIntentFactory.getPendingBroadcastIntent(
38
45
  applicationContext,
39
46
  CallingxModuleImpl.CALL_ANSWERED_ACTION,
@@ -29,7 +29,7 @@ abstract class CallRepository(protected val context: Context) {
29
29
  }
30
30
 
31
31
  interface Listener {
32
- fun onCallStateChanged(call: Call)
32
+ fun onCallStateChanged(callId: String, call: Call)
33
33
  fun onIsCallAnswered(callId: String, source: EventSource)
34
34
  fun onIsCallDisconnected(callId: String?, cause: DisconnectCause, source: EventSource)
35
35
  fun onIsCallInactive(callId: String)
@@ -39,8 +39,8 @@ abstract class CallRepository(protected val context: Context) {
39
39
  fun onCallEndpointChanged(callId: String, endpoint: String)
40
40
  }
41
41
 
42
- protected val _currentCall: MutableStateFlow<Call> = MutableStateFlow(Call.None)
43
- val currentCall: StateFlow<Call> = _currentCall.asStateFlow()
42
+ protected val _calls: MutableStateFlow<Map<String, Call.Registered>> = MutableStateFlow(emptyMap())
43
+ val calls: StateFlow<Map<String, Call.Registered>> = _calls.asStateFlow()
44
44
 
45
45
  protected var _listener: Listener? = null
46
46
  protected val scope: CoroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
@@ -65,9 +65,19 @@ abstract class CallRepository(protected val context: Context) {
65
65
  isVideo: Boolean,
66
66
  displayOptions: Bundle?,
67
67
  ) {
68
- updateCurrentCall { copy(displayOptions = displayOptions) }
68
+ updateCallById(callId) { copy(displayOptions = displayOptions) }
69
69
  }
70
70
 
71
+ fun getCall(callId: String): Call.Registered? = _calls.value[callId]
72
+
73
+ fun hasAnyCalls(): Boolean = _calls.value.isNotEmpty()
74
+
75
+ fun hasRingingCall(excludeCallId: String? = null): Boolean =
76
+ _calls.value.any { (id, c) -> id != excludeCallId && !c.isPending && c.isIncoming() && !c.isActive }
77
+
78
+ fun hasActiveCall(excludeCallId: String? = null): Boolean =
79
+ _calls.value.any { (id, c) -> id != excludeCallId && !c.isPending && c.isActive }
80
+
71
81
  //this call instance is used to display call notification before the call is registered, this is needed to invoke startForeground method on the service
72
82
  public fun getTempCall(callInfo: CallService.CallInfo, incoming: Boolean): Call.Registered {
73
83
  val attributes = createCallAttributes(
@@ -79,6 +89,7 @@ abstract class CallRepository(protected val context: Context) {
79
89
 
80
90
  return Call.Registered(
81
91
  id = callInfo.callId,
92
+ isPending = true,
82
93
  isActive = false,
83
94
  isOnHold = false,
84
95
  callAttributes = attributes,
@@ -92,34 +103,42 @@ abstract class CallRepository(protected val context: Context) {
92
103
  }
93
104
 
94
105
  /**
95
- * Update the current state of our call applying the transform lambda only if the call is
96
- * registered. Otherwise keep the current state
106
+ * Update the state of a specific call applying the transform lambda only if the call is
107
+ * found in the map. Otherwise keep the current state.
97
108
  */
98
- protected fun updateCurrentCall(transform: Call.Registered.() -> Call) {
99
- val currentState = _currentCall.value
100
- debugLog(
101
- getTag(),
102
- "[repository] updateCurrentCall: Current call state: ${currentState::class.simpleName}"
103
- )
104
-
105
- _currentCall.update { call ->
106
- if (call is Call.Registered) {
109
+ protected fun updateCallById(callId: String, transform: Call.Registered.() -> Call) {
110
+ _calls.update { currentMap ->
111
+ val call = currentMap[callId]
112
+ if (call != null) {
107
113
  val updated = call.transform()
108
114
  debugLog(
109
115
  getTag(),
110
- "[repository] updateCurrentCall: Call state updated to: ${updated::class.simpleName}"
116
+ "[repository] updateCallById: Call $callId state updated to: ${updated::class.simpleName}"
111
117
  )
112
- updated
118
+ if (updated is Call.Registered) {
119
+ currentMap + (callId to updated)
120
+ } else {
121
+ // Call transitioned to non-Registered state (e.g. Unregistered) — remove from map
122
+ currentMap - callId
123
+ }
113
124
  } else {
114
125
  Log.w(
115
126
  getTag(),
116
- "[repository] updateCurrentCall: Call is not Registered, skipping update"
127
+ "[repository] updateCallById: Call $callId not found in map, skipping update"
117
128
  )
118
- call
129
+ currentMap
119
130
  }
120
131
  }
121
132
  }
122
133
 
134
+ protected fun addCall(callId: String, call: Call.Registered) {
135
+ _calls.update { it + (callId to call) }
136
+ }
137
+
138
+ protected fun removeCall(callId: String) {
139
+ _calls.update { it - callId }
140
+ }
141
+
123
142
  protected fun createCallAttributes(
124
143
  displayName: String,
125
144
  address: Uri,