@@ -94,6 +94,7 @@ import com.nextcloud.talk.models.json.signaling.settings.SignalingSettingsOveral
9494import com.nextcloud.talk.raisehand.viewmodel.RaiseHandViewModel
9595import com.nextcloud.talk.raisehand.viewmodel.RaiseHandViewModel.LoweredHandState
9696import com.nextcloud.talk.raisehand.viewmodel.RaiseHandViewModel.RaisedHandState
97+ import com.nextcloud.talk.receivers.EndCallReceiver.Companion.END_CALL_FROM_NOTIFICATION
9798import com.nextcloud.talk.services.CallForegroundService
9899import com.nextcloud.talk.signaling.SignalingMessageReceiver
99100import com.nextcloud.talk.signaling.SignalingMessageReceiver.CallParticipantMessageListener
@@ -185,7 +186,6 @@ import java.util.Objects
185186import java.util.concurrent.TimeUnit
186187import java.util.concurrent.atomic.AtomicInteger
187188import javax.inject.Inject
188- import kotlin.String
189189import 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