Skip to content

Commit ecf471a

Browse files
Tarek Loubanirapterjet2004
authored andcommitted
Implement persistent foreground service to keep calls active in background, with notification controls for managing the call
- Add CallForegroundService with persistent notification - Support calls in background without requiring picture-in-picture mode - Add "Return to call" and "End call" action buttons to CallForegroundService notification with corresponding PendingIntent - Handle proper foreground service types for microphone/camera permissions - Add notification permission and fallback messaging. - Add EndCallReceiver to handle end call broadcasts from notification action - Use existing ic_baseline_close_24 drawable for end call action icon - Register broadcast receiver in CallActivity to handle end call requests from notification using ReceiverFlag.NotExported for Android 14+ compatibility - Add proper cleanup flow: notification action → EndCallReceiver → CallActivity → proper hangup sequence - Track intentional call leaving to prevent unwanted service restarts - Release proximity sensor lock properly during notification-triggered hangup - Add diagnostic logging throughout the end call flow for debugging Signed-off-by: Tarek Loubani <tarek@tarek.org> - refactoring - linter Signed-off-by: rapterjet2004 <juliuslinus1@gmail.com>
1 parent e93566a commit ecf471a

7 files changed

Lines changed: 250 additions & 23 deletions

File tree

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -86,5 +86,6 @@ freeline_project_description.json
8686

8787
# python
8888
**/__pycache__/
89+
.vscode/
8990

9091
/gradle/verification-keyring.gpg

app/src/main/AndroidManifest.xml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -295,6 +295,7 @@
295295
<receiver android:name=".receivers.MarkAsReadReceiver" />
296296
<receiver android:name=".receivers.DismissRecordingAvailableReceiver" />
297297
<receiver android:name=".receivers.ShareRecordingToChatReceiver" />
298+
<receiver android:name=".receivers.EndCallReceiver" />
298299

299300
<service
300301
android:name=".utils.SyncService"

app/src/main/java/com/nextcloud/talk/activities/CallActivity.kt

Lines changed: 152 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -94,6 +94,7 @@ import com.nextcloud.talk.models.json.signaling.settings.SignalingSettingsOveral
9494
import com.nextcloud.talk.raisehand.viewmodel.RaiseHandViewModel
9595
import com.nextcloud.talk.raisehand.viewmodel.RaiseHandViewModel.LoweredHandState
9696
import com.nextcloud.talk.raisehand.viewmodel.RaiseHandViewModel.RaisedHandState
97+
import com.nextcloud.talk.receivers.EndCallReceiver.Companion.END_CALL_FROM_NOTIFICATION
9798
import com.nextcloud.talk.services.CallForegroundService
9899
import com.nextcloud.talk.signaling.SignalingMessageReceiver
99100
import com.nextcloud.talk.signaling.SignalingMessageReceiver.CallParticipantMessageListener
@@ -185,7 +186,6 @@ import java.util.Objects
185186
import java.util.concurrent.TimeUnit
186187
import java.util.concurrent.atomic.AtomicInteger
187188
import javax.inject.Inject
188-
import kotlin.String
189189
import kotlin.math.abs
190190

191191
@AutoInjector(NextcloudTalkApplication::class)
@@ -248,6 +248,9 @@ class CallActivity : CallBaseActivity() {
248248

249249
private val callTimeHandler = Handler(Looper.getMainLooper())
250250

251+
// Track if we're intentionally leaving the call
252+
private var isIntentionallyLeavingCall = false
253+
251254
// push to talk
252255
private var isPushToTalkActive = false
253256
private var pulseAnimation: PulseAnimation? = null
@@ -264,6 +267,16 @@ class CallActivity : CallBaseActivity() {
264267
private val callParticipantMessageListeners: MutableMap<String?, CallParticipantMessageListener> = HashMap()
265268
private val selfPeerConnectionObserver: PeerConnectionObserver = CallActivitySelfPeerConnectionObserver()
266269

270+
private val endCallFromNotificationReceiver = object : BroadcastReceiver() {
271+
override fun onReceive(context: Context, intent: Intent) {
272+
if (intent.action == END_CALL_FROM_NOTIFICATION) {
273+
isIntentionallyLeavingCall = true
274+
powerManagerUtils?.updatePhoneState(PowerManagerUtils.PhoneState.IDLE)
275+
hangup(shutDownView = true, endCallForAll = false)
276+
}
277+
}
278+
}
279+
267280
private val callParticipantListObserver: CallParticipantList.Observer = object : CallParticipantList.Observer {
268281
override fun onCallParticipantsChanged(
269282
joined: Collection<Participant>,
@@ -312,12 +325,16 @@ class CallActivity : CallBaseActivity() {
312325
private var requestPermissionLauncher = registerForActivityResult(
313326
ActivityResultContracts.RequestMultiplePermissions()
314327
) { permissionMap: Map<String, Boolean> ->
328+
// Log permission results
329+
Log.d(TAG, "Permission request completed with results: $permissionMap")
330+
315331
val rationaleList: MutableList<String> = ArrayList()
316332
val audioPermission = permissionMap[Manifest.permission.RECORD_AUDIO]
317333
if (audioPermission != null) {
318334
if (java.lang.Boolean.TRUE == audioPermission) {
319335
Log.d(TAG, "Microphone permission was granted")
320336
} else {
337+
Log.d(TAG, "Microphone permission was denied")
321338
rationaleList.add(resources.getString(R.string.nc_microphone_permission_hint))
322339
}
323340
}
@@ -326,6 +343,7 @@ class CallActivity : CallBaseActivity() {
326343
if (java.lang.Boolean.TRUE == cameraPermission) {
327344
Log.d(TAG, "Camera permission was granted")
328345
} else {
346+
Log.d(TAG, "Camera permission was denied")
329347
rationaleList.add(resources.getString(R.string.nc_camera_permission_hint))
330348
}
331349
}
@@ -335,6 +353,7 @@ class CallActivity : CallBaseActivity() {
335353
if (java.lang.Boolean.TRUE == bluetoothPermission) {
336354
enableBluetoothManager()
337355
} else {
356+
Log.d(TAG, "Bluetooth permission was denied")
338357
// Only ask for bluetooth when already asking to grant microphone or camera access. Asking
339358
// for bluetooth solely is not important enough here and would most likely annoy the user.
340359
if (rationaleList.isNotEmpty()) {
@@ -343,11 +362,30 @@ class CallActivity : CallBaseActivity() {
343362
}
344363
}
345364
}
365+
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
366+
val notificationPermission = permissionMap[Manifest.permission.POST_NOTIFICATIONS]
367+
if (notificationPermission != null) {
368+
if (java.lang.Boolean.TRUE == notificationPermission) {
369+
Log.d(TAG, "Notification permission was granted")
370+
} else {
371+
Log.w(TAG, "Notification permission was denied - this may cause call hang")
372+
rationaleList.add(resources.getString(R.string.nc_notification_permission_hint))
373+
}
374+
}
375+
}
346376
if (rationaleList.isNotEmpty()) {
347377
showRationaleDialogForSettings(rationaleList)
348378
}
349379

380+
// Check if we should proceed with call despite notification permission
381+
val notificationPermissionGranted = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
382+
permissionMap[Manifest.permission.POST_NOTIFICATIONS] == true
383+
} else {
384+
true // Older Android versions have permission by default
385+
}
386+
350387
if (!isConnectionEstablished) {
388+
Log.d(TAG, "Proceeding with prepareCall() despite notification permission status")
351389
prepareCall()
352390
}
353391
}
@@ -377,6 +415,21 @@ class CallActivity : CallBaseActivity() {
377415
super.onCreate(savedInstanceState)
378416
sharedApplication!!.componentApplication.inject(this)
379417

418+
// Register broadcast receiver for ending call from notification
419+
val endCallFilter = IntentFilter(END_CALL_FROM_NOTIFICATION)
420+
421+
// Use the proper utility function with ReceiverFlag for Android 14+ compatibility
422+
// This receiver is for internal app use only (notification actions), so it should NOT be exported
423+
registerPermissionHandlerBroadcastReceiver(
424+
endCallFromNotificationReceiver,
425+
endCallFilter,
426+
permissionUtil!!.privateBroadcastPermission,
427+
null,
428+
ReceiverFlag.NotExported
429+
)
430+
431+
Log.d(TAG, "Broadcast receiver registered successfully")
432+
380433
callViewModel = ViewModelProvider(this, viewModelFactory)[CallViewModel::class.java]
381434

382435
rootEglBase = EglBase.create()
@@ -762,6 +815,7 @@ class CallActivity : CallBaseActivity() {
762815
true
763816
}
764817
binding!!.hangupButton.setOnClickListener {
818+
isIntentionallyLeavingCall = true
765819
hangup(shutDownView = true, endCallForAll = true)
766820
}
767821
binding!!.endCallPopupMenu.setOnClickListener {
@@ -776,6 +830,7 @@ class CallActivity : CallBaseActivity() {
776830
}
777831
}
778832
binding!!.hangupButton.setOnClickListener {
833+
isIntentionallyLeavingCall = true
779834
hangup(shutDownView = true, endCallForAll = false)
780835
}
781836
binding!!.endCallPopupMenu.setOnClickListener {
@@ -1003,6 +1058,18 @@ class CallActivity : CallBaseActivity() {
10031058
}
10041059
}
10051060

1061+
// Check notification permission for Android 13+ (API 33+)
1062+
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
1063+
if (permissionUtil!!.isPostNotificationsPermissionGranted()) {
1064+
Log.d(TAG, "Notification permission already granted")
1065+
} else if (shouldShowRequestPermissionRationale(Manifest.permission.POST_NOTIFICATIONS)) {
1066+
permissionsToRequest.add(Manifest.permission.POST_NOTIFICATIONS)
1067+
rationaleList.add(resources.getString(R.string.nc_notification_permission_hint))
1068+
} else {
1069+
permissionsToRequest.add(Manifest.permission.POST_NOTIFICATIONS)
1070+
}
1071+
}
1072+
10061073
if (permissionsToRequest.isNotEmpty()) {
10071074
if (rationaleList.isNotEmpty()) {
10081075
showRationaleDialog(permissionsToRequest, rationaleList)
@@ -1011,30 +1078,68 @@ class CallActivity : CallBaseActivity() {
10111078
}
10121079
} else if (!isConnectionEstablished) {
10131080
prepareCall()
1081+
} else {
1082+
// All permissions granted but connection not established
1083+
Log.d(TAG, "All permissions granted but connection not established, proceeding with prepareCall()")
1084+
prepareCall()
10141085
}
10151086
}
10161087

10171088
private fun prepareCall() {
1089+
Log.d(TAG, "prepareCall() started")
10181090
basicInitialization()
10191091
initViews()
10201092
// updateSelfVideoViewPosition(true)
10211093
checkRecordingConsentAndInitiateCall()
10221094

1095+
// Start foreground service only if we have notification permission (for Android 13+)
1096+
// or if we're on older Android versions where permission is automatically granted
10231097
if (permissionUtil!!.isMicrophonePermissionGranted()) {
1024-
CallForegroundService.start(applicationContext, conversationName, intent.extras)
1098+
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
1099+
// Android 13+ requires explicit notification permission
1100+
if (permissionUtil!!.isPostNotificationsPermissionGranted()) {
1101+
Log.d(TAG, "Starting foreground service with notification permission")
1102+
CallForegroundService.start(applicationContext, conversationName, intent.extras)
1103+
} else {
1104+
Log.w(
1105+
TAG,
1106+
"Notification permission not granted - call will work but without persistent notification"
1107+
)
1108+
// Show warning to user that notification permission is missing (10 seconds)
1109+
Snackbar.make(
1110+
binding!!.root,
1111+
resources.getString(R.string.nc_notification_permission_hint),
1112+
SEC_10
1113+
).show()
1114+
}
1115+
} else {
1116+
// Android 12 and below - notification permission is automatically granted
1117+
Log.d(TAG, "Starting foreground service (Android 12-)")
1118+
CallForegroundService.start(applicationContext, conversationName, intent.extras)
1119+
}
1120+
10251121
if (!microphoneOn) {
10261122
onMicrophoneClick()
10271123
}
1124+
} else {
1125+
Log.w(TAG, "Microphone permission not granted - skipping foreground service start")
10281126
}
10291127

1128+
// The call should not hang just because notification permission was denied
1129+
// Always proceed with call setup regardless of notification permission
1130+
Log.d(TAG, "Ensuring call proceeds even without notification permission")
1131+
10301132
if (isVoiceOnlyCall) {
10311133
binding!!.selfVideoViewWrapper.visibility = View.GONE
10321134
} else if (permissionUtil!!.isCameraPermissionGranted()) {
1135+
Log.d(TAG, "Camera permission granted, showing video")
10331136
binding!!.selfVideoViewWrapper.visibility = View.VISIBLE
10341137
onCameraClick()
10351138
if (cameraEnumerator!!.deviceNames.isEmpty()) {
10361139
binding!!.cameraButton.visibility = View.GONE
10371140
}
1141+
} else {
1142+
Log.w(TAG, "Camera permission not granted, hiding video")
10381143
}
10391144
}
10401145

@@ -1051,13 +1156,27 @@ class CallActivity : CallBaseActivity() {
10511156
for (rationale in rationaleList) {
10521157
rationalesWithLineBreaks.append(rationale).append("\n\n")
10531158
}
1159+
10541160
val dialogBuilder = MaterialAlertDialogBuilder(this)
10551161
.setTitle(R.string.nc_permissions_rationale_dialog_title)
10561162
.setMessage(rationalesWithLineBreaks)
10571163
.setPositiveButton(R.string.nc_permissions_ask) { _, _ ->
1164+
Log.d(TAG, "User clicked 'Ask' for permissions")
10581165
requestPermissionLauncher.launch(permissionsToRequest.toTypedArray())
10591166
}
1060-
.setNegativeButton(R.string.nc_common_dismiss, null)
1167+
.setNegativeButton(R.string.nc_common_dismiss) { _, _ ->
1168+
// Log when user dismisses permission request
1169+
Log.w(TAG, "User dismissed permission request for: $permissionsToRequest")
1170+
if (permissionsToRequest.contains(Manifest.permission.POST_NOTIFICATIONS)) {
1171+
Log.w(TAG, "Notification permission specifically dismissed - proceeding with call anyway")
1172+
}
1173+
1174+
// Proceed with call even when notification permission is dismissed
1175+
if (!isConnectionEstablished) {
1176+
Log.d(TAG, "Proceeding with prepareCall() after dismissing notification permission")
1177+
prepareCall()
1178+
}
1179+
}
10611180
viewThemeUtils.dialog.colorMaterialAlertDialogBackground(this, dialogBuilder)
10621181
dialogBuilder.show()
10631182
}
@@ -1327,6 +1446,10 @@ class CallActivity : CallBaseActivity() {
13271446
}
13281447

13291448
public override fun onDestroy() {
1449+
Log.d(TAG, "onDestroy called")
1450+
Log.d(TAG, "onDestroy: isIntentionallyLeavingCall=$isIntentionallyLeavingCall")
1451+
Log.d(TAG, "onDestroy: currentCallStatus=$currentCallStatus")
1452+
13301453
if (signalingMessageReceiver != null) {
13311454
signalingMessageReceiver!!.removeListener(localParticipantMessageListener)
13321455
signalingMessageReceiver!!.removeListener(offerMessageListener)
@@ -1339,10 +1462,29 @@ class CallActivity : CallBaseActivity() {
13391462
Log.d(TAG, "localStream is null")
13401463
}
13411464
if (currentCallStatus !== CallStatus.LEAVING) {
1342-
hangup(true, false)
1465+
// Only hangup if we're intentionally leaving
1466+
if (isIntentionallyLeavingCall) {
1467+
hangup(true, false)
1468+
}
1469+
}
1470+
// Only stop the foreground service if we're actually leaving the call
1471+
if (isIntentionallyLeavingCall || currentCallStatus === CallStatus.LEAVING) {
1472+
CallForegroundService.stop(applicationContext)
13431473
}
1344-
CallForegroundService.stop(applicationContext)
1474+
1475+
Log.d(TAG, "onDestroy: Releasing proximity sensor - updating to IDLE state")
13451476
powerManagerUtils!!.updatePhoneState(PowerManagerUtils.PhoneState.IDLE)
1477+
Log.d(TAG, "onDestroy: Proximity sensor released")
1478+
1479+
// Unregister receiver
1480+
try {
1481+
Log.d(TAG, "Unregistering endCallFromNotificationReceiver...")
1482+
unregisterReceiver(endCallFromNotificationReceiver)
1483+
Log.d(TAG, "endCallFromNotificationReceiver unregistered successfully")
1484+
} catch (e: IllegalArgumentException) {
1485+
Log.w(TAG, "Failed to unregister endCallFromNotificationReceiver", e)
1486+
}
1487+
13461488
super.onDestroy()
13471489
}
13481490

@@ -1916,7 +2058,10 @@ class CallActivity : CallBaseActivity() {
19162058
}
19172059

19182060
private fun hangup(shutDownView: Boolean, endCallForAll: Boolean) {
1919-
Log.d(TAG, "hangup! shutDownView=$shutDownView")
2061+
Log.d(TAG, "hangup! shutDownView=$shutDownView, endCallForAll=$endCallForAll")
2062+
Log.d(TAG, "hangup! isIntentionallyLeavingCall=$isIntentionallyLeavingCall")
2063+
Log.d(TAG, "hangup! powerManagerUtils state before cleanup: ${powerManagerUtils != null}")
2064+
19202065
if (shutDownView) {
19212066
setCallState(CallStatus.LEAVING)
19222067
}
@@ -3080,6 +3225,7 @@ class CallActivity : CallBaseActivity() {
30803225

30813226
private const val CALLING_TIMEOUT: Long = 45000
30823227
private const val PULSE_ANIMATION_DURATION: Int = 310
3228+
private const val SEC_10 = 10000
30833229

30843230
private const val DELAY_ON_ERROR_STOP_THRESHOLD: Int = 16
30853231

0 commit comments

Comments
 (0)