@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
@@ -2,7 +2,10 @@ package io.getstream.rn.callingx
2
2
 
3
3
  import android.app.Notification
4
4
  import android.app.Service
5
+ import android.content.BroadcastReceiver
6
+ import android.content.Context
5
7
  import android.content.Intent
8
+ import android.content.IntentFilter
6
9
  import android.content.pm.ServiceInfo
7
10
  import android.net.Uri
8
11
  import android.os.Binder
@@ -11,11 +14,16 @@ import android.os.Bundle
11
14
  import android.os.IBinder
12
15
  import android.telecom.DisconnectCause
13
16
  import android.util.Log
17
+ import androidx.core.content.ContextCompat
18
+ import androidx.core.net.toUri
14
19
  import io.getstream.rn.callingx.model.Call
15
20
  import io.getstream.rn.callingx.model.CallAction
16
21
  import io.getstream.rn.callingx.notifications.CallNotificationManager
22
+ import io.getstream.rn.callingx.notifications.NotificationChannelsManager
23
+ import io.getstream.rn.callingx.notifications.NotificationsConfig
17
24
  import io.getstream.rn.callingx.repo.CallRepository
18
25
  import io.getstream.rn.callingx.repo.CallRepositoryFactory
26
+ import io.getstream.rn.callingx.utils.SettingsStore
19
27
  import kotlinx.coroutines.CoroutineScope
20
28
  import kotlinx.coroutines.SupervisorJob
21
29
  import kotlinx.coroutines.cancel
@@ -45,8 +53,8 @@ class CallService : Service(), CallRepository.Listener {
45
53
  internal const val EXTRA_URI = "extra_uri"
46
54
  internal const val EXTRA_IS_VIDEO = "extra_is_video"
47
55
  internal const val EXTRA_DISPLAY_TITLE = "displayTitle"
48
- internal const val EXTRA_DISPLAY_SUBTITLE = "displaySubtitle"
49
56
  internal const val EXTRA_DISPLAY_OPTIONS = "display_options"
57
+ internal const val EXTRA_ACTION = "action_name"
50
58
  // Background task extras
51
59
  internal const val EXTRA_TASK_NAME = "task_name"
52
60
  internal const val EXTRA_TASK_DATA = "task_data"
@@ -58,7 +66,61 @@ class CallService : Service(), CallRepository.Listener {
58
66
  internal const val ACTION_START_BACKGROUND_TASK = "start_background_task"
59
67
  internal const val ACTION_STOP_BACKGROUND_TASK = "stop_background_task"
60
68
  internal const val ACTION_STOP_SERVICE = "stop_service"
69
+ internal const val ACTION_PROCESS_ACTION = "execute_action"
61
70
  internal const val ACTION_REGISTRATION_FAILED = "registration_failed"
71
+
72
+ fun startIncomingCallFromPush(context: Context, data: Map<String, String>) {
73
+ debugLog(TAG, "[service] startIncomingCallFromPush: Starting incoming call from push")
74
+
75
+ // Check if we are allowed to post call notifications (moved from JS layer).
76
+ val notificationsConfig = NotificationsConfig.loadNotificationsConfig(context)
77
+ val notificationChannelsManager =
78
+ NotificationChannelsManager(context).apply {
79
+ setNotificationsConfig(notificationsConfig)
80
+ }
81
+ val notificationStatus = notificationChannelsManager.getNotificationStatus()
82
+ if (!notificationStatus.canPost) {
83
+ debugLog(
84
+ TAG,
85
+ "[service] startIncomingCallFromPush: Cannot post notifications, skipping incoming call"
86
+ )
87
+ return
88
+ }
89
+
90
+ val shouldRejectCallWhenBusy = SettingsStore.shouldRejectCallWhenBusy(context)
91
+ if (shouldRejectCallWhenBusy && CallRegistrationStore.hasRegisteredCall()) {
92
+ debugLog(
93
+ TAG,
94
+ "[service] startIncomingCallFromPush: Registered call found and rejectCallWhenBusy is enabled, skipping incoming call"
95
+ )
96
+ return
97
+ }
98
+
99
+ val callCid = data["call_cid"]
100
+ if (callCid.isNullOrEmpty()) {
101
+ debugLog(
102
+ TAG,
103
+ "[service] startIncomingCallFromPush: Call CID is null or empty, skipping"
104
+ )
105
+ return
106
+ }
107
+
108
+ val callName = data["created_by_display_name"].orEmpty()
109
+ val isVideo = data["video"] == "true"
110
+
111
+ CallRegistrationStore.trackCallRegistration(callCid, null)
112
+
113
+ val intent =
114
+ Intent(context, CallService::class.java).apply {
115
+ action = ACTION_INCOMING_CALL
116
+ putExtra(EXTRA_CALL_ID, callCid)
117
+ putExtra(EXTRA_URI, callCid.toUri())
118
+ putExtra(EXTRA_NAME, callName)
119
+ putExtra(EXTRA_IS_VIDEO, isVideo)
120
+ }
121
+
122
+ ContextCompat.startForegroundService(context, intent)
123
+ }
62
124
  }
63
125
 
64
126
  inner class CallServiceBinder : Binder() {
@@ -71,9 +133,72 @@ class CallService : Service(), CallRepository.Listener {
71
133
 
72
134
  private val binder = CallServiceBinder()
73
135
  private val scope: CoroutineScope = CoroutineScope(SupervisorJob())
136
+ private val actionProcessingLock = Object()
74
137
 
75
138
  private var isInForeground = false
76
139
 
140
+ private val optimisticNotificationReceiver =
141
+ object : BroadcastReceiver() {
142
+ override fun onReceive(context: Context, intent: Intent) {
143
+ val callId = intent.getStringExtra(CallingxModuleImpl.EXTRA_CALL_ID) ?: return
144
+ when (intent.action) {
145
+ CallingxModuleImpl.CALL_OPTIMISTIC_ACCEPT_ACTION -> {
146
+ debugLog(
147
+ TAG,
148
+ "[service] optimisticReceiver: Optimistic accept for $callId"
149
+ )
150
+ notificationManager.stopRingtone()
151
+ notificationManager.setOptimisticState(
152
+ callId,
153
+ CallNotificationManager.OptimisticState.ACCEPTING
154
+ )
155
+ val call = callRepository.getCall(callId)
156
+ if (call != null) {
157
+ notificationManager.updateCallNotification(callId, call)
158
+ }
159
+ }
160
+ CallingxModuleImpl.CALL_END_ACTION -> {
161
+ val source = intent.getStringExtra(CallingxModuleImpl.EXTRA_SOURCE)
162
+ val cause =
163
+ intent.getStringExtra(CallingxModuleImpl.EXTRA_DISCONNECT_CAUSE)
164
+ val rejectedCause =
165
+ getDisconnectCauseString(
166
+ DisconnectCause(DisconnectCause.REJECTED)
167
+ )
168
+ val call = callRepository.getCall(callId)
169
+
170
+ val isSysSource =
171
+ source == CallRepository.EventSource.SYS.name.lowercase()
172
+
173
+ // we handle optimistic updates only if incoming call (non-answered) was rejected within notification action
174
+ if (!isSysSource ||
175
+ cause != rejectedCause ||
176
+ call == null ||
177
+ !call.isIncoming() ||
178
+ call.isActive
179
+ ) {
180
+ debugLog(
181
+ TAG,
182
+ "[service] optimisticReceiver: Skipping optimistic reject for $callId"
183
+ )
184
+ return
185
+ }
186
+
187
+ debugLog(
188
+ TAG,
189
+ "[service] optimisticReceiver: Optimistic reject for $callId"
190
+ )
191
+ notificationManager.stopRingtone()
192
+ notificationManager.setOptimisticState(
193
+ callId,
194
+ CallNotificationManager.OptimisticState.REJECTING
195
+ )
196
+ notificationManager.updateCallNotification(callId, call)
197
+ }
198
+ }
199
+ }
200
+ }
201
+
77
202
  override fun onCreate() {
78
203
  super.onCreate()
79
204
  debugLog(TAG, "[service] onCreate: TelecomCallService created")
@@ -83,14 +208,26 @@ class CallService : Service(), CallRepository.Listener {
83
208
  callRepository = CallRepositoryFactory.create(applicationContext)
84
209
  callRepository.setListener(this)
85
210
 
86
- sendBroadcastEvent(CallingxModuleImpl.SERVICE_READY_ACTION)
211
+ val filter =
212
+ IntentFilter().apply {
213
+ addAction(CallingxModuleImpl.CALL_OPTIMISTIC_ACCEPT_ACTION)
214
+ addAction(CallingxModuleImpl.CALL_END_ACTION)
215
+ }
216
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
217
+ registerReceiver(optimisticNotificationReceiver, filter, Context.RECEIVER_NOT_EXPORTED)
218
+ } else {
219
+ @Suppress("UnspecifiedRegisterReceiverFlag")
220
+ registerReceiver(optimisticNotificationReceiver, filter)
221
+ }
87
222
  }
88
223
 
89
224
  override fun onDestroy() {
90
225
  super.onDestroy()
91
226
  debugLog(TAG, "[service] onDestroy: TelecomCallService destroyed")
92
227
 
93
- notificationManager.cancelNotifications()
228
+ unregisterReceiver(optimisticNotificationReceiver)
229
+
230
+ notificationManager.cancelAllNotifications()
94
231
  notificationManager.stopRingtone()
95
232
  callRepository.release()
96
233
  headlessJSManager.release()
@@ -124,23 +261,28 @@ class CallService : Service(), CallRepository.Listener {
124
261
  registerCall(intent, false)
125
262
  }
126
263
  ACTION_START_BACKGROUND_TASK -> {
127
- if (!isInForeground) {
128
- debugLog(TAG, "[service] onStartCommand: Starting foreground for background task")
129
- // for now bg task is intended to be used after a call registered and
130
- // notification is shown, so we don't need to show a separate notification for
131
- // bg task
132
- // startForeground(CallNotificationManager.NOTIFICATION_ID, notification)
133
- // isInForeground = true
134
- }
135
-
136
264
  startBackgroundTask(intent)
265
+ return START_NOT_STICKY
137
266
  }
138
267
  ACTION_STOP_BACKGROUND_TASK -> {
139
268
  stopBackgroundTask()
269
+ return START_NOT_STICKY
140
270
  }
141
271
  ACTION_UPDATE_CALL -> {
142
272
  updateCall(intent)
143
273
  }
274
+ ACTION_PROCESS_ACTION -> {
275
+ processAction(intent)
276
+ }
277
+ ACTION_STOP_SERVICE -> {
278
+ if (isInForeground) {
279
+ stopForeground(STOP_FOREGROUND_REMOVE)
280
+ isInForeground = false
281
+ }
282
+ notificationManager.cancelAllNotifications()
283
+ notificationManager.stopRingtone()
284
+ stopSelf()
285
+ }
144
286
  else -> {
145
287
  Log.e(TAG, "[service] onStartCommand: Unknown action: ${intent.action}")
146
288
  stopSelf()
@@ -158,51 +300,61 @@ class CallService : Service(), CallRepository.Listener {
158
300
  return super.onUnbind(intent)
159
301
  }
160
302
 
161
- override fun onCallStateChanged(call: Call) {
162
- debugLog(TAG, "[service] onCallStateChanged: Call state changed: ${call::class.simpleName}")
303
+ override fun onCallStateChanged(callId: String, call: Call) {
304
+ debugLog(
305
+ TAG,
306
+ "[service] onCallStateChanged[$callId]: Call state changed: ${call::class.simpleName}"
307
+ )
163
308
  when (call) {
164
309
  is Call.Registered -> {
165
310
  debugLog(
166
311
  TAG,
167
- "[service] updateServiceState: Call registered - Active: ${call.isActive}, OnHold: ${call.isOnHold}, Muted: ${call.isMuted}"
312
+ "[service] onCallStateChanged[$callId]: Call registered - Active: ${call.isActive}, OnHold: ${call.isOnHold}, Muted: ${call.isMuted}"
168
313
  )
169
314
 
315
+ val shouldStopExecution = processPendingActions(call)
316
+ if (shouldStopExecution) {
317
+ return
318
+ }
319
+
170
320
  if (call.isIncoming()) {
171
- if (!call.isActive) notificationManager.startRingtone()
172
- else notificationManager.stopRingtone()
321
+ // Play ringtone only if there is no active call
322
+ if (!call.isActive && !callRepository.hasActiveCall(excludeCallId = callId)) {
323
+ notificationManager.startRingtone()
324
+ } else {
325
+ notificationManager.stopRingtone()
326
+ }
173
327
  }
174
- // Update the call state.
328
+ // Update the call notification
329
+ val notificationId = notificationManager.getOrCreateNotificationId(callId)
175
330
  if (isInForeground) {
176
- notificationManager.updateCallNotification(call)
331
+ notificationManager.updateCallNotification(callId, call)
177
332
  } else {
178
333
  debugLog(
179
334
  TAG,
180
- "[service] updateServiceState: Fallback starting foreground for call: ${call.id}"
335
+ "[service] onCallStateChanged[$callId]: Starting foreground for call"
181
336
  )
182
- //fallback if for some reason startForeground method is not called in onStartCommand method
183
- val notification = notificationManager.createNotification(call)
184
- startForegroundSafely(notification)
337
+ notificationManager.resetOptimisticState(callId)
338
+ val notification = notificationManager.createNotification(callId, call)
339
+ startForegroundSafely(notificationId, notification)
185
340
  }
186
341
  }
187
- is Call.Unregistered -> {
188
- notificationManager.updateCallNotification(call)
189
-
190
- if (isInForeground) {
191
- stopForeground(STOP_FOREGROUND_REMOVE)
192
- isInForeground = false
193
- }
342
+ is Call.None, is Call.Unregistered -> {
343
+ repromoteForegroundIfNeeded(callId)
344
+ if (!callRepository.hasRingingCall()) notificationManager.stopRingtone()
194
345
 
195
- notificationManager.stopRingtone()
196
- stopSelf()
197
- }
198
- is Call.None -> {
199
- notificationManager.updateCallNotification(call)
200
-
201
- if (isInForeground) {
202
- stopForeground(STOP_FOREGROUND_REMOVE)
203
- isInForeground = false
346
+ // Stop service only when no calls remain
347
+ if (!callRepository.hasAnyCalls()) {
348
+ debugLog(
349
+ TAG,
350
+ "[service] onCallStateChanged[$callId]: No more calls, stopping service"
351
+ )
352
+ if (isInForeground) {
353
+ stopForeground(STOP_FOREGROUND_REMOVE)
354
+ isInForeground = false
355
+ }
356
+ stopSelf()
204
357
  }
205
- notificationManager.stopRingtone()
206
358
  }
207
359
  }
208
360
  }
@@ -219,8 +371,6 @@ class CallService : Service(), CallRepository.Listener {
219
371
  cause: DisconnectCause,
220
372
  source: CallRepository.EventSource
221
373
  ) {
222
- // we're not passing the callId here to prevent infinite loops
223
- // callEnd event with callId will sent only when after interaction with notification buttons
224
374
  sendBroadcastEvent(CallingxModuleImpl.CALL_END_ACTION) {
225
375
  if (callId != null) {
226
376
  putExtra(CallingxModuleImpl.EXTRA_CALL_ID, callId)
@@ -268,43 +418,61 @@ class CallService : Service(), CallRepository.Listener {
268
418
  }
269
419
  }
270
420
 
271
- public fun hasRegisteredCall(): Boolean {
272
- val currentCall = callRepository.currentCall.value
273
- return currentCall is Call.Registered
421
+ fun processAction(intent: Intent) {
422
+ val callId = intent.getStringExtra(EXTRA_CALL_ID) ?: return
423
+ val action = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
424
+ intent.getParcelableExtra(EXTRA_ACTION, CallAction::class.java)
425
+ } else {
426
+ @Suppress("DEPRECATION") intent.getParcelableExtra(EXTRA_ACTION)
427
+ } ?: return
428
+
429
+ processAction(callId, action)
274
430
  }
275
431
 
276
- public fun processAction(callId: String, action: CallAction) {
277
- debugLog(TAG, "[service] processAction: Processing action: ${action::class.simpleName}")
278
- val currentCall = callRepository.currentCall.value
279
- if (currentCall is Call.Registered && currentCall.id == callId) {
280
- currentCall.processAction(action)
281
- } else {
282
- Log.e(
283
- TAG,
284
- "[service] processAction: Call not registered or not the current call, ignoring action"
285
- )
432
+ fun processAction(callId: String, action: CallAction) {
433
+ debugLog(
434
+ TAG,
435
+ "[service] processAction[$callId]: Processing action: ${action::class.simpleName}"
436
+ )
437
+ synchronized(actionProcessingLock) {
438
+ val call = callRepository.getCall(callId)
439
+ if (call != null && !call.isPending) {
440
+ call.processAction(action)
441
+ } else {
442
+ // this solves race condition, when action is requested before the call is
443
+ // registered in Telecom
444
+ debugLog(
445
+ TAG,
446
+ "[service] processAction: Add pending action for ${call?.id} to queue"
447
+ )
448
+ CallRegistrationStore.addPendingAction(callId, action)
449
+ }
286
450
  }
287
451
  }
288
452
 
289
- public fun startBackgroundTask(intent: Intent) {
453
+ fun startBackgroundTask(intent: Intent) {
290
454
  val taskName = intent.getStringExtra(EXTRA_TASK_NAME)!!
291
455
  val data = intent.getBundleExtra(EXTRA_TASK_DATA)!!
292
456
  val timeout = intent.getLongExtra(EXTRA_TASK_TIMEOUT, 0)
293
457
  headlessJSManager.startHeadlessTask(taskName, data, timeout)
294
458
  }
295
459
 
296
- public fun stopBackgroundTask() {
460
+ fun stopBackgroundTask() {
297
461
  headlessJSManager.stopHeadlessTask()
298
462
  }
299
463
 
300
464
  private fun registerCall(intent: Intent, incoming: Boolean) {
301
465
  debugLog(TAG, "[service] registerCall: ${if (incoming) "in" else "out"} call")
466
+
302
467
  val callInfo = extractIntentParams(intent)
303
468
 
304
- // If we have an ongoing call, notify the module that registration is
305
- // already done (so the pending promise resolves) and skip re-registration.
306
- if (callRepository.currentCall.value is Call.Registered) {
307
- Log.w(TAG, "[service] registerCall: Call already registered, ignoring new call request")
469
+ // If this specific call is already registered, just notify
470
+ val existingCall = callRepository.getCall(callInfo.callId)
471
+ if (existingCall != null) {
472
+ Log.w(
473
+ TAG,
474
+ "[service] registerCall: Call ${callInfo.callId} already registered, notifying"
475
+ )
308
476
  if (incoming) {
309
477
  sendBroadcastEvent(CallingxModuleImpl.CALL_REGISTERED_INCOMING_ACTION) {
310
478
  putExtra(CallingxModuleImpl.EXTRA_CALL_ID, callInfo.callId)
@@ -316,17 +484,8 @@ class CallService : Service(), CallRepository.Listener {
316
484
  }
317
485
  return
318
486
  }
319
- val tempCall = callRepository.getTempCall(callInfo, incoming)
320
487
 
321
- //it is better to invoke startForeground method synchronously inside onStartCommand method
322
- if (!isInForeground) {
323
- debugLog(
324
- TAG,
325
- "[service] registerCall: Starting foreground for call: ${callInfo.callId}"
326
- )
327
- val notification = notificationManager.createNotification(tempCall)
328
- startForegroundSafely(notification)
329
- }
488
+ startForegroundForCall(callInfo, incoming)
330
489
 
331
490
  scope.launch {
332
491
  try {
@@ -345,34 +504,59 @@ class CallService : Service(), CallRepository.Listener {
345
504
  putExtra(CallingxModuleImpl.EXTRA_CALL_ID, callInfo.callId)
346
505
  }
347
506
 
348
- if (isInForeground) {
349
- stopForeground(STOP_FOREGROUND_REMOVE)
350
- isInForeground = false
507
+ repromoteForegroundIfNeeded(callInfo.callId)
508
+
509
+ // Only stop foreground/service when no other calls remain
510
+ if (!callRepository.hasAnyCalls()) {
511
+ if (isInForeground) {
512
+ stopForeground(STOP_FOREGROUND_REMOVE)
513
+ isInForeground = false
514
+ }
515
+ notificationManager.stopRingtone()
516
+ stopSelf()
351
517
  }
518
+ }
519
+ }
520
+ }
352
521
 
353
- notificationManager.cancelNotifications()
354
- notificationManager.stopRingtone()
355
- stopSelf()
522
+ private fun processPendingActions(call: Call.Registered): Boolean {
523
+ synchronized(actionProcessingLock) {
524
+ val pendingActions = CallRegistrationStore.takePendingActions(call.id)
525
+
526
+ val disconnectAction = pendingActions.find { it is CallAction.Disconnect }
527
+ if (disconnectAction != null) {
528
+ // if queue contains Disconnect, execute it and ignore rest of the queue
529
+ debugLog(TAG, "[service] processPendingActions: Executing pending disconnect for ${call.id}")
530
+ call.processAction(disconnectAction)
531
+ return true
532
+ }
533
+
534
+ // process pending actions in the order they were added
535
+ for (action in pendingActions) {
536
+ call.processAction(action)
537
+ debugLog(
538
+ TAG,
539
+ "[service] processPendingActions: Executing pending action: $action for ${call.id}"
540
+ )
356
541
  }
542
+
543
+ return false
357
544
  }
358
545
  }
359
546
 
360
- private fun startForegroundSafely(notification: Notification) {
547
+ private fun startForegroundSafely(notificationId: Int, notification: Notification) {
361
548
  try {
362
549
  if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
363
550
  startForeground(
364
- CallNotificationManager.NOTIFICATION_ID,
551
+ notificationId,
365
552
  notification,
366
553
  ServiceInfo.FOREGROUND_SERVICE_TYPE_PHONE_CALL
367
554
  )
368
555
  } else {
369
- startForeground(CallNotificationManager.NOTIFICATION_ID, notification)
556
+ startForeground(notificationId, notification)
370
557
  }
371
558
  isInForeground = true
372
559
  } catch (e: Exception) {
373
- // If starting the foreground service fails (for example due to background start
374
- // restrictions or notification issues), we log the error but avoid crashing the
375
- // process so the rest of the call flow can continue and be recovered by Telecom.
376
560
  Log.e(
377
561
  TAG,
378
562
  "[service] startForegroundSafely: Failed to start foreground service: ${e.message}",
@@ -381,6 +565,40 @@ class CallService : Service(), CallRepository.Listener {
381
565
  }
382
566
  }
383
567
 
568
+ /**
569
+ * Cancels the notification for [callId]. If that notification was the foreground one
570
+ * and other calls remain, re-promotes the service with the next call's notification.
571
+ */
572
+ private fun repromoteForegroundIfNeeded(callId: String) {
573
+ val newForegroundNotificationId = notificationManager.cancelNotification(callId)
574
+ if (newForegroundNotificationId != null && isInForeground) {
575
+ val newForegroundCallId = notificationManager.getForegroundCallId()
576
+ val call = if (newForegroundCallId != null) callRepository.getCall(newForegroundCallId) else null
577
+ if (call != null && newForegroundCallId != null) {
578
+ debugLog(TAG, "[service] repromoteForegroundIfNeeded: Re-promoting with call $newForegroundCallId (notificationId=$newForegroundNotificationId)")
579
+ val notification = notificationManager.createNotification(newForegroundCallId, call)
580
+ startForegroundSafely(newForegroundNotificationId, notification)
581
+ }
582
+ }
583
+ }
584
+
585
+ private fun startForegroundForCall(callInfo: CallInfo, incoming: Boolean) {
586
+ val tempCall = callRepository.getTempCall(callInfo, incoming)
587
+ val notificationId = notificationManager.getOrCreateNotificationId(callInfo.callId)
588
+ if (!isInForeground) {
589
+ debugLog(
590
+ TAG,
591
+ "[service] registerCall: Starting foreground for call: ${callInfo.callId}"
592
+ )
593
+ val notification = notificationManager.createNotification(callInfo.callId, tempCall)
594
+ startForegroundSafely(notificationId, notification)
595
+ } else {
596
+ // Already in foreground from another call — just post the notification
597
+ val notification = notificationManager.createNotification(callInfo.callId, tempCall)
598
+ notificationManager.postNotification(callInfo.callId, notification)
599
+ }
600
+ }
601
+
384
602
  private fun updateCall(intent: Intent) {
385
603
  val callInfo = extractIntentParams(intent)
386
604
  callRepository.updateCall(