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

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 +176 -0
  5. package/android/src/main/java/io/getstream/rn/callingx/CallService.kt +302 -80
  6. package/android/src/main/java/io/getstream/rn/callingx/CallingxModuleImpl.kt +176 -191
  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 +196 -46
  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 +13 -10
  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 -13
  24. package/dist/module/utils/constants.js.map +1 -1
  25. package/dist/typescript/src/CallingxModule.d.ts +3 -0
  26. package/dist/typescript/src/CallingxModule.d.ts.map +1 -1
  27. package/dist/typescript/src/spec/NativeCallingx.d.ts +7 -1
  28. package/dist/typescript/src/spec/NativeCallingx.d.ts.map +1 -1
  29. package/dist/typescript/src/types.d.ts +31 -0
  30. package/dist/typescript/src/types.d.ts.map +1 -1
  31. package/dist/typescript/src/utils/constants.d.ts +1 -1
  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 +14 -10
  41. package/src/spec/NativeCallingx.ts +10 -3
  42. package/src/types.ts +34 -0
  43. package/src/utils/constants.ts +23 -9
  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
@@ -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,239 @@ 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
89
  val displaySubtitle: String?,
61
90
  val displayName: CharSequence,
62
91
  val address: Uri
63
92
  )
64
93
 
65
- private fun Call.Registered.toSnapshot() = NotificationSnapshot(
94
+ /**
95
+ * Creates a snapshot of the call state used to detect notification changes.
96
+ * @return NotificationSnapshot
97
+ */
98
+ private fun Call.Registered.toSnapshot(callId: String) = NotificationSnapshot(
66
99
  id = id,
67
100
  isActive = isActive,
68
101
  isIncoming = isIncoming(),
102
+ optimisticState = notificationsState[callId]?.optimisticState ?: OptimisticState.NONE,
69
103
  displayTitle = displayOptions?.getString(CallService.EXTRA_DISPLAY_TITLE),
70
104
  displaySubtitle = displayOptions?.getString(CallService.EXTRA_DISPLAY_SUBTITLE),
71
105
  displayName = callAttributes.displayName,
72
106
  address = callAttributes.address
73
107
  )
74
108
 
75
- fun createNotification(call: Call.Registered): Notification {
109
+ fun getOrCreateNotificationId(callId: String): Int = synchronized(lock) {
110
+ if (!notificationsState.containsKey(callId)) {
111
+ notificationsState[callId] = CallNotificationState()
112
+ }
113
+ if (foregroundCallId == null) {
114
+ foregroundCallId = callId
115
+ }
116
+ return@synchronized getNotificationId(callId)
117
+ }
118
+
119
+ /**
120
+ * Sets the optimistic state of the call notification.
121
+ * Optimistic state is used to update the notification text while the app is connecting or declining the call.
122
+ * @param state The optimistic state to set.
123
+ */
124
+ fun setOptimisticState(callId: String, state: OptimisticState) = synchronized(lock) {
125
+ // Be resilient to races where we receive optimistic actions before a notification state entry exists.
126
+ val current =
127
+ notificationsState[callId]
128
+ ?: CallNotificationState().also {
129
+ if (foregroundCallId == null) {
130
+ foregroundCallId = callId
131
+ }
132
+ }
133
+ notificationsState[callId] = if (state != OptimisticState.NONE) {
134
+ current.copy(optimisticState = state, lastSnapshot = null)
135
+ } else {
136
+ current.copy(optimisticState = state)
137
+ }
138
+ }
139
+
140
+ /**
141
+ * Creates a notification for the call.
142
+ * Notification is created based on the call state and optimistic state.
143
+ * @param call The call to create a notification for.
144
+ * @return The notification.
145
+ */
146
+ fun createNotification(callId: String, call: Call.Registered): Notification = synchronized(lock) {
76
147
  debugLog(TAG,"[notifications] createNotification: Creating notification for call ID: ${call.id}")
77
148
 
149
+ val state = notificationsState[callId]
150
+ val optimisticState = state?.optimisticState ?: OptimisticState.NONE
151
+
78
152
  val contentIntent =
79
153
  NotificationIntentFactory.getLaunchActivityIntent(
80
154
  context,
81
155
  CallingxModuleImpl.CALL_ANSWERED_ACTION,
82
156
  call.id
83
157
  )
84
- val callStyle = createCallStyle(call)
85
- val channelId = getChannelId(call)
158
+ val callStyle = createCallStyle(call, optimisticState)
159
+ val channelId = getChannelId(call, optimisticState)
86
160
  debugLog(TAG, "[notifications] createNotification: Channel ID: $channelId")
87
161
 
88
162
  val builder =
89
163
  NotificationCompat.Builder(context, channelId)
90
164
  .setContentIntent(contentIntent)
91
165
  .setFullScreenIntent(contentIntent, true)
92
- .setStyle(callStyle)
93
166
  .setSmallIcon(R.drawable.ic_round_call_24)
94
167
  .setCategory(NotificationCompat.CATEGORY_CALL)
95
168
  .setPriority(NotificationCompat.PRIORITY_MAX)
96
- .setOngoing(true)
169
+ .setOngoing(optimisticState != OptimisticState.REJECTING)
170
+
171
+ builder.setStyle(callStyle)
97
172
 
98
- if (!hasBecameActive && call.isActive) {
99
- debugLog(TAG, "[notifications] createNotification: Setting when to current time")
100
- builder.setWhen(System.currentTimeMillis())
173
+ // When call becomes active we need to set the when to current time and show the chronometer
174
+ if (call.isActive && optimisticState == OptimisticState.NONE && state != null) {
175
+ // We need to set the activation time once when call becomes active
176
+ if (!state.hasBecameActive) {
177
+ debugLog(TAG, "[notifications] createNotification: Setting when to current time for $callId")
178
+ val now = System.currentTimeMillis()
179
+ notificationsState[callId] = state.copy(activeWhen = now, hasBecameActive = true)
180
+ }
181
+ builder.setWhen(notificationsState[callId]?.activeWhen ?: System.currentTimeMillis())
101
182
  builder.setUsesChronometer(true)
102
183
  builder.setShowWhen(true)
103
- hasBecameActive = true
104
184
  }
105
185
 
106
- call.displayOptions?.let {
107
- if (it.containsKey(CallService.EXTRA_DISPLAY_SUBTITLE)) {
108
- builder.setContentText(it.getString(CallService.EXTRA_DISPLAY_SUBTITLE))
186
+ // If the call is not active and the optimistic state is not none, we need to set the notification text
187
+ // based on exact action that is being taken (accepting or rejecting)
188
+ if (optimisticState != OptimisticState.NONE && !call.isActive) {
189
+ val text = when (optimisticState) {
190
+ OptimisticState.ACCEPTING -> SettingsStore.getOptimisticAcceptingText(context)
191
+ OptimisticState.REJECTING -> SettingsStore.getOptimisticRejectingText(context)
192
+ else -> null
193
+ }
194
+ if (text != null) builder.setContentText(text)
195
+ } else {
196
+ // If the call is active, we need to set the notification text
197
+ // based on the call display options (defined on js side)
198
+ call.displayOptions?.let {
199
+ if (it.containsKey(CallService.EXTRA_DISPLAY_SUBTITLE)) {
200
+ builder.setContentText(it.getString(CallService.EXTRA_DISPLAY_SUBTITLE))
201
+ }
109
202
  }
110
203
  }
111
204
 
112
205
  return builder.build()
113
206
  }
114
207
 
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
- }
208
+ /**
209
+ * Updates the call notification.
210
+ * If the call is None or Unregistered, we need to dismiss the notification.
211
+ * If the call is active and the optimistic state is not none, we need to reset the optimistic state.
212
+ * If the call is active and the optimistic state is none, we need to create a new notification.
213
+ * @param call The call to update the notification for.
214
+ */
215
+ fun updateCallNotification(callId: String, call: Call.Registered) = synchronized(lock) {
216
+ val state = notificationsState[callId]
217
+ val optimisticState = state?.optimisticState ?: OptimisticState.NONE
218
+ if (call.isActive && optimisticState != OptimisticState.NONE && state != null) {
219
+ notificationsState[callId] = state.copy(optimisticState = OptimisticState.NONE)
220
+ debugLog(TAG, "[notifications] updateCallNotification[$callId]: Resetting optimistic state")
221
+ }
222
+
223
+ // If the new snapshot is the same as the last posted snapshot, we need to skip the update
224
+ val newSnapshot = call.toSnapshot(callId)
225
+ if (newSnapshot == notificationsState[callId]?.lastSnapshot) {
226
+ debugLog(TAG, "[notifications] updateCallNotification[$callId]: Skipping - no state change")
227
+ return@synchronized
228
+ }
229
+
230
+ val notificationId = getOrCreateNotificationId(callId)
231
+ notificationsState[callId] =
232
+ notificationsState[callId]?.copy(lastSnapshot = newSnapshot)
233
+ ?: CallNotificationState(lastSnapshot = newSnapshot)
234
+ val notification = createNotification(callId, call)
235
+ notificationManager.notify(notificationId, notification)
236
+ debugLog(TAG, "[notifications] updateCallNotification[$callId]: Notification posted (id=$notificationId)")
237
+ }
238
+
239
+ fun postNotification(callId: String, notification: Notification) = synchronized(lock) {
240
+ val notificationId = getOrCreateNotificationId(callId)
241
+ notificationManager.notify(notificationId, notification)
242
+ }
129
243
 
130
- lastPostedSnapshot = newSnapshot
131
- val notification = createNotification(call)
132
- notificationManager.notify(NOTIFICATION_ID, notification)
133
- debugLog(TAG, "[notifications] updateCallNotification: Notification posted successfully")
244
+ /**
245
+ * Returns a new foreground notification ID if the caller needs to call startForeground()
246
+ * to re-promote the service, or null if no action is needed.
247
+ */
248
+ fun cancelNotification(callId: String): Int? = synchronized(lock) {
249
+ debugLog(TAG, "[notifications] cancelNotification[$callId]")
250
+ val state = notificationsState.remove(callId)
251
+ val notificationId = getNotificationId(callId)
252
+ notificationManager.cancel(notificationId)
253
+ if (state != null) {
254
+ debugLog(TAG, "[notifications] cancelNotification[$callId]: Cancelled (id=$notificationId)")
255
+ }
256
+
257
+ if (foregroundCallId == callId) {
258
+ foregroundCallId = notificationsState.keys.firstOrNull()
259
+ // Return the new foreground notification ID so the service can re-promote
260
+ if (foregroundCallId != null) {
261
+ return@synchronized getNotificationId(foregroundCallId!!)
134
262
  }
135
263
  }
264
+ return@synchronized null
136
265
  }
137
266
 
138
- fun cancelNotifications() {
139
- notificationManager.cancel(NOTIFICATION_ID)
140
- hasBecameActive = false
141
- lastPostedSnapshot = null
267
+ fun getForegroundCallId(): String? = synchronized(lock) { foregroundCallId }
268
+
269
+ fun cancelAllNotifications() = synchronized(lock) {
270
+ for (callId in notificationsState.keys) {
271
+ notificationManager.cancel(getNotificationId(callId))
272
+ }
273
+ notificationsState.clear()
274
+ foregroundCallId = null
142
275
  }
143
276
 
144
277
  fun startRingtone() {
@@ -174,18 +307,24 @@ class CallNotificationManager(
174
307
  ringtone = null
175
308
  }
176
309
 
177
- private fun getChannelId(call: Call.Registered): String {
178
- return if (call.isIncoming() && !call.isActive) {
310
+ fun resetOptimisticState(callId: String) = synchronized(lock) {
311
+ debugLog(TAG, "[notifications] resetOptimisticState[$callId]: Resetting optimistic state")
312
+ val current = notificationsState[callId] ?: return@synchronized
313
+ notificationsState[callId] = current.copy(optimisticState = OptimisticState.NONE, lastSnapshot = null)
314
+ }
315
+
316
+ private fun getChannelId(call: Call.Registered, optimisticState: OptimisticState): String {
317
+ return if (call.isIncoming() && !call.isActive && optimisticState == OptimisticState.NONE) {
179
318
  notificationsConfig.incomingChannel.id
180
319
  } else {
181
320
  notificationsConfig.ongoingChannel.id
182
321
  }
183
322
  }
184
323
 
185
- private fun createCallStyle(call: Call.Registered): NotificationCompat.CallStyle {
324
+ private fun createCallStyle(call: Call.Registered, optimisticState: OptimisticState): NotificationCompat.CallStyle? {
186
325
  val caller = createPerson(call)
187
326
 
188
- if (call.isIncoming() && !call.isActive) {
327
+ if (call.isIncoming() && !call.isActive && optimisticState == OptimisticState.NONE) {
189
328
  return NotificationCompat.CallStyle.forIncomingCall(
190
329
  caller,
191
330
  NotificationIntentFactory.getPendingBroadcastIntent(
@@ -211,6 +350,17 @@ class CallNotificationManager(
211
350
  )
212
351
  }
213
352
 
353
+ if (optimisticState == OptimisticState.REJECTING) {
354
+ return NotificationCompat.CallStyle.forOngoingCall(
355
+ caller,
356
+ NotificationIntentFactory.getPendingBroadcastIntent(
357
+ context,
358
+ "io.getstream.CALL_END_NOOP",
359
+ call.id
360
+ ) ,
361
+ ).setDeclineButtonColorHint(DISABLED_COLOR.toColorInt())
362
+ }
363
+
214
364
  return NotificationCompat.CallStyle.forOngoingCall(
215
365
  caller,
216
366
  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,