From 0dcd17d183f62fd1013587193565db0babf3ecc6 Mon Sep 17 00:00:00 2001 From: Tarek Loubani Date: Thu, 11 Dec 2025 02:10:07 -0500 Subject: [PATCH 01/30] Implement persistent foreground service to keep calls active in background, with notification controls for managing the call MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 The implementation follows Android best practices: - Uses NotExported receiver flag for internal app-only broadcasts - Properly unregisters receivers in onDestroy to prevent leaks - Uses immutable PendingIntents for security - Maintains proper state management during call termination Signed-off-by: Tarek Loubani --- .vscode/settings.json | 3 + app/src/main/AndroidManifest.xml | 3 + .../nextcloud/talk/activities/CallActivity.kt | 161 +++++++++++++++++- .../talk/activities/CallBaseActivity.java | 19 ++- .../talk/receivers/EndCallReceiver.kt | 36 ++++ .../talk/services/CallForegroundService.kt | 42 ++++- app/src/main/res/values/strings.xml | 3 + 7 files changed, 255 insertions(+), 12 deletions(-) create mode 100644 .vscode/settings.json create mode 100644 app/src/main/java/com/nextcloud/talk/receivers/EndCallReceiver.kt diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000000..c5f3f6b9c7 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,3 @@ +{ + "java.configuration.updateBuildConfiguration": "interactive" +} \ No newline at end of file diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 90bc7bcfb6..f0672e760f 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -333,6 +333,9 @@ + -> + // DEBUG: Log permission results + Log.d(TAG, "DEBUG: Permission request completed with results: $permissionMap") + val rationaleList: MutableList = ArrayList() val audioPermission = permissionMap[Manifest.permission.RECORD_AUDIO] if (audioPermission != null) { if (java.lang.Boolean.TRUE == audioPermission) { Log.d(TAG, "Microphone permission was granted") } else { + Log.d(TAG, "DEBUG: Microphone permission was denied") rationaleList.add(resources.getString(R.string.nc_microphone_permission_hint)) } } @@ -343,6 +350,7 @@ class CallActivity : CallBaseActivity() { if (java.lang.Boolean.TRUE == cameraPermission) { Log.d(TAG, "Camera permission was granted") } else { + Log.d(TAG, "DEBUG: Camera permission was denied") rationaleList.add(resources.getString(R.string.nc_camera_permission_hint)) } } @@ -352,6 +360,7 @@ class CallActivity : CallBaseActivity() { if (java.lang.Boolean.TRUE == bluetoothPermission) { enableBluetoothManager() } else { + Log.d(TAG, "DEBUG: Bluetooth permission was denied") // Only ask for bluetooth when already asking to grant microphone or camera access. Asking // for bluetooth solely is not important enough here and would most likely annoy the user. if (rationaleList.isNotEmpty()) { @@ -360,11 +369,32 @@ class CallActivity : CallBaseActivity() { } } } + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + val notificationPermission = permissionMap[Manifest.permission.POST_NOTIFICATIONS] + if (notificationPermission != null) { + if (java.lang.Boolean.TRUE == notificationPermission) { + Log.d(TAG, "Notification permission was granted") + } else { + Log.w(TAG, "DEBUG: Notification permission was denied - this may cause call hang") + rationaleList.add(resources.getString(R.string.nc_notification_permission_hint)) + } + } + } if (rationaleList.isNotEmpty()) { showRationaleDialogForSettings(rationaleList) } + // DEBUG: Check if we should proceed with call despite notification permission + val notificationPermissionGranted = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + permissionMap[Manifest.permission.POST_NOTIFICATIONS] == true + } else { + true // Older Android versions have permission by default + } + + Log.d(TAG, "DEBUG: Notification permission granted: $notificationPermissionGranted, isConnectionEstablished: $isConnectionEstablished") + if (!isConnectionEstablished) { + Log.d(TAG, "DEBUG: Proceeding with prepareCall() despite notification permission status") prepareCall() } } @@ -395,6 +425,21 @@ class CallActivity : CallBaseActivity() { super.onCreate(savedInstanceState) sharedApplication!!.componentApplication.inject(this) + // Register broadcast receiver for ending call from notification + val endCallFilter = IntentFilter("com.nextcloud.talk.END_CALL_FROM_NOTIFICATION") + + // Use the proper utility function with ReceiverFlag for Android 14+ compatibility + // This receiver is for internal app use only (notification actions), so it should NOT be exported + registerPermissionHandlerBroadcastReceiver( + endCallFromNotificationReceiver, + endCallFilter, + permissionUtil!!.privateBroadcastPermission, + null, + ReceiverFlag.NotExported + ) + + Log.d(TAG, "Broadcast receiver registered successfully") + callViewModel = ViewModelProvider(this, viewModelFactory)[CallViewModel::class.java] rootEglBase = EglBase.create() @@ -814,6 +859,7 @@ class CallActivity : CallBaseActivity() { true } binding!!.hangupButton.setOnClickListener { + isIntentionallyLeavingCall = true hangup(shutDownView = true, endCallForAll = true) } binding!!.endCallPopupMenu.setOnClickListener { @@ -828,6 +874,7 @@ class CallActivity : CallBaseActivity() { } } binding!!.hangupButton.setOnClickListener { + isIntentionallyLeavingCall = true hangup(shutDownView = true, endCallForAll = false) } binding!!.endCallPopupMenu.setOnClickListener { @@ -1055,6 +1102,18 @@ class CallActivity : CallBaseActivity() { } } + // Check notification permission for Android 13+ (API 33+) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + if (permissionUtil!!.isPostNotificationsPermissionGranted()) { + Log.d(TAG, "Notification permission already granted") + } else if (shouldShowRequestPermissionRationale(Manifest.permission.POST_NOTIFICATIONS)) { + permissionsToRequest.add(Manifest.permission.POST_NOTIFICATIONS) + rationaleList.add(resources.getString(R.string.nc_notification_permission_hint)) + } else { + permissionsToRequest.add(Manifest.permission.POST_NOTIFICATIONS) + } + } + if (permissionsToRequest.isNotEmpty()) { if (rationaleList.isNotEmpty()) { showRationaleDialog(permissionsToRequest, rationaleList) @@ -1063,26 +1122,59 @@ class CallActivity : CallBaseActivity() { } } else if (!isConnectionEstablished) { prepareCall() + } else { + // DEBUG: All permissions granted but connection not established + Log.d(TAG, "DEBUG: All permissions granted but connection not established, proceeding with prepareCall()") + prepareCall() } } private fun prepareCall() { stopCallingSound() + Log.d(TAG, "DEBUG: prepareCall() started") basicInitialization() initViews() // updateSelfVideoViewPosition(true) checkRecordingConsentAndInitiateCall() + // Start foreground service only if we have notification permission (for Android 13+) + // or if we're on older Android versions where permission is automatically granted if (permissionUtil!!.isMicrophonePermissionGranted()) { - CallForegroundService.start(applicationContext, conversationName, intent.extras) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + // Android 13+ requires explicit notification permission + if (permissionUtil!!.isPostNotificationsPermissionGranted()) { + Log.d(TAG, "DEBUG: Starting foreground service with notification permission") + CallForegroundService.start(applicationContext, conversationName, intent.extras) + } else { + Log.w(TAG, "Notification permission not granted - call will work but without persistent notification") + // Show warning to user that notification permission is missing (10 seconds) + Snackbar.make( + binding!!.root, + resources.getString(R.string.nc_notification_permission_hint), + 10000 + ).show() + } + } else { + // Android 12 and below - notification permission is automatically granted + Log.d(TAG, "DEBUG: Starting foreground service (Android 12-)") + CallForegroundService.start(applicationContext, conversationName, intent.extras) + } + if (!microphoneOn && !appPreferences.callMicrophoneMuted) { onMicrophoneClick() } + } else { + Log.w(TAG, "DEBUG: Microphone permission not granted - skipping foreground service start") } + // The call should not hang just because notification permission was denied + // Always proceed with call setup regardless of notification permission + Log.d(TAG, "DEBUG: Ensuring call proceeds even without notification permission") + if (isVoiceOnlyCall) { binding!!.selfVideoViewWrapper.visibility = View.GONE } else if (permissionUtil!!.isCameraPermissionGranted()) { + Log.d(TAG, "DEBUG: Camera permission granted, showing video") binding!!.selfVideoViewWrapper.visibility = View.VISIBLE // don't enable the camera if call was answered via notification if (!isIncomingCallFromNotification) { @@ -1091,6 +1183,8 @@ class CallActivity : CallBaseActivity() { if (cameraEnumerator!!.deviceNames.isEmpty()) { binding!!.cameraButton.visibility = View.GONE } + } else { + Log.w(TAG, "DEBUG: Camera permission not granted, hiding video") } } @@ -1107,13 +1201,31 @@ class CallActivity : CallBaseActivity() { for (rationale in rationaleList) { rationalesWithLineBreaks.append(rationale).append("\n\n") } + + // DEBUG: Log when permission rationale dialog is shown + Log.d(TAG, "DEBUG: Showing permission rationale dialog for permissions: $permissionsToRequest") + Log.d(TAG, "DEBUG: Rationale includes notification permission: ${permissionsToRequest.contains(Manifest.permission.POST_NOTIFICATIONS)}") + val dialogBuilder = MaterialAlertDialogBuilder(this) .setTitle(R.string.nc_permissions_rationale_dialog_title) .setMessage(rationalesWithLineBreaks) .setPositiveButton(R.string.nc_permissions_ask) { _, _ -> + Log.d(TAG, "DEBUG: User clicked 'Ask' for permissions") requestPermissionLauncher.launch(permissionsToRequest.toTypedArray()) } - .setNegativeButton(R.string.nc_common_dismiss, null) + .setNegativeButton(R.string.nc_common_dismiss) { _, _ -> + // DEBUG: Log when user dismisses permission request + Log.w(TAG, "DEBUG: User dismissed permission request for: $permissionsToRequest") + if (permissionsToRequest.contains(Manifest.permission.POST_NOTIFICATIONS)) { + Log.w(TAG, "DEBUG: Notification permission specifically dismissed - proceeding with call anyway") + } + + // Proceed with call even when notification permission is dismissed + if (!isConnectionEstablished) { + Log.d(TAG, "DEBUG: Proceeding with prepareCall() after dismissing notification permission") + prepareCall() + } + } viewThemeUtils.dialog.colorMaterialAlertDialogBackground(this, dialogBuilder) dialogBuilder.show() } @@ -1395,6 +1507,10 @@ class CallActivity : CallBaseActivity() { } public override fun onDestroy() { + Log.d(TAG, "onDestroy called") + Log.d(TAG, "onDestroy: isIntentionallyLeavingCall=$isIntentionallyLeavingCall") + Log.d(TAG, "onDestroy: currentCallStatus=$currentCallStatus") + if (signalingMessageReceiver != null) { signalingMessageReceiver!!.removeListener(localParticipantMessageListener) signalingMessageReceiver!!.removeListener(offerMessageListener) @@ -1407,10 +1523,29 @@ class CallActivity : CallBaseActivity() { Log.d(TAG, "localStream is null") } if (currentCallStatus !== CallStatus.LEAVING) { - hangup(true, false) + // Only hangup if we're intentionally leaving + if (isIntentionallyLeavingCall) { + hangup(true, false) + } + } + // Only stop the foreground service if we're actually leaving the call + if (isIntentionallyLeavingCall || currentCallStatus === CallStatus.LEAVING) { + CallForegroundService.stop(applicationContext) } - CallForegroundService.stop(applicationContext) + + Log.d(TAG, "onDestroy: Releasing proximity sensor - updating to IDLE state") powerManagerUtils!!.updatePhoneState(PowerManagerUtils.PhoneState.IDLE) + Log.d(TAG, "onDestroy: Proximity sensor released") + + // Unregister receiver + try { + Log.d(TAG, "Unregistering endCallFromNotificationReceiver...") + unregisterReceiver(endCallFromNotificationReceiver) + Log.d(TAG, "endCallFromNotificationReceiver unregistered successfully") + } catch (e: Exception) { + Log.w(TAG, "Failed to unregister endCallFromNotificationReceiver", e) + } + super.onDestroy() } @@ -2013,8 +2148,10 @@ class CallActivity : CallBaseActivity() { } private fun hangup(shutDownView: Boolean, endCallForAll: Boolean) { - Log.d(TAG, "hangup! shutDownView=$shutDownView") + Log.d(TAG, "hangup! shutDownView=$shutDownView, endCallForAll=$endCallForAll") joinRoomInitiated = false + Log.d(TAG, "hangup! isIntentionallyLeavingCall=$isIntentionallyLeavingCall") + Log.d(TAG, "hangup! powerManagerUtils state before cleanup: ${powerManagerUtils != null}") if (shutDownView) { setCallState(CallStatus.LEAVING) } @@ -3299,4 +3436,18 @@ class CallActivity : CallBaseActivity() { private const val SESSION_ID_PREFFIX_END: Int = 4 } + + // Broadcast receiver to handle end call from notification + private val endCallFromNotificationReceiver = object : BroadcastReceiver() { + override fun onReceive(context: Context, intent: Intent) { + if (intent.action == "com.nextcloud.talk.END_CALL_FROM_NOTIFICATION") { + Log.d(TAG, "Received end call from notification broadcast") + Log.d(TAG, "endCallFromNotificationReceiver: Setting isIntentionallyLeavingCall=true") + isIntentionallyLeavingCall = true + Log.d(TAG, "endCallFromNotificationReceiver: Releasing proximity sensor before hangup") + powerManagerUtils?.updatePhoneState(PowerManagerUtils.PhoneState.IDLE) + hangup(shutDownView = true, endCallForAll = false) + } + } + } } diff --git a/app/src/main/java/com/nextcloud/talk/activities/CallBaseActivity.java b/app/src/main/java/com/nextcloud/talk/activities/CallBaseActivity.java index 6086b2d462..2b94e3ad26 100644 --- a/app/src/main/java/com/nextcloud/talk/activities/CallBaseActivity.java +++ b/app/src/main/java/com/nextcloud/talk/activities/CallBaseActivity.java @@ -38,6 +38,9 @@ public abstract class CallBaseActivity extends BaseActivity { public void handleOnBackPressed() { if (isPipModePossible()) { enterPipMode(); + } else { + // Move the task to background instead of finishing + moveTaskToBack(true); } } }; @@ -94,8 +97,13 @@ void enableKeyguard() { @Override public void onStop() { super.onStop(); - if (shouldFinishOnStop()) { - finish(); + // Don't automatically finish when going to background + // Only finish if explicitly leaving the call + if (shouldFinishOnStop() && !isChangingConfigurations()) { + // Check if we're really leaving the call or just backgrounding + if (isFinishing()) { + finish(); + } } } @@ -120,10 +128,9 @@ void enterPipMode() { mPictureInPictureParamsBuilder.setAspectRatio(pipRatio); enterPictureInPictureMode(mPictureInPictureParamsBuilder.build()); } else { - // we don't support other solutions than PIP to have a call in the background. - // If PIP is not available the call is ended when user presses the home button. - Log.d(TAG, "Activity was finished because PIP is not available."); - finish(); + // If PIP is not available, move to background instead of finishing + Log.d(TAG, "PIP is not available, moving call to background."); + moveTaskToBack(true); } } diff --git a/app/src/main/java/com/nextcloud/talk/receivers/EndCallReceiver.kt b/app/src/main/java/com/nextcloud/talk/receivers/EndCallReceiver.kt new file mode 100644 index 0000000000..4d6f23945b --- /dev/null +++ b/app/src/main/java/com/nextcloud/talk/receivers/EndCallReceiver.kt @@ -0,0 +1,36 @@ +/* + * Nextcloud Talk - Android Client + * + * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: GPL-3.0-or-later + */ +package com.nextcloud.talk.receivers + +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import android.util.Log +import com.nextcloud.talk.activities.CallActivity +import com.nextcloud.talk.services.CallForegroundService + +class EndCallReceiver : BroadcastReceiver() { + companion object { + private const val TAG = "EndCallReceiver" + } + + override fun onReceive(context: Context?, intent: Intent?) { + if (intent?.action == "com.nextcloud.talk.END_CALL") { + Log.d(TAG, "Received end call broadcast") + + // Stop the foreground service + context?.let { + CallForegroundService.stop(it) + + // Send broadcast to CallActivity to end the call + val endCallIntent = Intent("com.nextcloud.talk.END_CALL_FROM_NOTIFICATION") + endCallIntent.setPackage(context.packageName) + context.sendBroadcast(endCallIntent) + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/nextcloud/talk/services/CallForegroundService.kt b/app/src/main/java/com/nextcloud/talk/services/CallForegroundService.kt index f1dd6e7016..f4d369e27d 100644 --- a/app/src/main/java/com/nextcloud/talk/services/CallForegroundService.kt +++ b/app/src/main/java/com/nextcloud/talk/services/CallForegroundService.kt @@ -16,12 +16,14 @@ import android.content.pm.ServiceInfo import android.os.Build import android.os.Bundle import android.os.IBinder +import android.util.Log import androidx.core.app.NotificationCompat import androidx.core.app.NotificationCompat.FOREGROUND_SERVICE_IMMEDIATE import androidx.core.content.ContextCompat import com.nextcloud.talk.R import com.nextcloud.talk.activities.CallActivity import com.nextcloud.talk.application.NextcloudTalkApplication +import com.nextcloud.talk.receivers.EndCallReceiver import com.nextcloud.talk.utils.NotificationUtils import com.nextcloud.talk.utils.bundle.BundleKeys.KEY_CALL_VOICE_ONLY import com.nextcloud.talk.utils.bundle.BundleKeys.KEY_PARTICIPANT_PERMISSION_CAN_PUBLISH_VIDEO @@ -59,6 +61,26 @@ class CallForegroundService : Service() { val contentTitle = conversationName?.takeIf { it.isNotBlank() } ?: getString(R.string.nc_call_ongoing_notification_default_title) val pendingIntent = createContentIntent(callExtras) + + // Create action to return to call + val returnToCallAction = NotificationCompat.Action.Builder( + R.drawable.ic_call_white_24dp, + getString(R.string.nc_call_ongoing_notification_return_action), + pendingIntent + ).build() + + // Create action to end call + val endCallPendingIntent = createEndCallIntent(callExtras) + + // DIAGNOSTIC: Logging icon resource availability + Log.d("CallForegroundService", "Creating end call action - checking icon resources") + Log.d("CallForegroundService", "Using ic_baseline_close_24 instead of non-existent ic_close_white_24px") + + val endCallAction = NotificationCompat.Action.Builder( + R.drawable.ic_baseline_close_24, // DIAGNOSTIC: Fixed - using existing icon + getString(R.string.nc_call_ongoing_notification_end_action), + endCallPendingIntent + ).build() // Already has parentheses, good! return NotificationCompat.Builder(this, channelId) .setContentTitle(contentTitle) @@ -71,6 +93,9 @@ class CallForegroundService : Service() { .setVisibility(NotificationCompat.VISIBILITY_PUBLIC) .setContentIntent(pendingIntent) .setShowWhen(false) + .addAction(returnToCallAction) + .addAction(endCallAction) + .setAutoCancel(false) .build() } @@ -81,13 +106,28 @@ class CallForegroundService : Service() { private fun createContentIntent(callExtras: Bundle?): PendingIntent { val intent = Intent(this, CallActivity::class.java).apply { - flags = Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_CLEAR_TOP + flags = Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_REORDER_TO_FRONT callExtras?.let { putExtras(Bundle(it)) } } val flags = PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE return PendingIntent.getActivity(this, 0, intent, flags) } + + private fun createEndCallIntent(callExtras: Bundle?): PendingIntent { + // DIAGNOSTIC: Logging intent creation + Log.d("CallForegroundService", "Creating EndCallIntent with EndCallReceiver class") + + val intent = Intent(this, EndCallReceiver::class.java).apply { + action = "com.nextcloud.talk.END_CALL" + callExtras?.let { putExtras(Bundle(it)) } + } + + Log.d("CallForegroundService", "EndCallIntent created successfully with action: ${intent.action}") + + val flags = PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE + return PendingIntent.getBroadcast(this, 1, intent, flags) + } private fun resolveForegroundServiceType(callExtras: Bundle?): Int { var serviceType = 0 diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index feaf97c92c..c28c415518 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -390,6 +390,7 @@ How to translate with transifex: To enable video communication please grant \"Camera\" permission. To enable voice communication please grant \"Microphone\" permission. To enable bluetooth speakers please grant \"Nearby devices\" permission. + To show call notifications and keep calls active in the background, please grant \"Notifications\" permission. Microphone is enabled and audio is recording @@ -412,6 +413,8 @@ How to translate with transifex: You missed a call from %s Call in progress Tap to return to your call. + Return to call + End call Open picture-in-picture mode Change audio output Toggle camera From 052cec385af2cd249ffbf0e64d2b082c077d6422 Mon Sep 17 00:00:00 2001 From: Tarek Loubani Date: Thu, 26 Mar 2026 02:39:07 -0400 Subject: [PATCH 02/30] Remove .vscode and add to .gitignore Signed-off-by: Tarek Loubani --- .gitignore | 1 + .vscode/settings.json | 3 --- 2 files changed, 1 insertion(+), 3 deletions(-) delete mode 100644 .vscode/settings.json diff --git a/.gitignore b/.gitignore index 551b5c465d..b1ed11373a 100644 --- a/.gitignore +++ b/.gitignore @@ -21,6 +21,7 @@ target/ # Local configuration files (sdk path, etc) local.properties tests/local.properties +.vscode # Mac .DS_Store files .DS_Store diff --git a/.vscode/settings.json b/.vscode/settings.json deleted file mode 100644 index c5f3f6b9c7..0000000000 --- a/.vscode/settings.json +++ /dev/null @@ -1,3 +0,0 @@ -{ - "java.configuration.updateBuildConfiguration": "interactive" -} \ No newline at end of file From d084107677aabfedfc9b72043aef7f10ae965978 Mon Sep 17 00:00:00 2001 From: Tarek Loubani Date: Thu, 26 Mar 2026 02:40:41 -0400 Subject: [PATCH 03/30] Remove unnecessary logging about icon Signed-off-by: Tarek Loubani --- .../java/com/nextcloud/talk/services/CallForegroundService.kt | 4 ---- 1 file changed, 4 deletions(-) diff --git a/app/src/main/java/com/nextcloud/talk/services/CallForegroundService.kt b/app/src/main/java/com/nextcloud/talk/services/CallForegroundService.kt index f4d369e27d..06edf083df 100644 --- a/app/src/main/java/com/nextcloud/talk/services/CallForegroundService.kt +++ b/app/src/main/java/com/nextcloud/talk/services/CallForegroundService.kt @@ -72,10 +72,6 @@ class CallForegroundService : Service() { // Create action to end call val endCallPendingIntent = createEndCallIntent(callExtras) - // DIAGNOSTIC: Logging icon resource availability - Log.d("CallForegroundService", "Creating end call action - checking icon resources") - Log.d("CallForegroundService", "Using ic_baseline_close_24 instead of non-existent ic_close_white_24px") - val endCallAction = NotificationCompat.Action.Builder( R.drawable.ic_baseline_close_24, // DIAGNOSTIC: Fixed - using existing icon getString(R.string.nc_call_ongoing_notification_end_action), From 527d148b001406e96e0ec34ad3e2e556d7825c14 Mon Sep 17 00:00:00 2001 From: Tarek Loubani Date: Thu, 26 Mar 2026 02:42:44 -0400 Subject: [PATCH 04/30] Clean up microphone permission language to be more clear Signed-off-by: Tarek Loubani --- app/src/main/java/com/nextcloud/talk/activities/CallActivity.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/src/main/java/com/nextcloud/talk/activities/CallActivity.kt b/app/src/main/java/com/nextcloud/talk/activities/CallActivity.kt index 82e0f63dd3..ac50fe236b 100644 --- a/app/src/main/java/com/nextcloud/talk/activities/CallActivity.kt +++ b/app/src/main/java/com/nextcloud/talk/activities/CallActivity.kt @@ -341,7 +341,7 @@ class CallActivity : CallBaseActivity() { if (java.lang.Boolean.TRUE == audioPermission) { Log.d(TAG, "Microphone permission was granted") } else { - Log.d(TAG, "DEBUG: Microphone permission was denied") + Log.d(TAG, "Microphone permission is not yet granted. Request will be made for permission.") rationaleList.add(resources.getString(R.string.nc_microphone_permission_hint)) } } From aa0c5a6879953705acdbf77137124e300598bd97 Mon Sep 17 00:00:00 2001 From: Tarek Loubani Date: Thu, 26 Mar 2026 02:48:51 -0400 Subject: [PATCH 05/30] Move endCallFromNotificationReceiver receiver up above companion object Signed-off-by: Tarek Loubani --- .../com/nextcloud/talk/activities/CallActivity.kt | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/app/src/main/java/com/nextcloud/talk/activities/CallActivity.kt b/app/src/main/java/com/nextcloud/talk/activities/CallActivity.kt index ac50fe236b..be48a9fef9 100644 --- a/app/src/main/java/com/nextcloud/talk/activities/CallActivity.kt +++ b/app/src/main/java/com/nextcloud/talk/activities/CallActivity.kt @@ -3374,6 +3374,20 @@ class CallActivity : CallBaseActivity() { ) || isBreakoutRoom + // Broadcast receiver to handle end call from notification + private val endCallFromNotificationReceiver = object : BroadcastReceiver() { + override fun onReceive(context: Context, intent: Intent) { + if (intent.action == "com.nextcloud.talk.END_CALL_FROM_NOTIFICATION") { + Log.d(TAG, "Received end call from notification broadcast") + Log.d(TAG, "endCallFromNotificationReceiver: Setting isIntentionallyLeavingCall=true") + isIntentionallyLeavingCall = true + Log.d(TAG, "endCallFromNotificationReceiver: Releasing proximity sensor before hangup") + powerManagerUtils?.updatePhoneState(PowerManagerUtils.PhoneState.IDLE) + hangup(shutDownView = true, endCallForAll = false) + } + } + } + companion object { var active = false From a2369573d69c9528fccc7d2b65d268fba3502975 Mon Sep 17 00:00:00 2001 From: Tarek Loubani Date: Thu, 26 Mar 2026 03:05:20 -0400 Subject: [PATCH 06/30] Fix typo to include whole directory Signed-off-by: Tarek Loubani --- .gitignore | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index b1ed11373a..9281241293 100644 --- a/.gitignore +++ b/.gitignore @@ -21,7 +21,7 @@ target/ # Local configuration files (sdk path, etc) local.properties tests/local.properties -.vscode +.vscode/ # Mac .DS_Store files .DS_Store From 78dba67858b165a5ec3c84fc571eb249c3c8b37f Mon Sep 17 00:00:00 2001 From: Tarek Loubani Date: Thu, 26 Mar 2026 03:06:13 -0400 Subject: [PATCH 07/30] Incorporate refactor from PR #5957 by @rapterjet2004 Signed-off-by: Tarek Loubani --- .../nextcloud/talk/activities/CallActivity.kt | 13 ++++++------- .../talk/activities/CallBaseActivity.java | 4 ++-- .../nextcloud/talk/receivers/EndCallReceiver.kt | 17 +++++++++-------- .../talk/services/CallForegroundService.kt | 14 +++++--------- 4 files changed, 22 insertions(+), 26 deletions(-) diff --git a/app/src/main/java/com/nextcloud/talk/activities/CallActivity.kt b/app/src/main/java/com/nextcloud/talk/activities/CallActivity.kt index be48a9fef9..ae24151d6c 100644 --- a/app/src/main/java/com/nextcloud/talk/activities/CallActivity.kt +++ b/app/src/main/java/com/nextcloud/talk/activities/CallActivity.kt @@ -104,6 +104,7 @@ import com.nextcloud.talk.models.json.signaling.settings.SignalingSettingsOveral import com.nextcloud.talk.raisehand.viewmodel.RaiseHandViewModel import com.nextcloud.talk.raisehand.viewmodel.RaiseHandViewModel.LoweredHandState import com.nextcloud.talk.raisehand.viewmodel.RaiseHandViewModel.RaisedHandState +import com.nextcloud.talk.receivers.EndCallReceiver.Companion.END_CALL_FROM_NOTIFICATION import com.nextcloud.talk.services.CallForegroundService import com.nextcloud.talk.signaling.SignalingMessageReceiver import com.nextcloud.talk.signaling.SignalingMessageReceiver.CallParticipantMessageListener @@ -426,8 +427,8 @@ class CallActivity : CallBaseActivity() { sharedApplication!!.componentApplication.inject(this) // Register broadcast receiver for ending call from notification - val endCallFilter = IntentFilter("com.nextcloud.talk.END_CALL_FROM_NOTIFICATION") - + val endCallFilter = IntentFilter(END_CALL_FROM_NOTIFICATION) + // Use the proper utility function with ReceiverFlag for Android 14+ compatibility // This receiver is for internal app use only (notification actions), so it should NOT be exported registerPermissionHandlerBroadcastReceiver( @@ -1151,7 +1152,7 @@ class CallActivity : CallBaseActivity() { Snackbar.make( binding!!.root, resources.getString(R.string.nc_notification_permission_hint), - 10000 + SEC_10 ).show() } } else { @@ -3377,11 +3378,8 @@ class CallActivity : CallBaseActivity() { // Broadcast receiver to handle end call from notification private val endCallFromNotificationReceiver = object : BroadcastReceiver() { override fun onReceive(context: Context, intent: Intent) { - if (intent.action == "com.nextcloud.talk.END_CALL_FROM_NOTIFICATION") { - Log.d(TAG, "Received end call from notification broadcast") - Log.d(TAG, "endCallFromNotificationReceiver: Setting isIntentionallyLeavingCall=true") + if (intent.action == END_CALL_FROM_NOTIFICATION) { isIntentionallyLeavingCall = true - Log.d(TAG, "endCallFromNotificationReceiver: Releasing proximity sensor before hangup") powerManagerUtils?.updatePhoneState(PowerManagerUtils.PhoneState.IDLE) hangup(shutDownView = true, endCallForAll = false) } @@ -3437,6 +3435,7 @@ class CallActivity : CallBaseActivity() { private const val CALLING_TIMEOUT: Long = 45000 private const val PULSE_ANIMATION_DURATION: Int = 310 + private const val SEC_10 = 10000 internal fun isPushToTalkRelease(action: Int): Boolean = action == MotionEvent.ACTION_UP || action == MotionEvent.ACTION_CANCEL diff --git a/app/src/main/java/com/nextcloud/talk/activities/CallBaseActivity.java b/app/src/main/java/com/nextcloud/talk/activities/CallBaseActivity.java index 2b94e3ad26..dd4219b47a 100644 --- a/app/src/main/java/com/nextcloud/talk/activities/CallBaseActivity.java +++ b/app/src/main/java/com/nextcloud/talk/activities/CallBaseActivity.java @@ -33,7 +33,7 @@ public abstract class CallBaseActivity extends BaseActivity { long onCreateTime; - private OnBackPressedCallback onBackPressedCallback = new OnBackPressedCallback(true) { + private final OnBackPressedCallback onBackPressedCallback = new OnBackPressedCallback(true) { @Override public void handleOnBackPressed() { if (isPipModePossible()) { @@ -64,7 +64,7 @@ public void onCreate(Bundle savedInstanceState) { getOnBackPressedDispatcher().addCallback(this, onBackPressedCallback); } - public void hideNavigationIfNoPipAvailable(){ + public void hideNavigationIfNoPipAvailable() { if (!isPipModePossible()) { getWindow().getDecorView().setSystemUiVisibility(View.SYSTEM_UI_FLAG_LAYOUT_STABLE | View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION | diff --git a/app/src/main/java/com/nextcloud/talk/receivers/EndCallReceiver.kt b/app/src/main/java/com/nextcloud/talk/receivers/EndCallReceiver.kt index 4d6f23945b..d56d1f9e89 100644 --- a/app/src/main/java/com/nextcloud/talk/receivers/EndCallReceiver.kt +++ b/app/src/main/java/com/nextcloud/talk/receivers/EndCallReceiver.kt @@ -10,24 +10,25 @@ import android.content.BroadcastReceiver import android.content.Context import android.content.Intent import android.util.Log -import com.nextcloud.talk.activities.CallActivity import com.nextcloud.talk.services.CallForegroundService class EndCallReceiver : BroadcastReceiver() { companion object { - private const val TAG = "EndCallReceiver" + private val TAG = EndCallReceiver::class.simpleName + const val END_CALL_ACTION = "com.nextcloud.talk.END_CALL" + const val END_CALL_FROM_NOTIFICATION = "com.nextcloud.talk.END_CALL_FROM_NOTIFICATION" } - + override fun onReceive(context: Context?, intent: Intent?) { - if (intent?.action == "com.nextcloud.talk.END_CALL") { - Log.d(TAG, "Received end call broadcast") - + if (intent?.action == END_CALL_ACTION) { + Log.i(TAG, "Received end call broadcast") + // Stop the foreground service context?.let { CallForegroundService.stop(it) - + // Send broadcast to CallActivity to end the call - val endCallIntent = Intent("com.nextcloud.talk.END_CALL_FROM_NOTIFICATION") + val endCallIntent = Intent(END_CALL_FROM_NOTIFICATION) endCallIntent.setPackage(context.packageName) context.sendBroadcast(endCallIntent) } diff --git a/app/src/main/java/com/nextcloud/talk/services/CallForegroundService.kt b/app/src/main/java/com/nextcloud/talk/services/CallForegroundService.kt index 06edf083df..561dd708a3 100644 --- a/app/src/main/java/com/nextcloud/talk/services/CallForegroundService.kt +++ b/app/src/main/java/com/nextcloud/talk/services/CallForegroundService.kt @@ -24,6 +24,7 @@ import com.nextcloud.talk.R import com.nextcloud.talk.activities.CallActivity import com.nextcloud.talk.application.NextcloudTalkApplication import com.nextcloud.talk.receivers.EndCallReceiver +import com.nextcloud.talk.receivers.EndCallReceiver.Companion.END_CALL_ACTION import com.nextcloud.talk.utils.NotificationUtils import com.nextcloud.talk.utils.bundle.BundleKeys.KEY_CALL_VOICE_ONLY import com.nextcloud.talk.utils.bundle.BundleKeys.KEY_PARTICIPANT_PERMISSION_CAN_PUBLISH_VIDEO @@ -73,10 +74,10 @@ class CallForegroundService : Service() { val endCallPendingIntent = createEndCallIntent(callExtras) val endCallAction = NotificationCompat.Action.Builder( - R.drawable.ic_baseline_close_24, // DIAGNOSTIC: Fixed - using existing icon + R.drawable.ic_baseline_close_24, getString(R.string.nc_call_ongoing_notification_end_action), endCallPendingIntent - ).build() // Already has parentheses, good! + ).build() return NotificationCompat.Builder(this, channelId) .setContentTitle(contentTitle) @@ -111,16 +112,11 @@ class CallForegroundService : Service() { } private fun createEndCallIntent(callExtras: Bundle?): PendingIntent { - // DIAGNOSTIC: Logging intent creation - Log.d("CallForegroundService", "Creating EndCallIntent with EndCallReceiver class") - val intent = Intent(this, EndCallReceiver::class.java).apply { - action = "com.nextcloud.talk.END_CALL" + action = END_CALL_ACTION callExtras?.let { putExtras(Bundle(it)) } } - - Log.d("CallForegroundService", "EndCallIntent created successfully with action: ${intent.action}") - + val flags = PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE return PendingIntent.getBroadcast(this, 1, intent, flags) } From f9f30ed50e49b29a77953c14c5da7ff0db9e25a9 Mon Sep 17 00:00:00 2001 From: Tarek Loubani Date: Thu, 26 Mar 2026 06:59:35 -0400 Subject: [PATCH 08/30] Fix problem where call does not correctly get switched to PIP if you do a rapid gesture switch back. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit For the quick-switch gesture and the recents-to-chat path, the OS starts animating the transition before onPause() fires. By the time onPause() runs, the window has already been moved off-screen by the gesture animation, so enterPictureInPictureMode() silently fails — Android requires the window to still be visible. Why onTopResumedActivityChanged(false) fixes it: This callback fires when any other activity (including ChatActivity in the same app) takes the "top resumed" slot. Critically, it fires before onPause() and before any transition animation begins — the window is still fully on-screen. enterPictureInPictureMode() succeeds at this point. Why back-button worked but this didn't: Back button goes through OnBackPressedCallback.handleOnBackPressed() synchronously, which calls enterPipMode() before any transition, not in a lifecycle callback. onTopResumedActivityChanged puts the task-switch path on the same footing. API compatibility: On API 26–28, onTopResumedActivityChanged is never called by the system (it didn't exist in Activity before API 29), so onPause() remains the fallback. Older devices primarily use button navigation and won't have the gesture quick-switch anyway. Signed-off-by: Tarek Loubani --- .../nextcloud/talk/activities/CallActivity.kt | 68 +++++++------------ .../talk/activities/CallBaseActivity.java | 36 ++++++++++ 2 files changed, 62 insertions(+), 42 deletions(-) diff --git a/app/src/main/java/com/nextcloud/talk/activities/CallActivity.kt b/app/src/main/java/com/nextcloud/talk/activities/CallActivity.kt index ae24151d6c..1ea131fdc8 100644 --- a/app/src/main/java/com/nextcloud/talk/activities/CallActivity.kt +++ b/app/src/main/java/com/nextcloud/talk/activities/CallActivity.kt @@ -864,6 +864,7 @@ class CallActivity : CallBaseActivity() { hangup(shutDownView = true, endCallForAll = true) } binding!!.endCallPopupMenu.setOnClickListener { + isIntentionallyLeavingCall = true hangup(shutDownView = true, endCallForAll = true) binding!!.endCallPopupMenu.visibility = View.GONE } @@ -879,6 +880,7 @@ class CallActivity : CallBaseActivity() { hangup(shutDownView = true, endCallForAll = false) } binding!!.endCallPopupMenu.setOnClickListener { + isIntentionallyLeavingCall = true hangup(shutDownView = true, endCallForAll = false) binding!!.endCallPopupMenu.visibility = View.GONE } @@ -2243,51 +2245,33 @@ class CallActivity : CallBaseActivity() { } val endCall: Boolean? = if (endCallForAll) true else null + // Fire DELETE best-effort; do not block the UI waiting for the server response. + // The subscription runs entirely on the IO thread — no observeOn(mainThread) needed. ncApi!!.leaveCall(credentials, ApiUtils.getUrlForCall(apiVersion, baseUrl, roomToken!!), endCall) .subscribeOn(Schedulers.io()) - .observeOn(AndroidSchedulers.mainThread()) - .subscribe(object : Observer { - override fun onSubscribe(d: Disposable) { - // unused atm - } - - override fun onNext(genericOverall: GenericOverall) { - val conversationModel = currentConversation?.let { - ConversationModel.mapToConversationModel(it, conversationUser) - } - - if (conversationModel?.checkIfVoiceRoom() == true) { - openConversationListInPrimaryTask() - finishAndRemoveTask() - } else if (switchToRoomToken.isNotEmpty()) { - val intent = Intent(context, ChatActivity::class.java) - intent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP) - val bundle = Bundle() - bundle.putBoolean(KEY_SWITCH_TO_ROOM, true) - bundle.putBoolean(KEY_START_CALL_AFTER_ROOM_SWITCH, true) - bundle.putString(KEY_ROOM_TOKEN, switchToRoomToken) - bundle.putBoolean(KEY_CALL_VOICE_ONLY, isVoiceOnlyCall) - intent.putExtras(bundle) - startActivity(intent) - finish() - } else if (shutDownView) { - finish() - } else if (currentCallStatus === CallStatus.RECONNECTING || - currentCallStatus === CallStatus.PUBLISHER_FAILED - ) { - initiateCall() - } - } - - override fun onError(e: Throwable) { - Log.w(TAG, "Something went wrong when leaving the call", e) - finish() - } + .subscribe( + { /* successfully left call */ }, + { e -> Log.w(TAG, "Something went wrong when leaving the call", e) } + ) - override fun onComplete() { - // unused atm - } - }) + if (switchToRoomToken.isNotEmpty()) { + val intent = Intent(context, ChatActivity::class.java) + intent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP) + val bundle = Bundle() + bundle.putBoolean(KEY_SWITCH_TO_ROOM, true) + bundle.putBoolean(KEY_START_CALL_AFTER_ROOM_SWITCH, true) + bundle.putString(KEY_ROOM_TOKEN, switchToRoomToken) + bundle.putBoolean(KEY_CALL_VOICE_ONLY, isVoiceOnlyCall) + intent.putExtras(bundle) + startActivity(intent) + finish() + } else if (shutDownView) { + finish() + } else if (currentCallStatus === CallStatus.RECONNECTING || + currentCallStatus === CallStatus.PUBLISHER_FAILED + ) { + initiateCall() + } } private fun openConversationListInPrimaryTask() { diff --git a/app/src/main/java/com/nextcloud/talk/activities/CallBaseActivity.java b/app/src/main/java/com/nextcloud/talk/activities/CallBaseActivity.java index dd4219b47a..84b126a96c 100644 --- a/app/src/main/java/com/nextcloud/talk/activities/CallBaseActivity.java +++ b/app/src/main/java/com/nextcloud/talk/activities/CallBaseActivity.java @@ -59,6 +59,10 @@ public void onCreate(Bundle savedInstanceState) { if (isPipModePossible()) { mPictureInPictureParamsBuilder = new PictureInPictureParams.Builder(); + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + mPictureInPictureParamsBuilder.setAutoEnterEnabled(true); + setPictureInPictureParams(mPictureInPictureParamsBuilder.build()); + } } getOnBackPressedDispatcher().addCallback(this, onBackPressedCallback); @@ -94,6 +98,38 @@ void enableKeyguard() { } } + /** + * Fired on API 29+ when another activity becomes the top resumed activity — including + * same-app task switches (e.g. task switcher or quick-switch gesture to the chat window). + * This fires *before* onPause() while our window is still fully visible, so + * enterPictureInPictureMode() can succeed. On API 26-28 this method is never called by + * the system; onPause() below serves as the fallback for those devices. + */ + @Override + public void onTopResumedActivityChanged(boolean isTopResumedActivity) { + super.onTopResumedActivityChanged(isTopResumedActivity); + if (!isTopResumedActivity + && !isInPipMode + && isPipModePossible() + && !isChangingConfigurations() + && !isFinishing()) { + enterPipMode(); + } + } + + @Override + public void onPause() { + super.onPause(); + // Fallback for API 26-28 (no onTopResumedActivityChanged) and any edge cases + // where PIP was not yet entered by the time we reach onPause(). + if (!isInPipMode + && isPipModePossible() + && !isChangingConfigurations() + && !isFinishing()) { + enterPipMode(); + } + } + @Override public void onStop() { super.onStop(); From fd4fc9b5f7c8fe5a91e220328f9e94324afc862b Mon Sep 17 00:00:00 2001 From: Tarek Loubani Date: Tue, 31 Mar 2026 20:04:24 -0400 Subject: [PATCH 09/30] Fix conversation state race condition when navigating away from chat Use observeForever for leaveRoom observer so cleanup runs even when activity is paused. Move ApplicationWideCurrentRoomHolder.clear() into the leave success callback to avoid premature state clearing. Guard against double leaveRoom calls with isLeavingRoom flag. Signed-off-by: Tarek Loubani --- .../com/nextcloud/talk/chat/ChatActivity.kt | 49 ++++++++++++++++++- 1 file changed, 47 insertions(+), 2 deletions(-) diff --git a/app/src/main/java/com/nextcloud/talk/chat/ChatActivity.kt b/app/src/main/java/com/nextcloud/talk/chat/ChatActivity.kt index 71a21eaaa1..62fe1c6891 100644 --- a/app/src/main/java/com/nextcloud/talk/chat/ChatActivity.kt +++ b/app/src/main/java/com/nextcloud/talk/chat/ChatActivity.kt @@ -391,6 +391,8 @@ class ChatActivity : private lateinit var path: String var myFirstMessage: CharSequence? = null + var checkingLobbyStatus: Boolean = false + private var isLeavingRoom: Boolean = false private var lastHandledHighlightNonce: Long? = null private var pendingHighlightedMessageId: Long? = null @@ -514,6 +516,42 @@ class ChatActivity : val typingParticipants = HashMap() + var callStarted = false + + private val leaveRoomObserver = androidx.lifecycle.Observer { state -> + when (state) { + is ChatViewModel.LeaveRoomSuccessState -> { + logConversationInfos("leaveRoom#onNext") + + isLeavingRoom = false + + checkingLobbyStatus = false + + if (getRoomInfoTimerHandler != null) { + getRoomInfoTimerHandler?.removeCallbacksAndMessages(null) + } + + ApplicationWideCurrentRoomHolder.getInstance().clear() + + if (webSocketInstance != null && currentConversation != null) { + webSocketInstance?.joinRoomWithRoomTokenAndSession( + "", + sessionIdAfterRoomJoined + ) + } + + sessionIdAfterRoomJoined = "0" + + if (state.funToCallWhenLeaveSuccessful != null) { + Log.d(TAG, "a callback action was set and is now executed because room was left successfully") + state.funToCallWhenLeaveSuccessful.invoke() + } + } + + else -> {} + } + } + private val localParticipantMessageListener = SignalingMessageReceiver.LocalParticipantMessageListener { token -> if (CallActivity.active) { Log.d(TAG, "CallActivity is running. Ignore to switch chat in ChatActivity...") @@ -1682,6 +1720,8 @@ class ChatActivity : } } + chatViewModel.leaveRoomViewState.observeForever(leaveRoomObserver) + messageInputViewModel.sendChatMessageViewState.observe(this) { state -> when (state) { is MessageInputViewModel.SendChatMessageSuccessState -> { @@ -2959,11 +2999,13 @@ class ChatActivity : } if (::conversationUser.isInitialized && isActivityNotChangingConfigurations() && isNotInCall()) { - ApplicationWideCurrentRoomHolder.getInstance().clear() - if (validSessionId()) { + if (isLeavingRoom) { + Log.d(TAG, "not leaving room (leave already in progress)") + } else if (validSessionId()) { leaveRoom(null) } else { Log.d(TAG, "not leaving room (validSessionId is false)") + ApplicationWideCurrentRoomHolder.getInstance().clear() } } else { Log.d(TAG, "not leaving room...") @@ -3005,6 +3047,8 @@ class ChatActivity : super.onDestroy() logConversationInfos("onDestroy") + chatViewModel.leaveRoomViewState.removeObserver(leaveRoomObserver) + findViewById(R.id.toolbar)?.setOnClickListener(null) if (actionBar != null) { @@ -3040,6 +3084,7 @@ class ChatActivity : fun leaveRoom(functionToCallAfterLeave: (() -> Unit)?) { logConversationInfos("leaveRoom") + isLeavingRoom = true // Send the HPB "leave room" immediately, before waiting for the backend DELETE to // confirm. This minimises the window in which the HPB could still consider the user From 67a6b497061d20010643926fa3ea3b01dc4bb2dd Mon Sep 17 00:00:00 2001 From: Tarek Loubani Date: Thu, 2 Apr 2026 04:32:15 -0400 Subject: [PATCH 10/30] Fix call stability when backgrounding and add PIP/lifecycle diagnostic logging - Prevent spurious roomJoined events from re-running performCall() when already IN_CONVERSATION, fixing call reconnection when ChatActivity resumes behind PIP or task switch - Remove setAutoEnterEnabled(true) which conflicts with manual enterPictureInPictureMode() calls causing invisible PIP windows - Set aspect ratio in initial PIP params (onCreate) so PIP params are always valid - Add isInPipMode guard to onUserLeaveHint to prevent redundant PIP entry attempts - Add diagnostic logging to CallBaseActivity lifecycle methods and CallActivity PIP/call state transitions - Add unit tests documenting PIP race conditions and leaveRoom lifecycle behavior Signed-off-by: Tarek Loubani --- .../nextcloud/talk/activities/CallActivity.kt | 18 +- .../talk/activities/CallBaseActivity.java | 28 +- .../activities/CallBaseActivityPipTest.kt | 332 ++++++++++++ .../ChatActivityLeaveRoomLifecycleTest.kt | 487 ++++++++++++++++++ 4 files changed, 852 insertions(+), 13 deletions(-) create mode 100644 app/src/test/java/com/nextcloud/talk/activities/CallBaseActivityPipTest.kt create mode 100644 app/src/test/java/com/nextcloud/talk/activities/ChatActivityLeaveRoomLifecycleTest.kt diff --git a/app/src/main/java/com/nextcloud/talk/activities/CallActivity.kt b/app/src/main/java/com/nextcloud/talk/activities/CallActivity.kt index 1ea131fdc8..4cb3288cd5 100644 --- a/app/src/main/java/com/nextcloud/talk/activities/CallActivity.kt +++ b/app/src/main/java/com/nextcloud/talk/activities/CallActivity.kt @@ -756,6 +756,8 @@ class CallActivity : CallBaseActivity() { override fun onStop() { super.onStop() + Log.d(TAG, "CallActivity.onStop: isInPipMode=$isInPipMode currentCallStatus=$currentCallStatus" + + " isFinishing=$isFinishing isChangingConfigurations=$isChangingConfigurations") active = false if (isMicInputAudioThreadRunning) { @@ -2076,15 +2078,21 @@ class CallActivity : CallBaseActivity() { } "roomJoined" -> { - Log.d(TAG, "onMessageEvent 'roomJoined' joinRoomInitiated=$joinRoomInitiated") + Log.d(TAG, "onMessageEvent 'roomJoined' joinRoomInitiated=$joinRoomInitiated" + + " currentCallStatus=$currentCallStatus") if (!joinRoomInitiated) { Log.d(TAG, "Ignoring stale roomJoined event (joinRoomAndCall not yet called)") return } startSendingNick() if (webSocketCommunicationEvent.getHashMap()!!["roomToken"] == roomToken) { - roomJoinRefreshes = 0 - performCall() + if (currentCallStatus === CallStatus.IN_CONVERSATION) { + Log.d(TAG, "Already in conversation, skipping performCall()" + + " (ChatActivity resume triggered spurious roomJoined)") + } else { + roomJoinRefreshes = 0 + performCall() + } } } @@ -3239,8 +3247,8 @@ class CallActivity : CallBaseActivity() { override fun onPictureInPictureModeChanged(isInPictureInPictureMode: Boolean, newConfig: Configuration) { super.onPictureInPictureModeChanged(isInPictureInPictureMode, newConfig) - Log.d(TAG, "onPictureInPictureModeChanged") - Log.d(TAG, "isInPictureInPictureMode= $isInPictureInPictureMode") + Log.d(TAG, "onPictureInPictureModeChanged: isInPictureInPictureMode=$isInPictureInPictureMode" + + " currentCallStatus=$currentCallStatus isIntentionallyLeavingCall=$isIntentionallyLeavingCall") isInPipMode = isInPictureInPictureMode if (isInPictureInPictureMode) { mReceiver = object : BroadcastReceiver() { diff --git a/app/src/main/java/com/nextcloud/talk/activities/CallBaseActivity.java b/app/src/main/java/com/nextcloud/talk/activities/CallBaseActivity.java index 84b126a96c..d9f116dec8 100644 --- a/app/src/main/java/com/nextcloud/talk/activities/CallBaseActivity.java +++ b/app/src/main/java/com/nextcloud/talk/activities/CallBaseActivity.java @@ -59,10 +59,12 @@ public void onCreate(Bundle savedInstanceState) { if (isPipModePossible()) { mPictureInPictureParamsBuilder = new PictureInPictureParams.Builder(); - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { - mPictureInPictureParamsBuilder.setAutoEnterEnabled(true); - setPictureInPictureParams(mPictureInPictureParamsBuilder.build()); - } + Rational pipRatio = new Rational(300, 500); + mPictureInPictureParamsBuilder.setAspectRatio(pipRatio); + // Do NOT use setAutoEnterEnabled — it conflicts with manual enterPictureInPictureMode() + // calls, causing the PIP window to be invisible. Manual calls from + // onTopResumedActivityChanged fire early enough to work even on fast gestures. + setPictureInPictureParams(mPictureInPictureParamsBuilder.build()); } getOnBackPressedDispatcher().addCallback(this, onBackPressedCallback); @@ -108,11 +110,17 @@ void enableKeyguard() { @Override public void onTopResumedActivityChanged(boolean isTopResumedActivity) { super.onTopResumedActivityChanged(isTopResumedActivity); + Log.d(TAG, "onTopResumedActivityChanged: isTopResumedActivity=" + isTopResumedActivity + + " isInPipMode=" + isInPipMode); if (!isTopResumedActivity && !isInPipMode && isPipModePossible() && !isChangingConfigurations() && !isFinishing()) { + // Always call enterPipMode here — this fires while the window is still visible, + // so it works for both task switching (where auto-enter doesn't fire) and + // home gestures. On API 31+, auto-enter handles home gestures independently, + // but this manual call is needed for task switch (left/right swipe). enterPipMode(); } } @@ -120,8 +128,8 @@ && isPipModePossible() @Override public void onPause() { super.onPause(); - // Fallback for API 26-28 (no onTopResumedActivityChanged) and any edge cases - // where PIP was not yet entered by the time we reach onPause(). + Log.d(TAG, "onPause: isInPipMode=" + isInPipMode); + // Fallback: enter PIP if onTopResumedActivityChanged didn't already handle it. if (!isInPipMode && isPipModePossible() && !isChangingConfigurations() @@ -133,6 +141,7 @@ && isPipModePossible() @Override public void onStop() { super.onStop(); + Log.d(TAG, "onStop: isInPipMode=" + isInPipMode + " isFinishing=" + isFinishing()); // Don't automatically finish when going to background // Only finish if explicitly leaving the call if (shouldFinishOnStop() && !isChangingConfigurations()) { @@ -148,16 +157,19 @@ protected void onUserLeaveHint() { super.onUserLeaveHint(); long onUserLeaveHintTime = System.currentTimeMillis(); long diff = onUserLeaveHintTime - onCreateTime; - Log.d(TAG, "onUserLeaveHintTime - onCreateTime: " + diff); + Log.d(TAG, "onUserLeaveHint: diff=" + diff + " isInPipMode=" + isInPipMode); if (diff < 3000) { - Log.d(TAG, "enterPipMode skipped"); + Log.d(TAG, "enterPipMode skipped (too soon after onCreate)"); + } else if (isInPipMode) { + Log.d(TAG, "enterPipMode skipped (already in PIP)"); } else { enterPipMode(); } } void enterPipMode() { + Log.d(TAG, "enterPipMode: isPipModePossible=" + isPipModePossible() + " isInPipMode=" + isInPipMode); enableKeyguard(); if (isPipModePossible()) { Rational pipRatio = new Rational(300, 500); diff --git a/app/src/test/java/com/nextcloud/talk/activities/CallBaseActivityPipTest.kt b/app/src/test/java/com/nextcloud/talk/activities/CallBaseActivityPipTest.kt new file mode 100644 index 0000000000..9b99e5137b --- /dev/null +++ b/app/src/test/java/com/nextcloud/talk/activities/CallBaseActivityPipTest.kt @@ -0,0 +1,332 @@ +/* + * Nextcloud Talk - Android Client + * + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: GPL-3.0-or-later + */ +package com.nextcloud.talk.activities + +import android.os.Build +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test + +/** + * Tests documenting the PIP lifecycle race conditions in CallBaseActivity. + * + * These tests model the PIP entry decision logic without depending on the Android + * framework (PictureInPictureParams, Activity, etc.). They verify the state machine + * that determines whether and how PIP is entered, and document the race conditions + * that cause PIP to fail on fast navigation gestures. + */ +class CallBaseActivityPipTest { + + // Simulated CallBaseActivity state + private var isInPipMode = false + private var isPipModePossible = true + private var isChangingConfigurations = false + private var isFinishing = false + private var autoEnterEnabled = false + + // Tracking + private var enterPipModeCallCount = 0 + private var enableKeyguardCallCount = 0 + private var enterPictureInPictureModeCalled = false + + // Simulate enterPipMode() + private fun enterPipMode() { + enableKeyguardCallCount++ + if (isPipModePossible) { + enterPictureInPictureModeCalled = true + enterPipModeCallCount++ + } + } + + @Before + fun setUp() { + isInPipMode = false + isPipModePossible = true + isChangingConfigurations = false + isFinishing = false + autoEnterEnabled = false + enterPipModeCallCount = 0 + enableKeyguardCallCount = 0 + enterPictureInPictureModeCalled = false + } + + // ========================================== + // Tests documenting the triple-call race condition + // ========================================== + + /** + * Documents: On API 31+ with setAutoEnterEnabled(true), there are THREE concurrent + * PIP entry attempts when the user navigates away. + * + * 1. System auto-enter (from setAutoEnterEnabled) + * 2. Manual call from onTopResumedActivityChanged + * 3. Manual call from onPause + * + * The isInPipMode guard should prevent #3 after #2 succeeds, but + * onPictureInPictureModeChanged (which sets isInPipMode=true) fires asynchronously. + * So both #2 and #3 can execute before isInPipMode becomes true. + */ + @Test + fun `triple PIP entry race - all three calls fire before isInPipMode is set`() { + autoEnterEnabled = true // API 31+ with setAutoEnterEnabled(true) + + // System auto-enter fires (internal, we can't track it directly) + // But the manual calls below race with it + + // Call from onTopResumedActivityChanged + if (!isInPipMode && isPipModePossible && !isChangingConfigurations && !isFinishing) { + enterPipMode() + } + + // onPictureInPictureModeChanged has NOT fired yet (async) + // So isInPipMode is still false + + // Call from onPause + if (!isInPipMode && isPipModePossible && !isChangingConfigurations && !isFinishing) { + enterPipMode() + } + + assertEquals( + "Both manual enterPipMode calls fire (racing with system auto-enter)", + 2, + enterPipModeCallCount + ) + assertEquals( + "enableKeyguard called twice (side effect during PIP transition)", + 2, + enableKeyguardCallCount + ) + } + + /** + * After onPictureInPictureModeChanged fires, the guard prevents further calls. + */ + @Test + fun `isInPipMode guard works after async callback fires`() { + // First call succeeds + if (!isInPipMode && isPipModePossible) { + enterPipMode() + } + + // onPictureInPictureModeChanged fires + isInPipMode = true + + // Second call is blocked + if (!isInPipMode && isPipModePossible) { + enterPipMode() + } + + assertEquals("Only one call should succeed after guard activates", 1, enterPipModeCallCount) + } + + // ========================================== + // Tests for the fix: skip manual calls on API 31+ + // ========================================== + + /** + * FIX: On API 31+, skip manual enterPipMode() calls. Let auto-enter handle PIP. + * This eliminates the triple-call race and the enableKeyguard side effect. + */ + @Test + fun `skipping manual calls on API 31 plus eliminates race`() { + autoEnterEnabled = true + val isApiS = true // Simulating API 31+ + + // onTopResumedActivityChanged — skipped on API 31+ + if (!isApiS) { + if (!isInPipMode && isPipModePossible && !isChangingConfigurations && !isFinishing) { + enterPipMode() + } + } + + // onPause — skipped on API 31+ + if (!isApiS) { + if (!isInPipMode && isPipModePossible && !isChangingConfigurations && !isFinishing) { + enterPipMode() + } + } + + assertEquals("No manual PIP calls on API 31+", 0, enterPipModeCallCount) + assertEquals("enableKeyguard not called (no side effects)", 0, enableKeyguardCallCount) + } + + /** + * On API 26-30, manual calls are still needed since auto-enter is not available. + */ + @Test + fun `manual calls still work on pre-API 31`() { + autoEnterEnabled = false + val isApiS = false // Simulating API 26-30 + + // onTopResumedActivityChanged + if (!isApiS) { + if (!isInPipMode && isPipModePossible && !isChangingConfigurations && !isFinishing) { + enterPipMode() + } + } + + // Simulate: onPictureInPictureModeChanged fires synchronously (for testing) + isInPipMode = true + + // onPause — guarded by isInPipMode + if (!isApiS) { + if (!isInPipMode && isPipModePossible && !isChangingConfigurations && !isFinishing) { + enterPipMode() + } + } + + assertEquals("One manual call succeeds on pre-API 31", 1, enterPipModeCallCount) + } + + // ========================================== + // Tests for fast vs slow gesture behavior + // ========================================== + + /** + * Documents: On a SLOW gesture, onTopResumedActivityChanged fires while the window + * is still visible, so manual enterPictureInPictureMode succeeds. + */ + @Test + fun `slow gesture - manual enterPipMode succeeds (window still visible)`() { + val windowVisible = true // Slow gesture: window is still on screen + + if (!isInPipMode && isPipModePossible && windowVisible) { + enterPipMode() + } + + assertEquals("Manual PIP entry succeeds when window is visible", 1, enterPipModeCallCount) + } + + /** + * Documents: On a FAST gesture, the window has already moved off-screen by the time + * manual enterPipMode fires. enterPictureInPictureMode silently fails. + * Only setAutoEnterEnabled can handle this case (API 31+). + */ + @Test + fun `fast gesture - manual enterPipMode fails (window off-screen)`() { + val windowVisible = false // Fast gesture: window already moved off-screen + var pipEnteredSuccessfully = false + + if (!isInPipMode && isPipModePossible && windowVisible) { + enterPipMode() + pipEnteredSuccessfully = true + } + + assertFalse("Manual PIP entry fails when window is off-screen", pipEnteredSuccessfully) + assertEquals("enterPipMode was not called", 0, enterPipModeCallCount) + } + + /** + * Documents: setAutoEnterEnabled handles fast gestures because the system enters + * PIP at the framework level, before the window transition animation. + */ + @Test + fun `fast gesture with auto-enter - PIP succeeds without manual call`() { + autoEnterEnabled = true + val isApiS = true + val windowVisible = false // Fast gesture + + // Manual calls are skipped on API 31+ + if (!isApiS) { + if (!isInPipMode && isPipModePossible && windowVisible) { + enterPipMode() + } + } + + // No manual calls fired + assertEquals("No manual calls on API 31+", 0, enterPipModeCallCount) + + // System auto-enter handles PIP (simulated) + if (autoEnterEnabled && isPipModePossible) { + isInPipMode = true // System enters PIP successfully + } + + assertTrue("Auto-enter succeeds regardless of window visibility", isInPipMode) + } + + // ========================================== + // Tests for PIP entry guard conditions + // ========================================== + + @Test + fun `PIP is not entered when activity is finishing`() { + isFinishing = true + + if (!isInPipMode && isPipModePossible && !isChangingConfigurations && !isFinishing) { + enterPipMode() + } + + assertEquals("Should not enter PIP when finishing", 0, enterPipModeCallCount) + } + + @Test + fun `PIP is not entered during configuration change`() { + isChangingConfigurations = true + + if (!isInPipMode && isPipModePossible && !isChangingConfigurations && !isFinishing) { + enterPipMode() + } + + assertEquals("Should not enter PIP during config change", 0, enterPipModeCallCount) + } + + @Test + fun `PIP is not entered when PIP is not possible`() { + isPipModePossible = false + + if (!isInPipMode && isPipModePossible && !isChangingConfigurations && !isFinishing) { + enterPipMode() + } + + assertEquals("Should not enter PIP when not possible", 0, enterPipModeCallCount) + } + + // ========================================== + // Test for auto-enter params requirement + // ========================================== + + /** + * Documents: setAutoEnterEnabled(true) requires valid PIP params (including aspect + * ratio) to be set via setPictureInPictureParams() BEFORE the transition happens. + * + * CURRENT BUG: onCreate sets setAutoEnterEnabled(true) but the aspect ratio is only + * set in enterPipMode() (which is called manually and may not fire on fast gestures). + * Without the aspect ratio in the initial params, auto-enter silently fails. + * + * FIX: Set the aspect ratio in onCreate when building the initial PIP params. + */ + @Test + fun `auto-enter requires aspect ratio in initial params`() { + var aspectRatioSetInOnCreate = false + var aspectRatioSetInEnterPipMode = false + + // Simulate onCreate (CURRENT BUG: no aspect ratio) + autoEnterEnabled = true + // mPictureInPictureParamsBuilder.setAutoEnterEnabled(true) + // setPictureInPictureParams(builder.build()) ← no aspect ratio! + + // Simulate enterPipMode (aspect ratio set here, but may not be called) + fun enterPipModeWithRatio() { + aspectRatioSetInEnterPipMode = true + } + + // Fast gesture: enterPipMode never called, so aspect ratio never set + val windowVisible = false + if (windowVisible) { + enterPipModeWithRatio() + } + + assertFalse("Aspect ratio was NOT set (fast gesture skipped enterPipMode)", aspectRatioSetInEnterPipMode) + + // FIX: set aspect ratio in onCreate + aspectRatioSetInOnCreate = true + + assertTrue("FIX: Aspect ratio should be set in onCreate", aspectRatioSetInOnCreate) + } +} diff --git a/app/src/test/java/com/nextcloud/talk/activities/ChatActivityLeaveRoomLifecycleTest.kt b/app/src/test/java/com/nextcloud/talk/activities/ChatActivityLeaveRoomLifecycleTest.kt new file mode 100644 index 0000000000..f60fbf418c --- /dev/null +++ b/app/src/test/java/com/nextcloud/talk/activities/ChatActivityLeaveRoomLifecycleTest.kt @@ -0,0 +1,487 @@ +/* + * Nextcloud Talk - Android Client + * + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: GPL-3.0-or-later + */ +package com.nextcloud.talk.activities + +import androidx.arch.core.executor.testing.InstantTaskExecutorRule +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.LifecycleOwner +import androidx.lifecycle.LifecycleRegistry +import androidx.lifecycle.LiveData +import androidx.lifecycle.MutableLiveData +import com.nextcloud.talk.utils.singletons.ApplicationWideCurrentRoomHolder +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Rule +import org.junit.Test + +/** + * Tests documenting the leaveRoom lifecycle race conditions in ChatActivity and how + * the observeForever fix addresses them. + * + * Core problem: ChatActivity.onPause() calls leaveRoom() which is async. The LiveData + * observer for the leave response was lifecycle-aware (observe(this)), so it wouldn't + * deliver when the activity was paused. This meant: + * 1. Websocket cleanup never happened (server still thought user was in room) + * 2. ApplicationWideCurrentRoomHolder was cleared prematurely (before server confirmed) + * 3. switchToRoom callbacks could be lost during navigation gestures + * + * The fix uses observeForever so the callback always fires, with guards to prevent + * disrupting active calls. + */ +class ChatActivityLeaveRoomLifecycleTest { + + @get:Rule + val instantExecutorRule = InstantTaskExecutorRule() + + // Simulates the leaveRoomViewState LiveData from ChatViewModel + private val leaveRoomViewState = MutableLiveData(LeaveRoomStartState) + + // Simulates ApplicationWideCurrentRoomHolder state + private var holderIsInCall = false + private var holderIsDialing = false + private var holderCleared = false + + // Simulates ChatActivity state + private var isLeavingRoom = false + private var sessionIdAfterRoomJoined: String? = "valid-session" + private var websocketLeaveRoomCalled = false + private var callbackInvoked = false + + // Simulates the lifecycle of ChatActivity + private lateinit var lifecycleOwner: TestLifecycleOwner + + sealed interface LeaveState + object LeaveRoomStartState : LeaveState + class LeaveRoomSuccessState(val funToCallWhenLeaveSuccessful: (() -> Unit)?) : LeaveState + + private fun isNotInCall(): Boolean = !holderIsInCall && !holderIsDialing + + private fun simulateLeaveRoomObserverAction(state: LeaveState) { + when (state) { + is LeaveRoomSuccessState -> { + isLeavingRoom = false + + if (isNotInCall()) { + holderCleared = true // ApplicationWideCurrentRoomHolder.clear() + + websocketLeaveRoomCalled = true // websocket leave + sessionIdAfterRoomJoined = "0" + } + + state.funToCallWhenLeaveSuccessful?.invoke() + } + else -> {} + } + } + + @Before + fun setUp() { + lifecycleOwner = TestLifecycleOwner() + holderIsInCall = false + holderIsDialing = false + holderCleared = false + isLeavingRoom = false + sessionIdAfterRoomJoined = "valid-session" + websocketLeaveRoomCalled = false + callbackInvoked = false + } + + @After + fun tearDown() { + // Reset singleton state + ApplicationWideCurrentRoomHolder.getInstance().clear() + } + + // ========================================== + // Tests for the OLD behavior (lifecycle-aware observer) + // These document the bugs that existed before the fix + // ========================================== + + /** + * BUG: Lifecycle-aware observer doesn't deliver when activity is stopped. + * + * When ChatActivity.onPause() calls leaveRoom(), the async network call takes time. + * By the time it completes, the activity has progressed to STOPPED (onStop has run). + * LiveData's observe(this) only delivers to STARTED or RESUMED observers, so the + * leave response is never received. The websocket cleanup never happens. + * + * Note: LiveData considers STARTED (after onStart, before onStop) as active. + * ON_PAUSE moves to STARTED which is still active. ON_STOP moves to CREATED which + * is inactive. In practice, the network response arrives after onStop, not just + * onPause, so the observer misses it. + */ + @Test + fun `lifecycle-aware observer misses leave response when activity is stopped`() { + // Start in RESUMED state + lifecycleOwner.handleLifecycleEvent(Lifecycle.Event.ON_RESUME) + + var observerReceived = false + + // Register lifecycle-aware observer (old behavior) + leaveRoomViewState.observe(lifecycleOwner) { state -> + if (state is LeaveRoomSuccessState) { + observerReceived = true + } + } + + // Activity goes through onPause → onStop (normal navigation away) + lifecycleOwner.handleLifecycleEvent(Lifecycle.Event.ON_STOP) + + // Network call completes, LiveData is set while activity is stopped + leaveRoomViewState.value = LeaveRoomSuccessState(null) + + // Observer did NOT receive the value — websocket cleanup never happens + assertFalse( + "Lifecycle-aware observer should NOT deliver when stopped (this is the bug)", + observerReceived + ) + } + + /** + * FIX: observeForever delivers even when activity is paused. + */ + @Test + fun `observeForever delivers leave response even when activity is paused`() { + var observerReceived = false + + // Register observeForever (the fix) + val observer = androidx.lifecycle.Observer { state -> + if (state is LeaveRoomSuccessState) { + observerReceived = true + simulateLeaveRoomObserverAction(state) + } + } + leaveRoomViewState.observeForever(observer) + + // Simulate: activity is paused, leave response arrives + leaveRoomViewState.value = LeaveRoomSuccessState(null) + + // Observer DOES receive the value — cleanup happens + assertTrue("observeForever should deliver regardless of lifecycle", observerReceived) + assertTrue("Holder should be cleared", holderCleared) + assertTrue("Websocket leave should be called", websocketLeaveRoomCalled) + assertEquals("Session should be reset", "0", sessionIdAfterRoomJoined) + + leaveRoomViewState.removeObserver(observer) + } + + // ========================================== + // Tests for the isNotInCall guard + // ========================================== + + /** + * When a call is active (isInCall=true), the leave observer must NOT clear the + * holder or send websocket leave — doing so would kill the active call/PIP. + */ + @Test + fun `leave observer skips cleanup when call is active`() { + holderIsInCall = true + + val observer = androidx.lifecycle.Observer { state -> + if (state is LeaveRoomSuccessState) { + simulateLeaveRoomObserverAction(state) + } + } + leaveRoomViewState.observeForever(observer) + + leaveRoomViewState.value = LeaveRoomSuccessState(null) + + assertFalse("Holder should NOT be cleared during active call", holderCleared) + assertFalse("Websocket leave should NOT be called during active call", websocketLeaveRoomCalled) + assertEquals( + "Session should NOT be reset during active call", + "valid-session", + sessionIdAfterRoomJoined + ) + + leaveRoomViewState.removeObserver(observer) + } + + /** + * When dialing (isDialing=true), the leave observer must NOT clear the holder. + */ + @Test + fun `leave observer skips cleanup when dialing`() { + holderIsDialing = true + + val observer = androidx.lifecycle.Observer { state -> + if (state is LeaveRoomSuccessState) { + simulateLeaveRoomObserverAction(state) + } + } + leaveRoomViewState.observeForever(observer) + + leaveRoomViewState.value = LeaveRoomSuccessState(null) + + assertFalse("Holder should NOT be cleared while dialing", holderCleared) + assertFalse("Websocket leave should NOT be called while dialing", websocketLeaveRoomCalled) + + leaveRoomViewState.removeObserver(observer) + } + + /** + * When no call is active, the leave observer SHOULD perform full cleanup. + */ + @Test + fun `leave observer performs cleanup when no call is active`() { + holderIsInCall = false + holderIsDialing = false + + val observer = androidx.lifecycle.Observer { state -> + if (state is LeaveRoomSuccessState) { + simulateLeaveRoomObserverAction(state) + } + } + leaveRoomViewState.observeForever(observer) + + leaveRoomViewState.value = LeaveRoomSuccessState(null) + + assertTrue("Holder should be cleared when no call active", holderCleared) + assertTrue("Websocket leave should be called when no call active", websocketLeaveRoomCalled) + assertEquals("Session should be reset", "0", sessionIdAfterRoomJoined) + + leaveRoomViewState.removeObserver(observer) + } + + // ========================================== + // Tests for the callback (switchToRoom) behavior + // ========================================== + + /** + * The switchToRoom callback must fire even when the activity is paused. + * This ensures the new ChatActivity is launched after the room is left. + */ + @Test + fun `switchToRoom callback fires via observeForever even when paused`() { + val observer = androidx.lifecycle.Observer { state -> + if (state is LeaveRoomSuccessState) { + simulateLeaveRoomObserverAction(state) + } + } + leaveRoomViewState.observeForever(observer) + + leaveRoomViewState.value = LeaveRoomSuccessState { + callbackInvoked = true + } + + assertTrue("Callback should be invoked", callbackInvoked) + + leaveRoomViewState.removeObserver(observer) + } + + /** + * The switchToRoom callback must still fire even when a call is active — + * only the holder/websocket cleanup is skipped, not the callback. + */ + @Test + fun `switchToRoom callback fires even during active call`() { + holderIsInCall = true + + val observer = androidx.lifecycle.Observer { state -> + if (state is LeaveRoomSuccessState) { + simulateLeaveRoomObserverAction(state) + } + } + leaveRoomViewState.observeForever(observer) + + leaveRoomViewState.value = LeaveRoomSuccessState { + callbackInvoked = true + } + + assertTrue("Callback should fire even during active call", callbackInvoked) + assertFalse("But holder should NOT be cleared", holderCleared) + + leaveRoomViewState.removeObserver(observer) + } + + // ========================================== + // Tests for the isLeavingRoom guard (double-leave prevention) + // ========================================== + + /** + * Documents the double-leave race: switchToRoom calls leaveRoom, then onPause + * fires and tries to call leaveRoom again. The isLeavingRoom flag prevents this. + */ + @Test + fun `isLeavingRoom prevents double leave when switchToRoom is in progress`() { + var leaveRoomCallCount = 0 + + fun leaveRoom() { + isLeavingRoom = true + leaveRoomCallCount++ + } + + fun simulateOnPause() { + if (isNotInCall()) { + if (isLeavingRoom) { + // Skip — leave already in progress + } else { + leaveRoom() + } + } + } + + // switchToRoom calls leaveRoom first + leaveRoom() + assertEquals("First leave should fire", 1, leaveRoomCallCount) + + // onPause fires while the first leave is in progress + simulateOnPause() + assertEquals("Second leave should be skipped", 1, leaveRoomCallCount) + } + + /** + * After a leave completes, isLeavingRoom is reset, allowing future leaves. + */ + @Test + fun `isLeavingRoom is reset after leave completes`() { + isLeavingRoom = true + + val observer = androidx.lifecycle.Observer { state -> + if (state is LeaveRoomSuccessState) { + simulateLeaveRoomObserverAction(state) + } + } + leaveRoomViewState.observeForever(observer) + + leaveRoomViewState.value = LeaveRoomSuccessState(null) + + assertFalse("isLeavingRoom should be reset after leave completes", isLeavingRoom) + + leaveRoomViewState.removeObserver(observer) + } + + // ========================================== + // Tests for the ApplicationWideCurrentRoomHolder timing + // ========================================== + + /** + * BUG (old behavior): Holder was cleared in onPause BEFORE the server confirmed + * the leave. A new ChatActivity resuming concurrently would find the holder empty + * and lose session continuity. + * + * FIX: Holder is now cleared in the leave success callback, after server confirms. + */ + @Test + fun `holder is cleared only after server confirms leave`() { + val holder = ApplicationWideCurrentRoomHolder.getInstance() + holder.currentRoomToken = "room1" + holder.session = "session1" + + val observer = androidx.lifecycle.Observer { state -> + if (state is LeaveRoomSuccessState) { + simulateLeaveRoomObserverAction(state) + } + } + leaveRoomViewState.observeForever(observer) + + // Before server responds, holder should still have data + // (In old code, holder.clear() was called immediately in onPause) + assertEquals("room1", holder.currentRoomToken) + assertEquals("session1", holder.session) + + // Server confirms leave + leaveRoomViewState.value = LeaveRoomSuccessState(null) + + // NOW holder is cleared + assertTrue("Holder should be cleared after server confirms", holderCleared) + + leaveRoomViewState.removeObserver(observer) + } + + // ========================================== + // Test for the PIP + leaveRoom interaction + // ========================================== + + /** + * Documents the critical PIP interaction: when a call is active (in PIP or full), + * navigating away from ChatActivity must NOT clear the holder or send websocket + * leave, as this would end the call. + * + * This is the exact scenario the user reported: "the call ends when I shift away + * from it and then tries to reconnect when I make the video live again." + */ + @Test + fun `navigating away from chat during active call preserves call state`() { + val holder = ApplicationWideCurrentRoomHolder.getInstance() + holder.currentRoomToken = "room1" + holder.session = "session1" + holder.isInCall = true + holderIsInCall = true + + val observer = androidx.lifecycle.Observer { state -> + if (state is LeaveRoomSuccessState) { + simulateLeaveRoomObserverAction(state) + } + } + leaveRoomViewState.observeForever(observer) + + // Simulate: a previous leave request completes while call is active + leaveRoomViewState.value = LeaveRoomSuccessState(null) + + // Call state should be PRESERVED + assertFalse("Holder should NOT be cleared during call", holderCleared) + assertFalse("Websocket leave should NOT fire during call", websocketLeaveRoomCalled) + assertTrue("Holder should still show in-call", holder.isInCall) + assertEquals("Room token should be preserved", "room1", holder.currentRoomToken) + assertEquals("Session should be preserved", "session1", holder.session) + + leaveRoomViewState.removeObserver(observer) + } + + /** + * After a call ends (isInCall becomes false), the next leave should perform + * full cleanup. + */ + @Test + fun `after call ends, leave performs full cleanup`() { + // Call was active + holderIsInCall = true + + val observer = androidx.lifecycle.Observer { state -> + if (state is LeaveRoomSuccessState) { + simulateLeaveRoomObserverAction(state) + } + } + leaveRoomViewState.observeForever(observer) + + // Leave during call — skipped + leaveRoomViewState.value = LeaveRoomSuccessState(null) + assertFalse("Cleanup skipped during call", holderCleared) + + // Call ends + holderIsInCall = false + + // Reset LiveData to trigger again + leaveRoomViewState.value = LeaveRoomStartState + leaveRoomViewState.value = LeaveRoomSuccessState(null) + + // Now cleanup happens + assertTrue("Cleanup should happen after call ends", holderCleared) + assertTrue("Websocket leave should fire after call ends", websocketLeaveRoomCalled) + + leaveRoomViewState.removeObserver(observer) + } + + // ========================================== + // Helper: TestLifecycleOwner + // ========================================== + + private class TestLifecycleOwner : LifecycleOwner { + private val registry = LifecycleRegistry(this) + + override val lifecycle: Lifecycle + get() = registry + + fun handleLifecycleEvent(event: Lifecycle.Event) { + registry.handleLifecycleEvent(event) + } + } +} From 58e3ec2ab9bf4bc1cb1144dfaff45ae6d400aa70 Mon Sep 17 00:00:00 2001 From: Tarek Loubani Date: Thu, 2 Apr 2026 17:51:52 -0400 Subject: [PATCH 11/30] Rewrite PIP entry to follow Android docs: auto-enter + onTopResumedActivityChanged fallback The previous approach had three competing PIP entry mechanisms on API 31+ (auto-enter, onTopResumedActivityChanged, onPause) that raced against each other, and onTopResumedActivityChanged toggled setAutoEnterEnabled off/on which broke smooth transitions. New layered approach per the Android PIP documentation: - API 31+: setAutoEnterEnabled(true) as primary for home/recents gestures - API 29+: onTopResumedActivityChanged as fallback (fires while window is still visible, catches quick-switch gestures auto-enter misses) - API 26-30: onUserLeaveHint for home/recents, onPause fallback for 26-28 - All APIs: OnBackPressedCallback for back gesture (only manual entry point) Key fix: onTopResumedActivityChanged no longer disables auto-enter. It checks isInPictureInPictureMode() so if auto-enter already handled it, the manual call is skipped. No races, no toggling. Also removes shouldFinishOnStop, pipFallbackHandler, topResumedLostTime, and onCreateTime which were artifacts of the old racing approach. Signed-off-by: Tarek Loubani --- .../nextcloud/talk/activities/CallActivity.kt | 19 +- .../talk/activities/CallBaseActivity.java | 65 ++-- .../activities/CallBaseActivityPipTest.kt | 296 ++++++------------ 3 files changed, 133 insertions(+), 247 deletions(-) diff --git a/app/src/main/java/com/nextcloud/talk/activities/CallActivity.kt b/app/src/main/java/com/nextcloud/talk/activities/CallActivity.kt index 4cb3288cd5..2271bf7409 100644 --- a/app/src/main/java/com/nextcloud/talk/activities/CallActivity.kt +++ b/app/src/main/java/com/nextcloud/talk/activities/CallActivity.kt @@ -3121,6 +3121,13 @@ class CallActivity : CallBaseActivity() { override fun onIceConnectionStateChanged(iceConnectionState: IceConnectionState) { runOnUiThread { if (iceConnectionState == IceConnectionState.FAILED) { + // Don't hang up if the activity is just backgrounded (e.g., task switching). + // The ICE failure is likely transient due to the activity being stopped. + // The connection will recover when the activity resumes. + if (!active && currentCallStatus === CallStatus.IN_CONVERSATION) { + Log.d(TAG, "ICE FAILED while backgrounded, skipping hangup (will recover on resume)") + return@runOnUiThread + } setCallState(CallStatus.PUBLISHER_FAILED) webSocketClient!!.clearResumeId() hangup(false, false) @@ -3297,8 +3304,15 @@ class CallActivity : CallBaseActivity() { } } + private var pipUiInitialized = false + override fun updateUiForPipMode() { - Log.d(TAG, "updateUiForPipMode") + Log.d(TAG, "updateUiForPipMode: pipUiInitialized=$pipUiInitialized") + if (pipUiInitialized) { + return + } + pipUiInitialized = true + binding!!.callControls.visibility = View.GONE binding!!.selfVideoViewWrapper.visibility = View.GONE binding!!.callStates.callStateRelativeLayout.visibility = View.GONE @@ -3316,7 +3330,7 @@ class CallActivity : CallBaseActivity() { try { binding!!.pipSelfVideoRenderer.init(rootEglBase!!.eglBaseContext, null) } catch (e: IllegalStateException) { - Log.d(TAG, "pipGroupVideoRenderer already initialized", e) + Log.d(TAG, "pipSelfVideoRenderer already initialized", e) } binding!!.pipSelfVideoRenderer.setZOrderMediaOverlay(true) // disabled because it causes some devices to crash @@ -3333,6 +3347,7 @@ class CallActivity : CallBaseActivity() { override fun updateUiForNormalMode() { Log.d(TAG, "updateUiForNormalMode") + pipUiInitialized = false binding!!.pipOverlay.visibility = View.GONE binding!!.composeParticipantGrid.visibility = View.VISIBLE diff --git a/app/src/main/java/com/nextcloud/talk/activities/CallBaseActivity.java b/app/src/main/java/com/nextcloud/talk/activities/CallBaseActivity.java index d9f116dec8..a4a1e8c85b 100644 --- a/app/src/main/java/com/nextcloud/talk/activities/CallBaseActivity.java +++ b/app/src/main/java/com/nextcloud/talk/activities/CallBaseActivity.java @@ -13,7 +13,6 @@ import android.content.pm.PackageManager; import android.os.Build; import android.os.Bundle; -import android.os.PowerManager; import android.util.Log; import android.util.Rational; import android.view.View; @@ -30,8 +29,6 @@ public abstract class CallBaseActivity extends BaseActivity { public PictureInPictureParams.Builder mPictureInPictureParamsBuilder; public Boolean isInPipMode = Boolean.FALSE; - long onCreateTime; - private final OnBackPressedCallback onBackPressedCallback = new OnBackPressedCallback(true) { @Override @@ -39,7 +36,6 @@ public void handleOnBackPressed() { if (isPipModePossible()) { enterPipMode(); } else { - // Move the task to background instead of finishing moveTaskToBack(true); } } @@ -50,8 +46,6 @@ public void handleOnBackPressed() { public void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); - onCreateTime = System.currentTimeMillis(); - requestWindowFeature(Window.FEATURE_NO_TITLE); dismissKeyguard(); getWindow().addFlags(WindowManager.LayoutParams.FLAG_FULLSCREEN); @@ -61,9 +55,9 @@ public void onCreate(Bundle savedInstanceState) { mPictureInPictureParamsBuilder = new PictureInPictureParams.Builder(); Rational pipRatio = new Rational(300, 500); mPictureInPictureParamsBuilder.setAspectRatio(pipRatio); - // Do NOT use setAutoEnterEnabled — it conflicts with manual enterPictureInPictureMode() - // calls, causing the PIP window to be invisible. Manual calls from - // onTopResumedActivityChanged fire early enough to work even on fast gestures. + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + mPictureInPictureParamsBuilder.setAutoEnterEnabled(true); + } setPictureInPictureParams(mPictureInPictureParamsBuilder.build()); } @@ -101,26 +95,22 @@ void enableKeyguard() { } /** - * Fired on API 29+ when another activity becomes the top resumed activity — including - * same-app task switches (e.g. task switcher or quick-switch gesture to the chat window). - * This fires *before* onPause() while our window is still fully visible, so - * enterPictureInPictureMode() can succeed. On API 26-28 this method is never called by - * the system; onPause() below serves as the fallback for those devices. + * On API 29+, fires BEFORE onPause while the window is still fully visible. + * This is the earliest point where we can detect that another activity is taking over + * (including quick-switch gestures that setAutoEnterEnabled doesn't always catch). + * We do NOT disable auto-enter here — if auto-enter already handled it, + * isInPictureInPictureMode() will be true and this is a no-op. */ @Override public void onTopResumedActivityChanged(boolean isTopResumedActivity) { super.onTopResumedActivityChanged(isTopResumedActivity); Log.d(TAG, "onTopResumedActivityChanged: isTopResumedActivity=" + isTopResumedActivity - + " isInPipMode=" + isInPipMode); + + " isInPictureInPictureMode=" + isInPictureInPictureMode()); if (!isTopResumedActivity - && !isInPipMode + && !isInPictureInPictureMode() && isPipModePossible() && !isChangingConfigurations() && !isFinishing()) { - // Always call enterPipMode here — this fires while the window is still visible, - // so it works for both task switching (where auto-enter doesn't fire) and - // home gestures. On API 31+, auto-enter handles home gestures independently, - // but this manual call is needed for task switch (left/right swipe). enterPipMode(); } } @@ -128,9 +118,12 @@ && isPipModePossible() @Override public void onPause() { super.onPause(); - Log.d(TAG, "onPause: isInPipMode=" + isInPipMode); - // Fallback: enter PIP if onTopResumedActivityChanged didn't already handle it. - if (!isInPipMode + Log.d(TAG, "onPause: isInPipMode=" + isInPipMode + + " isInPictureInPictureMode=" + isInPictureInPictureMode()); + // Fallback for API 26-28 where onTopResumedActivityChanged doesn't exist. + // On API 29+, onTopResumedActivityChanged already handled this. + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q + && !isInPictureInPictureMode() && isPipModePossible() && !isChangingConfigurations() && !isFinishing()) { @@ -142,28 +135,17 @@ && isPipModePossible() public void onStop() { super.onStop(); Log.d(TAG, "onStop: isInPipMode=" + isInPipMode + " isFinishing=" + isFinishing()); - // Don't automatically finish when going to background - // Only finish if explicitly leaving the call - if (shouldFinishOnStop() && !isChangingConfigurations()) { - // Check if we're really leaving the call or just backgrounding - if (isFinishing()) { - finish(); - } - } } @Override protected void onUserLeaveHint() { super.onUserLeaveHint(); - long onUserLeaveHintTime = System.currentTimeMillis(); - long diff = onUserLeaveHintTime - onCreateTime; - Log.d(TAG, "onUserLeaveHint: diff=" + diff + " isInPipMode=" + isInPipMode); - - if (diff < 3000) { - Log.d(TAG, "enterPipMode skipped (too soon after onCreate)"); - } else if (isInPipMode) { - Log.d(TAG, "enterPipMode skipped (already in PIP)"); - } else { + Log.d(TAG, "onUserLeaveHint: isInPipMode=" + isInPipMode); + // On API 31+, setAutoEnterEnabled(true) handles this automatically. + // On API 26-30, we must enter PIP manually here. + if (!isInPipMode + && isPipModePossible() + && Build.VERSION.SDK_INT < Build.VERSION_CODES.S) { enterPipMode(); } } @@ -174,7 +156,8 @@ void enterPipMode() { if (isPipModePossible()) { Rational pipRatio = new Rational(300, 500); mPictureInPictureParamsBuilder.setAspectRatio(pipRatio); - enterPictureInPictureMode(mPictureInPictureParamsBuilder.build()); + boolean entered = enterPictureInPictureMode(mPictureInPictureParamsBuilder.build()); + Log.d(TAG, "enterPictureInPictureMode returned: " + entered); } else { // If PIP is not available, move to background instead of finishing Log.d(TAG, "PIP is not available, moving call to background."); diff --git a/app/src/test/java/com/nextcloud/talk/activities/CallBaseActivityPipTest.kt b/app/src/test/java/com/nextcloud/talk/activities/CallBaseActivityPipTest.kt index 9b99e5137b..faced2ab24 100644 --- a/app/src/test/java/com/nextcloud/talk/activities/CallBaseActivityPipTest.kt +++ b/app/src/test/java/com/nextcloud/talk/activities/CallBaseActivityPipTest.kt @@ -6,7 +6,6 @@ */ package com.nextcloud.talk.activities -import android.os.Build import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse import org.junit.Assert.assertTrue @@ -14,32 +13,30 @@ import org.junit.Before import org.junit.Test /** - * Tests documenting the PIP lifecycle race conditions in CallBaseActivity. + * Tests for the PIP entry logic in CallBaseActivity. * - * These tests model the PIP entry decision logic without depending on the Android - * framework (PictureInPictureParams, Activity, etc.). They verify the state machine - * that determines whether and how PIP is entered, and document the race conditions - * that cause PIP to fail on fast navigation gestures. + * The approach follows the Android PIP documentation: + * - API 31+: setAutoEnterEnabled(true) handles home/recents/swipe gestures automatically. + * Only the back gesture needs manual enterPipMode() via OnBackPressedCallback. + * - API 26-30: onUserLeaveHint() handles home/recents. OnBackPressedCallback handles back. + * + * Key insight: onTopResumedActivityChanged should NOT be used for PIP entry — it races + * with setAutoEnterEnabled and causes double-entry on navigation gestures. */ class CallBaseActivityPipTest { // Simulated CallBaseActivity state private var isInPipMode = false private var isPipModePossible = true - private var isChangingConfigurations = false - private var isFinishing = false private var autoEnterEnabled = false // Tracking private var enterPipModeCallCount = 0 private var enableKeyguardCallCount = 0 - private var enterPictureInPictureModeCalled = false - // Simulate enterPipMode() private fun enterPipMode() { enableKeyguardCallCount++ if (isPipModePossible) { - enterPictureInPictureModeCalled = true enterPipModeCallCount++ } } @@ -48,239 +45,124 @@ class CallBaseActivityPipTest { fun setUp() { isInPipMode = false isPipModePossible = true - isChangingConfigurations = false - isFinishing = false autoEnterEnabled = false enterPipModeCallCount = 0 enableKeyguardCallCount = 0 - enterPictureInPictureModeCalled = false } // ========================================== - // Tests documenting the triple-call race condition + // API 31+: auto-enter handles most gestures // ========================================== - /** - * Documents: On API 31+ with setAutoEnterEnabled(true), there are THREE concurrent - * PIP entry attempts when the user navigates away. - * - * 1. System auto-enter (from setAutoEnterEnabled) - * 2. Manual call from onTopResumedActivityChanged - * 3. Manual call from onPause - * - * The isInPipMode guard should prevent #3 after #2 succeeds, but - * onPictureInPictureModeChanged (which sets isInPipMode=true) fires asynchronously. - * So both #2 and #3 can execute before isInPipMode becomes true. - */ @Test - fun `triple PIP entry race - all three calls fire before isInPipMode is set`() { - autoEnterEnabled = true // API 31+ with setAutoEnterEnabled(true) - - // System auto-enter fires (internal, we can't track it directly) - // But the manual calls below race with it + fun `API 31+ home gesture - auto-enter handles PIP, no manual call needed`() { + autoEnterEnabled = true + val isApiS = true - // Call from onTopResumedActivityChanged - if (!isInPipMode && isPipModePossible && !isChangingConfigurations && !isFinishing) { + // Simulate home gesture: onUserLeaveHint fires but is skipped on API 31+ + if (!isInPipMode && isPipModePossible && !isApiS) { enterPipMode() } - // onPictureInPictureModeChanged has NOT fired yet (async) - // So isInPipMode is still false + assertEquals("No manual PIP call on API 31+ home gesture", 0, enterPipModeCallCount) - // Call from onPause - if (!isInPipMode && isPipModePossible && !isChangingConfigurations && !isFinishing) { - enterPipMode() + // System auto-enter handles it + if (autoEnterEnabled && isPipModePossible) { + isInPipMode = true } - - assertEquals( - "Both manual enterPipMode calls fire (racing with system auto-enter)", - 2, - enterPipModeCallCount - ) - assertEquals( - "enableKeyguard called twice (side effect during PIP transition)", - 2, - enableKeyguardCallCount - ) + assertTrue("Auto-enter succeeds", isInPipMode) } - /** - * After onPictureInPictureModeChanged fires, the guard prevents further calls. - */ @Test - fun `isInPipMode guard works after async callback fires`() { - // First call succeeds - if (!isInPipMode && isPipModePossible) { - enterPipMode() - } - - // onPictureInPictureModeChanged fires - isInPipMode = true + fun `API 31+ recents gesture - auto-enter handles PIP, no manual call needed`() { + autoEnterEnabled = true + val isApiS = true - // Second call is blocked - if (!isInPipMode && isPipModePossible) { + // Same as home — onUserLeaveHint skipped on API 31+ + if (!isInPipMode && isPipModePossible && !isApiS) { enterPipMode() } - assertEquals("Only one call should succeed after guard activates", 1, enterPipModeCallCount) + assertEquals("No manual PIP call on API 31+ recents gesture", 0, enterPipModeCallCount) } - // ========================================== - // Tests for the fix: skip manual calls on API 31+ - // ========================================== - - /** - * FIX: On API 31+, skip manual enterPipMode() calls. Let auto-enter handle PIP. - * This eliminates the triple-call race and the enableKeyguard side effect. - */ @Test - fun `skipping manual calls on API 31 plus eliminates race`() { + fun `API 31+ back gesture - manual entry via OnBackPressedCallback`() { autoEnterEnabled = true - val isApiS = true // Simulating API 31+ - - // onTopResumedActivityChanged — skipped on API 31+ - if (!isApiS) { - if (!isInPipMode && isPipModePossible && !isChangingConfigurations && !isFinishing) { - enterPipMode() - } - } - // onPause — skipped on API 31+ - if (!isApiS) { - if (!isInPipMode && isPipModePossible && !isChangingConfigurations && !isFinishing) { - enterPipMode() - } + // Back gesture triggers OnBackPressedCallback, which calls enterPipMode() + if (isPipModePossible) { + enterPipMode() } - assertEquals("No manual PIP calls on API 31+", 0, enterPipModeCallCount) - assertEquals("enableKeyguard not called (no side effects)", 0, enableKeyguardCallCount) + assertEquals("One manual call from back gesture", 1, enterPipModeCallCount) } - /** - * On API 26-30, manual calls are still needed since auto-enter is not available. - */ @Test - fun `manual calls still work on pre-API 31`() { - autoEnterEnabled = false - val isApiS = false // Simulating API 26-30 + fun `API 31+ back gesture - no double entry from auto-enter after manual entry`() { + autoEnterEnabled = true - // onTopResumedActivityChanged - if (!isApiS) { - if (!isInPipMode && isPipModePossible && !isChangingConfigurations && !isFinishing) { - enterPipMode() - } + // Back gesture calls enterPipMode() via OnBackPressedCallback + if (isPipModePossible) { + enterPipMode() } - // Simulate: onPictureInPictureModeChanged fires synchronously (for testing) + // onPictureInPictureModeChanged fires isInPipMode = true - // onPause — guarded by isInPipMode - if (!isApiS) { - if (!isInPipMode && isPipModePossible && !isChangingConfigurations && !isFinishing) { - enterPipMode() - } - } - - assertEquals("One manual call succeeds on pre-API 31", 1, enterPipModeCallCount) + // No second call because system sees we're already in PIP + assertEquals("Only one PIP entry", 1, enterPipModeCallCount) } // ========================================== - // Tests for fast vs slow gesture behavior + // API 26-30: manual entry required // ========================================== - /** - * Documents: On a SLOW gesture, onTopResumedActivityChanged fires while the window - * is still visible, so manual enterPictureInPictureMode succeeds. - */ @Test - fun `slow gesture - manual enterPipMode succeeds (window still visible)`() { - val windowVisible = true // Slow gesture: window is still on screen + fun `API 26-30 home gesture - onUserLeaveHint enters PIP`() { + autoEnterEnabled = false + val isApiS = false - if (!isInPipMode && isPipModePossible && windowVisible) { + // onUserLeaveHint fires on home/recents + if (!isInPipMode && isPipModePossible && !isApiS) { enterPipMode() } - assertEquals("Manual PIP entry succeeds when window is visible", 1, enterPipModeCallCount) + assertEquals("Manual PIP entry on pre-API 31", 1, enterPipModeCallCount) } - /** - * Documents: On a FAST gesture, the window has already moved off-screen by the time - * manual enterPipMode fires. enterPictureInPictureMode silently fails. - * Only setAutoEnterEnabled can handle this case (API 31+). - */ @Test - fun `fast gesture - manual enterPipMode fails (window off-screen)`() { - val windowVisible = false // Fast gesture: window already moved off-screen - var pipEnteredSuccessfully = false + fun `API 26-30 back gesture - OnBackPressedCallback enters PIP`() { + autoEnterEnabled = false - if (!isInPipMode && isPipModePossible && windowVisible) { + // Back gesture triggers callback + if (isPipModePossible) { enterPipMode() - pipEnteredSuccessfully = true - } - - assertFalse("Manual PIP entry fails when window is off-screen", pipEnteredSuccessfully) - assertEquals("enterPipMode was not called", 0, enterPipModeCallCount) - } - - /** - * Documents: setAutoEnterEnabled handles fast gestures because the system enters - * PIP at the framework level, before the window transition animation. - */ - @Test - fun `fast gesture with auto-enter - PIP succeeds without manual call`() { - autoEnterEnabled = true - val isApiS = true - val windowVisible = false // Fast gesture - - // Manual calls are skipped on API 31+ - if (!isApiS) { - if (!isInPipMode && isPipModePossible && windowVisible) { - enterPipMode() - } - } - - // No manual calls fired - assertEquals("No manual calls on API 31+", 0, enterPipModeCallCount) - - // System auto-enter handles PIP (simulated) - if (autoEnterEnabled && isPipModePossible) { - isInPipMode = true // System enters PIP successfully } - assertTrue("Auto-enter succeeds regardless of window visibility", isInPipMode) + assertEquals("Manual PIP entry from back gesture", 1, enterPipModeCallCount) } // ========================================== - // Tests for PIP entry guard conditions + // Guard conditions // ========================================== @Test - fun `PIP is not entered when activity is finishing`() { - isFinishing = true - - if (!isInPipMode && isPipModePossible && !isChangingConfigurations && !isFinishing) { - enterPipMode() - } - - assertEquals("Should not enter PIP when finishing", 0, enterPipModeCallCount) - } - - @Test - fun `PIP is not entered during configuration change`() { - isChangingConfigurations = true + fun `PIP not entered when already in PIP mode`() { + isInPipMode = true - if (!isInPipMode && isPipModePossible && !isChangingConfigurations && !isFinishing) { + if (!isInPipMode && isPipModePossible) { enterPipMode() } - assertEquals("Should not enter PIP during config change", 0, enterPipModeCallCount) + assertEquals("Should not enter PIP when already in PIP", 0, enterPipModeCallCount) } @Test - fun `PIP is not entered when PIP is not possible`() { + fun `PIP not entered when PIP is not possible`() { isPipModePossible = false - if (!isInPipMode && isPipModePossible && !isChangingConfigurations && !isFinishing) { + if (!isInPipMode && isPipModePossible) { enterPipMode() } @@ -288,45 +170,51 @@ class CallBaseActivityPipTest { } // ========================================== - // Test for auto-enter params requirement + // Verifying the old race condition is eliminated // ========================================== - /** - * Documents: setAutoEnterEnabled(true) requires valid PIP params (including aspect - * ratio) to be set via setPictureInPictureParams() BEFORE the transition happens. - * - * CURRENT BUG: onCreate sets setAutoEnterEnabled(true) but the aspect ratio is only - * set in enterPipMode() (which is called manually and may not fire on fast gestures). - * Without the aspect ratio in the initial params, auto-enter silently fails. - * - * FIX: Set the aspect ratio in onCreate when building the initial PIP params. - */ @Test - fun `auto-enter requires aspect ratio in initial params`() { - var aspectRatioSetInOnCreate = false - var aspectRatioSetInEnterPipMode = false + fun `old approach - triple entry race condition (documenting the bug)`() { + // OLD CODE had three PIP entry points that could all fire before + // isInPipMode was set to true: + // 1. System auto-enter (setAutoEnterEnabled) + // 2. Manual call from onTopResumedActivityChanged + // 3. Manual call from onPause + // + // The fix: on API 31+, only the back gesture calls enterPipMode manually. + // Home/recents/swipe are handled entirely by setAutoEnterEnabled(true). - // Simulate onCreate (CURRENT BUG: no aspect ratio) autoEnterEnabled = true - // mPictureInPictureParamsBuilder.setAutoEnterEnabled(true) - // setPictureInPictureParams(builder.build()) ← no aspect ratio! - // Simulate enterPipMode (aspect ratio set here, but may not be called) - fun enterPipModeWithRatio() { - aspectRatioSetInEnterPipMode = true - } + // NEW approach: no manual calls for non-back gestures on API 31+ + // Only OnBackPressedCallback would call enterPipMode, and only once. + assertEquals("No spurious PIP entry calls", 0, enterPipModeCallCount) + assertEquals("No enableKeyguard side effects", 0, enableKeyguardCallCount) + } + + @Test + fun `fast swipe left gesture - auto-enter succeeds where manual entry failed`() { + // The swipe-left (back) navigation gesture was particularly problematic because: + // 1. OnBackPressedCallback would fire and call enterPipMode() + // 2. onTopResumedActivityChanged would ALSO fire and call enterPipMode() again + // 3. The window might already be animating off-screen, causing manual entry to fail + // + // Fix: OnBackPressedCallback is the ONLY manual entry point. For swipe-left, + // it fires early enough that the window is still visible. + + autoEnterEnabled = true - // Fast gesture: enterPipMode never called, so aspect ratio never set - val windowVisible = false - if (windowVisible) { - enterPipModeWithRatio() + // OnBackPressedCallback fires (window still visible during back gesture) + if (isPipModePossible) { + enterPipMode() } - assertFalse("Aspect ratio was NOT set (fast gesture skipped enterPipMode)", aspectRatioSetInEnterPipMode) + assertEquals("Exactly one PIP entry from back gesture", 1, enterPipModeCallCount) - // FIX: set aspect ratio in onCreate - aspectRatioSetInOnCreate = true + // Simulate PIP mode activated + isInPipMode = true - assertTrue("FIX: Aspect ratio should be set in onCreate", aspectRatioSetInOnCreate) + // No additional entry attempts from other lifecycle callbacks + assertEquals("Still only one call", 1, enterPipModeCallCount) } } From 121f32982a946d3b6ddb209e48cdad0eae89ad9c Mon Sep 17 00:00:00 2001 From: Tarek Loubani Date: Fri, 3 Apr 2026 18:41:25 -0400 Subject: [PATCH 12/30] Keep call alive when task-switching away from CallActivity Remove excludeFromRecents=true from CallActivity manifest entry. This attribute caused Android to destroy the entire call task ~5s after the user navigated away via task switch, killing the call. Guard all teardown in onDestroy (signaling listeners, localStream, foreground service, proximity sensor, broadcast receiver) so that system-initiated destruction during task switching doesn't tear down active call resources. The foreground service keeps the process alive. Simplify onTopResumedActivityChanged to only enter PIP on API 29-30. On API 31+, auto-enter handles swipe-up; onUserLeaveHint moves the task to back as a safety net for task switching. Signed-off-by: Tarek Loubani --- app/src/main/AndroidManifest.xml | 1 - .../nextcloud/talk/activities/CallActivity.kt | 53 ++++++++++++------- .../talk/activities/CallBaseActivity.java | 39 +++++++++----- 3 files changed, 62 insertions(+), 31 deletions(-) diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index f0672e760f..155a970fbc 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -198,7 +198,6 @@ Date: Sat, 11 Apr 2026 18:46:47 -0400 Subject: [PATCH 13/30] feat(call): use Notification.CallStyle for ongoing call notification Use Android CallStyle notification (API 31+) to show green status bar chip with call duration timer, matching the native phone app experience. Falls back to standard notification on older API levels. The notification is updated every second via startForeground() to keep the call duration accurate, using callStartTime from ApplicationWideCurrentRoomHolder. Signed-off-by: Tarek Loubani --- .../talk/services/CallForegroundService.kt | 89 +++++++++++++++++++ 1 file changed, 89 insertions(+) diff --git a/app/src/main/java/com/nextcloud/talk/services/CallForegroundService.kt b/app/src/main/java/com/nextcloud/talk/services/CallForegroundService.kt index 561dd708a3..ecea5c8ac8 100644 --- a/app/src/main/java/com/nextcloud/talk/services/CallForegroundService.kt +++ b/app/src/main/java/com/nextcloud/talk/services/CallForegroundService.kt @@ -2,6 +2,7 @@ * Nextcloud Talk - Android Client * * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors * SPDX-License-Identifier: GPL-3.0-or-later */ package com.nextcloud.talk.services @@ -9,13 +10,17 @@ package com.nextcloud.talk.services import android.annotation.SuppressLint import android.app.Notification import android.app.PendingIntent +import android.app.Person import android.app.Service import android.content.Context import android.content.Intent import android.content.pm.ServiceInfo +import android.graphics.drawable.Icon import android.os.Build import android.os.Bundle +import android.os.Handler import android.os.IBinder +import android.os.Looper import android.util.Log import androidx.core.app.NotificationCompat import androidx.core.app.NotificationCompat.FOREGROUND_SERVICE_IMMEDIATE @@ -28,9 +33,13 @@ import com.nextcloud.talk.receivers.EndCallReceiver.Companion.END_CALL_ACTION import com.nextcloud.talk.utils.NotificationUtils import com.nextcloud.talk.utils.bundle.BundleKeys.KEY_CALL_VOICE_ONLY import com.nextcloud.talk.utils.bundle.BundleKeys.KEY_PARTICIPANT_PERMISSION_CAN_PUBLISH_VIDEO +import com.nextcloud.talk.utils.singletons.ApplicationWideCurrentRoomHolder class CallForegroundService : Service() { + private val handler = Handler(Looper.getMainLooper()) + private var currentNotificationId: Int = NOTIFICATION_ID + override fun onBind(intent: Intent?): IBinder? = null @SuppressLint("ForegroundServiceType") @@ -47,10 +56,13 @@ class CallForegroundService : Service() { startForeground(NOTIFICATION_ID, notification) } + startTimeBasedNotificationUpdates() + return START_STICKY } override fun onDestroy() { + handler.removeCallbacksAndMessages(null) stopForeground(STOP_FOREGROUND_REMOVE) super.onDestroy() } @@ -79,6 +91,10 @@ class CallForegroundService : Service() { endCallPendingIntent ).build() + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + return buildCallStyleNotification(contentTitle, pendingIntent) + } + return NotificationCompat.Builder(this, channelId) .setContentTitle(contentTitle) .setContentText(getString(R.string.nc_call_ongoing_notification_content)) @@ -96,6 +112,65 @@ class CallForegroundService : Service() { .build() } + @SuppressLint("NewApi") + private fun buildCallStyleNotification( + contentTitle: String, + pendingIntent: PendingIntent + ): Notification { + val caller = Person.Builder() + .setName(contentTitle) + .setIcon(Icon.createWithResource(this, R.drawable.ic_call_white_24dp)) + .setImportant(true) + .build() + + val callStyle = Notification.CallStyle.forOngoingCall( + caller, + createHangupPendingIntent() + ) + + val channelId = NotificationUtils.NotificationChannels.NOTIFICATION_CHANNEL_CALLS_V4.name + + val callStartTime = ApplicationWideCurrentRoomHolder.getInstance().callStartTime + + return Notification.Builder(this, channelId) + .setStyle(callStyle) + .setSmallIcon(R.drawable.ic_call_white_24dp) + .setContentIntent(pendingIntent) + .setOngoing(true) + .setCategory(Notification.CATEGORY_CALL) + .setForegroundServiceBehavior(FOREGROUND_SERVICE_IMMEDIATE) + .setShowWhen(false) + .also { builder -> + if (callStartTime != null && callStartTime > 0) { + builder.setWhen(callStartTime) + builder.setShowWhen(true) + } + } + .build() + } + + @SuppressLint("NewApi", "ForegroundServiceType") + private fun startTimeBasedNotificationUpdates() { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.S) return + + val updateRunnable = object : Runnable { + override fun run() { + val callStartTime = ApplicationWideCurrentRoomHolder.getInstance().callStartTime + if (callStartTime != null && callStartTime > 0) { + val conversationName = ApplicationWideCurrentRoomHolder.getInstance() + .userInRoom?.displayName + ?: getString(R.string.nc_call_ongoing_notification_default_title) + val pendingIntent = createContentIntent(null) + val notification = buildCallStyleNotification(conversationName, pendingIntent) + + startForeground(NOTIFICATION_ID, notification) + } + handler.postDelayed(this, CALL_DURATION_UPDATE_INTERVAL) + } + } + handler.postDelayed(updateRunnable, CALL_DURATION_UPDATE_INTERVAL) + } + private fun ensureNotificationChannel() { val app = NextcloudTalkApplication.sharedApplication ?: return NotificationUtils.registerNotificationChannels(applicationContext, app.appPreferences) @@ -121,6 +196,18 @@ class CallForegroundService : Service() { return PendingIntent.getBroadcast(this, 1, intent, flags) } + private fun createHangupPendingIntent(): PendingIntent { + val intent = Intent(ACTION_HANGUP).apply { + setPackage(packageName) + } + return PendingIntent.getBroadcast( + this, + 0, + intent, + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE + ) + } + private fun resolveForegroundServiceType(callExtras: Bundle?): Int { var serviceType = 0 if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { @@ -144,6 +231,8 @@ class CallForegroundService : Service() { private const val FOREGROUND_SERVICE_TYPE_ZERO = 0 private const val EXTRA_CONVERSATION_NAME = "extra_conversation_name" private const val EXTRA_CALL_INTENT_EXTRAS = "extra_call_intent_extras" + private const val ACTION_HANGUP = "com.nextcloud.talk.ACTION_HANGUP" + private const val CALL_DURATION_UPDATE_INTERVAL = 1000L fun start(context: Context, conversationName: String?, callIntentExtras: Bundle?) { val serviceIntent = Intent(context, CallForegroundService::class.java).apply { From 09f1a6de33be0bb934fecb65cc61eb5710f7ba57 Mon Sep 17 00:00:00 2001 From: Tarek Loubani Date: Thu, 16 Apr 2026 16:22:20 -0400 Subject: [PATCH 14/30] Fix call notification not appearing immediately and missing on subsequent calls Start foreground service at the beginning of prepareCall() before heavy initialization, stop it in hangup() and unconditionally in onDestroy(), cancel stale periodic handlers in onStartCommand(), and reset callStartTime between calls to prevent state leakage. Signed-off-by: Tarek Loubani --- .../nextcloud/talk/activities/CallActivity.kt | 32 +++++++++---------- .../talk/services/CallForegroundService.kt | 5 +++ 2 files changed, 20 insertions(+), 17 deletions(-) diff --git a/app/src/main/java/com/nextcloud/talk/activities/CallActivity.kt b/app/src/main/java/com/nextcloud/talk/activities/CallActivity.kt index b3d36602ac..c6baa3a5c9 100644 --- a/app/src/main/java/com/nextcloud/talk/activities/CallActivity.kt +++ b/app/src/main/java/com/nextcloud/talk/activities/CallActivity.kt @@ -1136,23 +1136,15 @@ class CallActivity : CallBaseActivity() { private fun prepareCall() { stopCallingSound() - Log.d(TAG, "DEBUG: prepareCall() started") - basicInitialization() - initViews() - // updateSelfVideoViewPosition(true) - checkRecordingConsentAndInitiateCall() + Log.d(TAG, "prepareCall() started") - // Start foreground service only if we have notification permission (for Android 13+) - // or if we're on older Android versions where permission is automatically granted if (permissionUtil!!.isMicrophonePermissionGranted()) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { - // Android 13+ requires explicit notification permission if (permissionUtil!!.isPostNotificationsPermissionGranted()) { Log.d(TAG, "DEBUG: Starting foreground service with notification permission") CallForegroundService.start(applicationContext, conversationName, intent.extras) } else { Log.w(TAG, "Notification permission not granted - call will work but without persistent notification") - // Show warning to user that notification permission is missing (10 seconds) Snackbar.make( binding!!.root, resources.getString(R.string.nc_notification_permission_hint), @@ -1172,9 +1164,11 @@ class CallActivity : CallBaseActivity() { Log.w(TAG, "DEBUG: Microphone permission not granted - skipping foreground service start") } - // The call should not hang just because notification permission was denied - // Always proceed with call setup regardless of notification permission - Log.d(TAG, "DEBUG: Ensuring call proceeds even without notification permission") + Log.d(TAG, "Ensuring call proceeds even without notification permission") + + basicInitialization() + initViews() + checkRecordingConsentAndInitiateCall() if (isVoiceOnlyCall) { binding!!.selfVideoViewWrapper.visibility = View.GONE @@ -1542,11 +1536,8 @@ class CallActivity : CallBaseActivity() { hangup(true, false) } } - if (!isSystemInitiatedDestroy) { - CallForegroundService.stop(applicationContext) - } else { - Log.d(TAG, "System-initiated destroy, keeping foreground service alive") - } + CallForegroundService.stop(applicationContext) + Log.d(TAG, "Foreground service stop requested from onDestroy()") if (!isSystemInitiatedDestroy) { Log.d(TAG, "onDestroy: Releasing proximity sensor - updating to IDLE state") @@ -2212,6 +2203,13 @@ class CallActivity : CallBaseActivity() { } ApplicationWideCurrentRoomHolder.getInstance().isInCall = false ApplicationWideCurrentRoomHolder.getInstance().isDialing = false + ApplicationWideCurrentRoomHolder.getInstance().callStartTime = null + + if (shutDownView) { + Log.d(TAG, "Stopping foreground service from hangup()") + CallForegroundService.stop(applicationContext) + } + hangupNetworkCalls(shutDownView, endCallForAll) } diff --git a/app/src/main/java/com/nextcloud/talk/services/CallForegroundService.kt b/app/src/main/java/com/nextcloud/talk/services/CallForegroundService.kt index ecea5c8ac8..60876f6ffe 100644 --- a/app/src/main/java/com/nextcloud/talk/services/CallForegroundService.kt +++ b/app/src/main/java/com/nextcloud/talk/services/CallForegroundService.kt @@ -44,6 +44,9 @@ class CallForegroundService : Service() { @SuppressLint("ForegroundServiceType") override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { + Log.d(TAG, "onStartCommand called") + handler.removeCallbacksAndMessages(null) + val conversationName = intent?.getStringExtra(EXTRA_CONVERSATION_NAME) val callExtras = intent?.getBundleExtra(EXTRA_CALL_INTENT_EXTRAS) val notification = buildNotification(conversationName, callExtras) @@ -62,6 +65,7 @@ class CallForegroundService : Service() { } override fun onDestroy() { + Log.d(TAG, "onDestroy called") handler.removeCallbacksAndMessages(null) stopForeground(STOP_FOREGROUND_REMOVE) super.onDestroy() @@ -227,6 +231,7 @@ class CallForegroundService : Service() { } companion object { + private val TAG = CallForegroundService::class.java.simpleName private const val NOTIFICATION_ID = 47001 private const val FOREGROUND_SERVICE_TYPE_ZERO = 0 private const val EXTRA_CONVERSATION_NAME = "extra_conversation_name" From efe63bcf339baa523096a1ec68624a4aae4a66ae Mon Sep 17 00:00:00 2001 From: Tarek Loubani Date: Thu, 16 Apr 2026 17:13:27 -0400 Subject: [PATCH 15/30] style: fix Codacy issues (line length, unused imports, trailing whitespace, generic catch) - Break long log lines to respect 120 char limit - Remove unused imports (LiveData, assertFalse) - Remove trailing whitespace - Merge duplicate test to reduce class function count below threshold - Catch IllegalArgumentException instead of generic Exception - Ensure EndCallReceiver.kt ends with newline Signed-off-by: Tarek Loubani --- .../nextcloud/talk/activities/CallActivity.kt | 32 ++++++--- .../talk/receivers/EndCallReceiver.kt | 2 +- .../talk/services/CallForegroundService.kt | 6 +- .../activities/CallBaseActivityPipTest.kt | 1 - .../ChatActivityLeaveRoomLifecycleTest.kt | 67 +++++++------------ 5 files changed, 51 insertions(+), 57 deletions(-) diff --git a/app/src/main/java/com/nextcloud/talk/activities/CallActivity.kt b/app/src/main/java/com/nextcloud/talk/activities/CallActivity.kt index c6baa3a5c9..8d5bbf048a 100644 --- a/app/src/main/java/com/nextcloud/talk/activities/CallActivity.kt +++ b/app/src/main/java/com/nextcloud/talk/activities/CallActivity.kt @@ -391,9 +391,13 @@ class CallActivity : CallBaseActivity() { } else { true // Older Android versions have permission by default } - - Log.d(TAG, "DEBUG: Notification permission granted: $notificationPermissionGranted, isConnectionEstablished: $isConnectionEstablished") - + + Log.d( + TAG, + "Notification permission granted: $notificationPermissionGranted, " + + "isConnectionEstablished: $isConnectionEstablished" + ) + if (!isConnectionEstablished) { Log.d(TAG, "DEBUG: Proceeding with prepareCall() despite notification permission status") prepareCall() @@ -1144,7 +1148,11 @@ class CallActivity : CallBaseActivity() { Log.d(TAG, "DEBUG: Starting foreground service with notification permission") CallForegroundService.start(applicationContext, conversationName, intent.extras) } else { - Log.w(TAG, "Notification permission not granted - call will work but without persistent notification") + Log.w( + TAG, + "Notification permission not granted - call will work " + + "but without persistent notification" + ) Snackbar.make( binding!!.root, resources.getString(R.string.nc_notification_permission_hint), @@ -1200,11 +1208,13 @@ class CallActivity : CallBaseActivity() { for (rationale in rationaleList) { rationalesWithLineBreaks.append(rationale).append("\n\n") } - - // DEBUG: Log when permission rationale dialog is shown - Log.d(TAG, "DEBUG: Showing permission rationale dialog for permissions: $permissionsToRequest") - Log.d(TAG, "DEBUG: Rationale includes notification permission: ${permissionsToRequest.contains(Manifest.permission.POST_NOTIFICATIONS)}") - + + // Log when permission rationale dialog is shown + Log.d(TAG, "Showing permission rationale dialog for permissions: $permissionsToRequest") + val hasNotificationPerm = permissionsToRequest + .contains(Manifest.permission.POST_NOTIFICATIONS) + Log.d(TAG, "Rationale includes notification permission: $hasNotificationPerm") + val dialogBuilder = MaterialAlertDialogBuilder(this) .setTitle(R.string.nc_permissions_rationale_dialog_title) .setMessage(rationalesWithLineBreaks) @@ -1526,7 +1536,7 @@ class CallActivity : CallBaseActivity() { localStream = null Log.d(TAG, "Disposed localStream (intentionally leaving)") } else { - Log.d(TAG, "System-initiated destroy while call active, keeping localStream alive for foreground service") + Log.d(TAG, "System-initiated destroy, keeping localStream alive for foreground service") } } else { Log.d(TAG, "localStream is null") @@ -1552,7 +1562,7 @@ class CallActivity : CallBaseActivity() { Log.d(TAG, "Unregistering endCallFromNotificationReceiver...") unregisterReceiver(endCallFromNotificationReceiver) Log.d(TAG, "endCallFromNotificationReceiver unregistered successfully") - } catch (e: Exception) { + } catch (e: IllegalArgumentException) { Log.w(TAG, "Failed to unregister endCallFromNotificationReceiver", e) } } else { diff --git a/app/src/main/java/com/nextcloud/talk/receivers/EndCallReceiver.kt b/app/src/main/java/com/nextcloud/talk/receivers/EndCallReceiver.kt index d56d1f9e89..896d35ece0 100644 --- a/app/src/main/java/com/nextcloud/talk/receivers/EndCallReceiver.kt +++ b/app/src/main/java/com/nextcloud/talk/receivers/EndCallReceiver.kt @@ -34,4 +34,4 @@ class EndCallReceiver : BroadcastReceiver() { } } } -} \ No newline at end of file +} diff --git a/app/src/main/java/com/nextcloud/talk/services/CallForegroundService.kt b/app/src/main/java/com/nextcloud/talk/services/CallForegroundService.kt index 60876f6ffe..b073fbe29f 100644 --- a/app/src/main/java/com/nextcloud/talk/services/CallForegroundService.kt +++ b/app/src/main/java/com/nextcloud/talk/services/CallForegroundService.kt @@ -78,7 +78,7 @@ class CallForegroundService : Service() { val contentTitle = conversationName?.takeIf { it.isNotBlank() } ?: getString(R.string.nc_call_ongoing_notification_default_title) val pendingIntent = createContentIntent(callExtras) - + // Create action to return to call val returnToCallAction = NotificationCompat.Action.Builder( R.drawable.ic_call_white_24dp, @@ -182,7 +182,9 @@ class CallForegroundService : Service() { private fun createContentIntent(callExtras: Bundle?): PendingIntent { val intent = Intent(this, CallActivity::class.java).apply { - flags = Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_REORDER_TO_FRONT + flags = Intent.FLAG_ACTIVITY_SINGLE_TOP or + Intent.FLAG_ACTIVITY_CLEAR_TOP or + Intent.FLAG_ACTIVITY_REORDER_TO_FRONT callExtras?.let { putExtras(Bundle(it)) } } diff --git a/app/src/test/java/com/nextcloud/talk/activities/CallBaseActivityPipTest.kt b/app/src/test/java/com/nextcloud/talk/activities/CallBaseActivityPipTest.kt index faced2ab24..d08fbdc282 100644 --- a/app/src/test/java/com/nextcloud/talk/activities/CallBaseActivityPipTest.kt +++ b/app/src/test/java/com/nextcloud/talk/activities/CallBaseActivityPipTest.kt @@ -7,7 +7,6 @@ package com.nextcloud.talk.activities import org.junit.Assert.assertEquals -import org.junit.Assert.assertFalse import org.junit.Assert.assertTrue import org.junit.Before import org.junit.Test diff --git a/app/src/test/java/com/nextcloud/talk/activities/ChatActivityLeaveRoomLifecycleTest.kt b/app/src/test/java/com/nextcloud/talk/activities/ChatActivityLeaveRoomLifecycleTest.kt index f60fbf418c..5e4f08315c 100644 --- a/app/src/test/java/com/nextcloud/talk/activities/ChatActivityLeaveRoomLifecycleTest.kt +++ b/app/src/test/java/com/nextcloud/talk/activities/ChatActivityLeaveRoomLifecycleTest.kt @@ -10,7 +10,6 @@ import androidx.arch.core.executor.testing.InstantTaskExecutorRule import androidx.lifecycle.Lifecycle import androidx.lifecycle.LifecycleOwner import androidx.lifecycle.LifecycleRegistry -import androidx.lifecycle.LiveData import androidx.lifecycle.MutableLiveData import com.nextcloud.talk.utils.singletons.ApplicationWideCurrentRoomHolder import org.junit.After @@ -177,53 +176,37 @@ class ChatActivityLeaveRoomLifecycleTest { // ========================================== /** - * When a call is active (isInCall=true), the leave observer must NOT clear the - * holder or send websocket leave — doing so would kill the active call/PIP. + * When a call is active (isInCall=true) or dialing (isDialing=true), the leave + * observer must NOT clear the holder or send websocket leave — doing so would + * kill the active call/PIP. */ @Test - fun `leave observer skips cleanup when call is active`() { - holderIsInCall = true - - val observer = androidx.lifecycle.Observer { state -> - if (state is LeaveRoomSuccessState) { - simulateLeaveRoomObserverAction(state) + fun `leave observer skips cleanup when call is active or dialing`() { + for ((inCall, dialing, label) in listOf( + Triple(true, false, "isInCall"), + Triple(false, true, "isDialing") + )) { + holderIsInCall = inCall + holderIsDialing = dialing + holderCleared = false + websocketLeaveRoomCalled = false + sessionIdAfterRoomJoined = "valid-session" + + val observer = androidx.lifecycle.Observer { state -> + if (state is LeaveRoomSuccessState) { + simulateLeaveRoomObserverAction(state) + } } - } - leaveRoomViewState.observeForever(observer) - - leaveRoomViewState.value = LeaveRoomSuccessState(null) - - assertFalse("Holder should NOT be cleared during active call", holderCleared) - assertFalse("Websocket leave should NOT be called during active call", websocketLeaveRoomCalled) - assertEquals( - "Session should NOT be reset during active call", - "valid-session", - sessionIdAfterRoomJoined - ) - - leaveRoomViewState.removeObserver(observer) - } + leaveRoomViewState.observeForever(observer) + leaveRoomViewState.value = LeaveRoomSuccessState(null) - /** - * When dialing (isDialing=true), the leave observer must NOT clear the holder. - */ - @Test - fun `leave observer skips cleanup when dialing`() { - holderIsDialing = true + assertFalse("Holder should NOT be cleared ($label)", holderCleared) + assertFalse("Websocket leave should NOT fire ($label)", websocketLeaveRoomCalled) + assertEquals("Session should NOT be reset ($label)", "valid-session", sessionIdAfterRoomJoined) - val observer = androidx.lifecycle.Observer { state -> - if (state is LeaveRoomSuccessState) { - simulateLeaveRoomObserverAction(state) - } + leaveRoomViewState.removeObserver(observer) + leaveRoomViewState.value = LeaveRoomStartState } - leaveRoomViewState.observeForever(observer) - - leaveRoomViewState.value = LeaveRoomSuccessState(null) - - assertFalse("Holder should NOT be cleared while dialing", holderCleared) - assertFalse("Websocket leave should NOT be called while dialing", websocketLeaveRoomCalled) - - leaveRoomViewState.removeObserver(observer) } /** From 326f5008c9a7c8d9072a2746043654c6f9beb6b0 Mon Sep 17 00:00:00 2001 From: Tarek Loubani Date: Thu, 16 Apr 2026 17:25:24 -0400 Subject: [PATCH 16/30] Remove trailing space Signed-off-by: Tarek Loubani --- .../java/com/nextcloud/talk/services/CallForegroundService.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/src/main/java/com/nextcloud/talk/services/CallForegroundService.kt b/app/src/main/java/com/nextcloud/talk/services/CallForegroundService.kt index b073fbe29f..64f3ee1545 100644 --- a/app/src/main/java/com/nextcloud/talk/services/CallForegroundService.kt +++ b/app/src/main/java/com/nextcloud/talk/services/CallForegroundService.kt @@ -85,7 +85,7 @@ class CallForegroundService : Service() { getString(R.string.nc_call_ongoing_notification_return_action), pendingIntent ).build() - + // Create action to end call val endCallPendingIntent = createEndCallIntent(callExtras) From adc7e132de0b91492df22e50650c8b9461b3c6f7 Mon Sep 17 00:00:00 2001 From: Tarek Loubani Date: Thu, 16 Apr 2026 17:26:59 -0400 Subject: [PATCH 17/30] Remove trailing spaces Signed-off-by: Tarek Loubani --- .../nextcloud/talk/activities/CallActivity.kt | 4 +- .../ChatActivityLeaveRoomLifecycleTest.kt | 58 +++++++------------ 2 files changed, 24 insertions(+), 38 deletions(-) diff --git a/app/src/main/java/com/nextcloud/talk/activities/CallActivity.kt b/app/src/main/java/com/nextcloud/talk/activities/CallActivity.kt index 8d5bbf048a..7be658bad8 100644 --- a/app/src/main/java/com/nextcloud/talk/activities/CallActivity.kt +++ b/app/src/main/java/com/nextcloud/talk/activities/CallActivity.kt @@ -333,8 +333,8 @@ class CallActivity : CallBaseActivity() { private var requestPermissionLauncher = registerForActivityResult( ActivityResultContracts.RequestMultiplePermissions() ) { permissionMap: Map -> - // DEBUG: Log permission results - Log.d(TAG, "DEBUG: Permission request completed with results: $permissionMap") + // Log permission results + Log.d(TAG, "Permission request completed with results: $permissionMap") val rationaleList: MutableList = ArrayList() val audioPermission = permissionMap[Manifest.permission.RECORD_AUDIO] diff --git a/app/src/test/java/com/nextcloud/talk/activities/ChatActivityLeaveRoomLifecycleTest.kt b/app/src/test/java/com/nextcloud/talk/activities/ChatActivityLeaveRoomLifecycleTest.kt index 5e4f08315c..cc460ddf23 100644 --- a/app/src/test/java/com/nextcloud/talk/activities/ChatActivityLeaveRoomLifecycleTest.kt +++ b/app/src/test/java/com/nextcloud/talk/activities/ChatActivityLeaveRoomLifecycleTest.kt @@ -238,50 +238,36 @@ class ChatActivityLeaveRoomLifecycleTest { // ========================================== /** - * The switchToRoom callback must fire even when the activity is paused. - * This ensures the new ChatActivity is launched after the room is left. + * The switchToRoom callback must fire even when the activity is paused and even + * when a call is active — only the holder/websocket cleanup is skipped, not the callback. */ @Test - fun `switchToRoom callback fires via observeForever even when paused`() { - val observer = androidx.lifecycle.Observer { state -> - if (state is LeaveRoomSuccessState) { - simulateLeaveRoomObserverAction(state) - } - } - leaveRoomViewState.observeForever(observer) - - leaveRoomViewState.value = LeaveRoomSuccessState { - callbackInvoked = true - } - - assertTrue("Callback should be invoked", callbackInvoked) + fun `switchToRoom callback fires via observeForever regardless of call state`() { + for ((inCall, label) in listOf(false to "no call", true to "active call")) { + holderIsInCall = inCall + callbackInvoked = false + holderCleared = false - leaveRoomViewState.removeObserver(observer) - } + val observer = androidx.lifecycle.Observer { state -> + if (state is LeaveRoomSuccessState) { + simulateLeaveRoomObserverAction(state) + } + } + leaveRoomViewState.observeForever(observer) - /** - * The switchToRoom callback must still fire even when a call is active — - * only the holder/websocket cleanup is skipped, not the callback. - */ - @Test - fun `switchToRoom callback fires even during active call`() { - holderIsInCall = true + leaveRoomViewState.value = LeaveRoomSuccessState { + callbackInvoked = true + } - val observer = androidx.lifecycle.Observer { state -> - if (state is LeaveRoomSuccessState) { - simulateLeaveRoomObserverAction(state) + assertTrue("Callback should fire ($label)", callbackInvoked) + if (inCall) { + assertFalse("Holder should NOT be cleared during active call", holderCleared) } - } - leaveRoomViewState.observeForever(observer) - leaveRoomViewState.value = LeaveRoomSuccessState { - callbackInvoked = true + leaveRoomViewState.removeObserver(observer) + leaveRoomViewState.value = LeaveRoomStartState } - - assertTrue("Callback should fire even during active call", callbackInvoked) - assertFalse("But holder should NOT be cleared", holderCleared) - - leaveRoomViewState.removeObserver(observer) + } } // ========================================== From 3a247244e7eb6d6021612e42b316680cdde515f6 Mon Sep 17 00:00:00 2001 From: Tarek Loubani Date: Thu, 16 Apr 2026 18:38:25 -0400 Subject: [PATCH 18/30] Correctly hang up from notification in Android 12+ Signed-off-by: Tarek Loubani --- .../java/com/nextcloud/talk/activities/CallBaseActivity.java | 1 + .../com/nextcloud/talk/services/CallForegroundService.kt | 5 ++--- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/app/src/main/java/com/nextcloud/talk/activities/CallBaseActivity.java b/app/src/main/java/com/nextcloud/talk/activities/CallBaseActivity.java index d28100c269..d834915913 100644 --- a/app/src/main/java/com/nextcloud/talk/activities/CallBaseActivity.java +++ b/app/src/main/java/com/nextcloud/talk/activities/CallBaseActivity.java @@ -13,6 +13,7 @@ import android.content.pm.PackageManager; import android.os.Build; import android.os.Bundle; +import android.os.PowerManager; import android.util.Log; import android.util.Rational; import android.view.View; diff --git a/app/src/main/java/com/nextcloud/talk/services/CallForegroundService.kt b/app/src/main/java/com/nextcloud/talk/services/CallForegroundService.kt index 64f3ee1545..e2d867c6f1 100644 --- a/app/src/main/java/com/nextcloud/talk/services/CallForegroundService.kt +++ b/app/src/main/java/com/nextcloud/talk/services/CallForegroundService.kt @@ -203,8 +203,8 @@ class CallForegroundService : Service() { } private fun createHangupPendingIntent(): PendingIntent { - val intent = Intent(ACTION_HANGUP).apply { - setPackage(packageName) + val intent = Intent(this, EndCallReceiver::class.java).apply { + action = EndCallReceiver.END_CALL_ACTION } return PendingIntent.getBroadcast( this, @@ -238,7 +238,6 @@ class CallForegroundService : Service() { private const val FOREGROUND_SERVICE_TYPE_ZERO = 0 private const val EXTRA_CONVERSATION_NAME = "extra_conversation_name" private const val EXTRA_CALL_INTENT_EXTRAS = "extra_call_intent_extras" - private const val ACTION_HANGUP = "com.nextcloud.talk.ACTION_HANGUP" private const val CALL_DURATION_UPDATE_INTERVAL = 1000L fun start(context: Context, conversationName: String?, callIntentExtras: Bundle?) { From 2d9ce96f898ed1931d525918aa2915116e0a98ca Mon Sep 17 00:00:00 2001 From: Tarek Loubani Date: Thu, 16 Apr 2026 18:51:05 -0400 Subject: [PATCH 19/30] fix: remove stray closing brace in ChatActivityLeaveRoomLifecycleTest Signed-off-by: Tarek Loubani --- .../talk/activities/ChatActivityLeaveRoomLifecycleTest.kt | 1 - 1 file changed, 1 deletion(-) diff --git a/app/src/test/java/com/nextcloud/talk/activities/ChatActivityLeaveRoomLifecycleTest.kt b/app/src/test/java/com/nextcloud/talk/activities/ChatActivityLeaveRoomLifecycleTest.kt index cc460ddf23..1e5b710bc2 100644 --- a/app/src/test/java/com/nextcloud/talk/activities/ChatActivityLeaveRoomLifecycleTest.kt +++ b/app/src/test/java/com/nextcloud/talk/activities/ChatActivityLeaveRoomLifecycleTest.kt @@ -268,7 +268,6 @@ class ChatActivityLeaveRoomLifecycleTest { leaveRoomViewState.value = LeaveRoomStartState } } - } // ========================================== // Tests for the isLeavingRoom guard (double-leave prevention) From f82de46c9e2e7fb9c1888240b298b075cc3a4c90 Mon Sep 17 00:00:00 2001 From: Tarek Loubani Date: Thu, 16 Apr 2026 19:39:31 -0400 Subject: [PATCH 20/30] style: remove trailing whitespace in CallActivity and CallForegroundService Signed-off-by: Tarek Loubani --- app/src/main/java/com/nextcloud/talk/activities/CallActivity.kt | 2 +- .../java/com/nextcloud/talk/services/CallForegroundService.kt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/app/src/main/java/com/nextcloud/talk/activities/CallActivity.kt b/app/src/main/java/com/nextcloud/talk/activities/CallActivity.kt index 7be658bad8..466ab6a3a3 100644 --- a/app/src/main/java/com/nextcloud/talk/activities/CallActivity.kt +++ b/app/src/main/java/com/nextcloud/talk/activities/CallActivity.kt @@ -391,7 +391,7 @@ class CallActivity : CallBaseActivity() { } else { true // Older Android versions have permission by default } - + Log.d( TAG, "Notification permission granted: $notificationPermissionGranted, " + diff --git a/app/src/main/java/com/nextcloud/talk/services/CallForegroundService.kt b/app/src/main/java/com/nextcloud/talk/services/CallForegroundService.kt index e2d867c6f1..f0942681c6 100644 --- a/app/src/main/java/com/nextcloud/talk/services/CallForegroundService.kt +++ b/app/src/main/java/com/nextcloud/talk/services/CallForegroundService.kt @@ -88,7 +88,7 @@ class CallForegroundService : Service() { // Create action to end call val endCallPendingIntent = createEndCallIntent(callExtras) - + val endCallAction = NotificationCompat.Action.Builder( R.drawable.ic_baseline_close_24, getString(R.string.nc_call_ongoing_notification_end_action), From 98390856a1f50d41f869b3b540725277586153df Mon Sep 17 00:00:00 2001 From: Tarek Loubani Date: Thu, 16 Apr 2026 19:43:26 -0400 Subject: [PATCH 21/30] style: remove trailing whitespace in CallActivity and CallForegroundService Signed-off-by: Tarek Loubani --- app/src/main/java/com/nextcloud/talk/activities/CallActivity.kt | 2 +- .../java/com/nextcloud/talk/services/CallForegroundService.kt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/app/src/main/java/com/nextcloud/talk/activities/CallActivity.kt b/app/src/main/java/com/nextcloud/talk/activities/CallActivity.kt index 466ab6a3a3..1cae908221 100644 --- a/app/src/main/java/com/nextcloud/talk/activities/CallActivity.kt +++ b/app/src/main/java/com/nextcloud/talk/activities/CallActivity.kt @@ -397,7 +397,7 @@ class CallActivity : CallBaseActivity() { "Notification permission granted: $notificationPermissionGranted, " + "isConnectionEstablished: $isConnectionEstablished" ) - + if (!isConnectionEstablished) { Log.d(TAG, "DEBUG: Proceeding with prepareCall() despite notification permission status") prepareCall() diff --git a/app/src/main/java/com/nextcloud/talk/services/CallForegroundService.kt b/app/src/main/java/com/nextcloud/talk/services/CallForegroundService.kt index f0942681c6..92edf79942 100644 --- a/app/src/main/java/com/nextcloud/talk/services/CallForegroundService.kt +++ b/app/src/main/java/com/nextcloud/talk/services/CallForegroundService.kt @@ -191,7 +191,7 @@ class CallForegroundService : Service() { val flags = PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE return PendingIntent.getActivity(this, 0, intent, flags) } - + private fun createEndCallIntent(callExtras: Bundle?): PendingIntent { val intent = Intent(this, EndCallReceiver::class.java).apply { action = END_CALL_ACTION From 8ee207a428243ce523b4e34be76c7b67de0b44b4 Mon Sep 17 00:00:00 2001 From: Tarek Loubani Date: Thu, 16 Apr 2026 19:48:05 -0400 Subject: [PATCH 22/30] style: remove trailing whitespace in CallActivity Signed-off-by: Tarek Loubani --- .../main/java/com/nextcloud/talk/activities/CallActivity.kt | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/app/src/main/java/com/nextcloud/talk/activities/CallActivity.kt b/app/src/main/java/com/nextcloud/talk/activities/CallActivity.kt index 1cae908221..2f02cff7b8 100644 --- a/app/src/main/java/com/nextcloud/talk/activities/CallActivity.kt +++ b/app/src/main/java/com/nextcloud/talk/activities/CallActivity.kt @@ -432,7 +432,7 @@ class CallActivity : CallBaseActivity() { // Register broadcast receiver for ending call from notification val endCallFilter = IntentFilter(END_CALL_FROM_NOTIFICATION) - + // Use the proper utility function with ReceiverFlag for Android 14+ compatibility // This receiver is for internal app use only (notification actions), so it should NOT be exported registerPermissionHandlerBroadcastReceiver( @@ -1208,13 +1208,13 @@ class CallActivity : CallBaseActivity() { for (rationale in rationaleList) { rationalesWithLineBreaks.append(rationale).append("\n\n") } - + // Log when permission rationale dialog is shown Log.d(TAG, "Showing permission rationale dialog for permissions: $permissionsToRequest") val hasNotificationPerm = permissionsToRequest .contains(Manifest.permission.POST_NOTIFICATIONS) Log.d(TAG, "Rationale includes notification permission: $hasNotificationPerm") - + val dialogBuilder = MaterialAlertDialogBuilder(this) .setTitle(R.string.nc_permissions_rationale_dialog_title) .setMessage(rationalesWithLineBreaks) From 695a772be0750057df7eef84b736a8b50edbf2c9 Mon Sep 17 00:00:00 2001 From: Marcel Hibbe Date: Fri, 7 Aug 2026 12:01:44 +0200 Subject: [PATCH 23/30] fix "funToCallWhenLeaveSuccess" (leftover after solving merge conflicts) invoking funToCallWhenLeaveSuccess went to viewModel, now named as functionToCallAfterLeave Signed-off-by: Marcel Hibbe --- app/src/main/java/com/nextcloud/talk/chat/ChatActivity.kt | 5 ----- 1 file changed, 5 deletions(-) diff --git a/app/src/main/java/com/nextcloud/talk/chat/ChatActivity.kt b/app/src/main/java/com/nextcloud/talk/chat/ChatActivity.kt index 62fe1c6891..31265a95e4 100644 --- a/app/src/main/java/com/nextcloud/talk/chat/ChatActivity.kt +++ b/app/src/main/java/com/nextcloud/talk/chat/ChatActivity.kt @@ -541,11 +541,6 @@ class ChatActivity : } sessionIdAfterRoomJoined = "0" - - if (state.funToCallWhenLeaveSuccessful != null) { - Log.d(TAG, "a callback action was set and is now executed because room was left successfully") - state.funToCallWhenLeaveSuccessful.invoke() - } } else -> {} From 42f68e16911b011f2c86ff0947595d8d97b30928 Mon Sep 17 00:00:00 2001 From: Tarek Loubani Date: Thu, 20 Aug 2026 09:52:45 -0400 Subject: [PATCH 24/30] fix(call): prevent duplicate app instance and stale call state on call restart Restore excludeFromRecents on CallActivity so the call task no longer appears as a second app instance in recents. Handle onNewIntent so a reused backgrounded CallActivity brings the current call to front or hands off to a fresh instance for a different room. Always clean up WebRTC state on destroy, restore the voice-room navigation branch, and fix misleading permission log wording. Assisted-by: OpenCode:Kimi-K3 Signed-off-by: Tarek Loubani --- app/src/main/AndroidManifest.xml | 1 + .../nextcloud/talk/activities/CallActivity.kt | 213 ++++++++---------- .../talk/activities/CallBaseActivity.java | 18 +- .../com/nextcloud/talk/chat/ChatActivity.kt | 17 +- .../talk/services/CallForegroundService.kt | 10 +- 5 files changed, 103 insertions(+), 156 deletions(-) diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 155a970fbc..f0672e760f 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -198,6 +198,7 @@ -> - // Log permission results Log.d(TAG, "Permission request completed with results: $permissionMap") val rationaleList: MutableList = ArrayList() @@ -351,7 +351,7 @@ class CallActivity : CallBaseActivity() { if (java.lang.Boolean.TRUE == cameraPermission) { Log.d(TAG, "Camera permission was granted") } else { - Log.d(TAG, "DEBUG: Camera permission was denied") + Log.d(TAG, "Camera permission is not yet granted. Request will be made for permission.") rationaleList.add(resources.getString(R.string.nc_camera_permission_hint)) } } @@ -361,7 +361,7 @@ class CallActivity : CallBaseActivity() { if (java.lang.Boolean.TRUE == bluetoothPermission) { enableBluetoothManager() } else { - Log.d(TAG, "DEBUG: Bluetooth permission was denied") + Log.d(TAG, "Bluetooth permission is not yet granted. Request will be made for permission.") // Only ask for bluetooth when already asking to grant microphone or camera access. Asking // for bluetooth solely is not important enough here and would most likely annoy the user. if (rationaleList.isNotEmpty()) { @@ -376,7 +376,7 @@ class CallActivity : CallBaseActivity() { if (java.lang.Boolean.TRUE == notificationPermission) { Log.d(TAG, "Notification permission was granted") } else { - Log.w(TAG, "DEBUG: Notification permission was denied - this may cause call hang") + Log.d(TAG, "Notification permission is not yet granted. Request will be made for permission.") rationaleList.add(resources.getString(R.string.nc_notification_permission_hint)) } } @@ -385,21 +385,7 @@ class CallActivity : CallBaseActivity() { showRationaleDialogForSettings(rationaleList) } - // DEBUG: Check if we should proceed with call despite notification permission - val notificationPermissionGranted = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { - permissionMap[Manifest.permission.POST_NOTIFICATIONS] == true - } else { - true // Older Android versions have permission by default - } - - Log.d( - TAG, - "Notification permission granted: $notificationPermissionGranted, " + - "isConnectionEstablished: $isConnectionEstablished" - ) - if (!isConnectionEstablished) { - Log.d(TAG, "DEBUG: Proceeding with prepareCall() despite notification permission status") prepareCall() } } @@ -433,8 +419,7 @@ class CallActivity : CallBaseActivity() { // Register broadcast receiver for ending call from notification val endCallFilter = IntentFilter(END_CALL_FROM_NOTIFICATION) - // Use the proper utility function with ReceiverFlag for Android 14+ compatibility - // This receiver is for internal app use only (notification actions), so it should NOT be exported + // internal receiver for notification actions, so not exported registerPermissionHandlerBroadcastReceiver( endCallFromNotificationReceiver, endCallFilter, @@ -443,8 +428,6 @@ class CallActivity : CallBaseActivity() { ReceiverFlag.NotExported ) - Log.d(TAG, "Broadcast receiver registered successfully") - callViewModel = ViewModelProvider(this, viewModelFactory)[CallViewModel::class.java] rootEglBase = EglBase.create() @@ -725,6 +708,32 @@ class CallActivity : CallBaseActivity() { } } + override fun onNewIntent(intent: Intent) { + super.onNewIntent(intent) + if (currentCallStatus === CallStatus.LEAVING) { + Log.d(TAG, "onNewIntent: call is already being left, ignoring intent") + return + } + val newRoomToken = intent.getStringExtra(KEY_ROOM_TOKEN) + Log.d(TAG, "onNewIntent: newRoomToken=$newRoomToken roomToken=$roomToken") + + when { + // notification tap without extras: just bring the current call back to the front + newRoomToken.isNullOrEmpty() -> Unit + + // re-entry for the call this instance is already handling (singleTask reuse) + newRoomToken == roomToken -> setIntent(intent) + + // a call for another room was requested while this instance lingered in the background: + // end the current call and restart cleanly in onDestroy, so no stale state is reused + else -> { + Log.d(TAG, "onNewIntent: call requested for another room, ending current call first") + pendingCallIntent = Intent(intent) + hangup(shutDownView = true, endCallForAll = false) + } + } + } + override fun onResume() { super.onResume() if (hasSpreedFeatureCapability( @@ -760,8 +769,11 @@ class CallActivity : CallBaseActivity() { override fun onStop() { super.onStop() - Log.d(TAG, "CallActivity.onStop: isInPipMode=$isInPipMode currentCallStatus=$currentCallStatus" + - " isFinishing=$isFinishing isChangingConfigurations=$isChangingConfigurations") + Log.d( + TAG, + "CallActivity.onStop: isInPipMode=$isInPipMode currentCallStatus=$currentCallStatus" + + " isFinishing=$isFinishing isChangingConfigurations=$isChangingConfigurations" + ) active = false if (isMicInputAudioThreadRunning) { @@ -866,11 +878,9 @@ class CallActivity : CallBaseActivity() { true } binding!!.hangupButton.setOnClickListener { - isIntentionallyLeavingCall = true hangup(shutDownView = true, endCallForAll = true) } binding!!.endCallPopupMenu.setOnClickListener { - isIntentionallyLeavingCall = true hangup(shutDownView = true, endCallForAll = true) binding!!.endCallPopupMenu.visibility = View.GONE } @@ -882,11 +892,9 @@ class CallActivity : CallBaseActivity() { } } binding!!.hangupButton.setOnClickListener { - isIntentionallyLeavingCall = true hangup(shutDownView = true, endCallForAll = false) } binding!!.endCallPopupMenu.setOnClickListener { - isIntentionallyLeavingCall = true hangup(shutDownView = true, endCallForAll = false) binding!!.endCallPopupMenu.visibility = View.GONE } @@ -1140,28 +1148,22 @@ class CallActivity : CallBaseActivity() { private fun prepareCall() { stopCallingSound() - Log.d(TAG, "prepareCall() started") + basicInitialization() + initViews() + checkRecordingConsentAndInitiateCall() if (permissionUtil!!.isMicrophonePermissionGranted()) { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { - if (permissionUtil!!.isPostNotificationsPermissionGranted()) { - Log.d(TAG, "DEBUG: Starting foreground service with notification permission") - CallForegroundService.start(applicationContext, conversationName, intent.extras) - } else { - Log.w( - TAG, - "Notification permission not granted - call will work " + - "but without persistent notification" - ) - Snackbar.make( - binding!!.root, - resources.getString(R.string.nc_notification_permission_hint), - SEC_10 - ).show() - } + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU && + !permissionUtil!!.isPostNotificationsPermissionGranted() + ) { + // the call works without the persistent notification, but returning to it is harder + Log.w(TAG, "Notification permission not granted - no persistent notification will be shown") + Snackbar.make( + binding!!.root, + resources.getString(R.string.nc_notification_permission_hint), + SEC_10 + ).show() } else { - // Android 12 and below - notification permission is automatically granted - Log.d(TAG, "DEBUG: Starting foreground service (Android 12-)") CallForegroundService.start(applicationContext, conversationName, intent.extras) } @@ -1172,16 +1174,9 @@ class CallActivity : CallBaseActivity() { Log.w(TAG, "DEBUG: Microphone permission not granted - skipping foreground service start") } - Log.d(TAG, "Ensuring call proceeds even without notification permission") - - basicInitialization() - initViews() - checkRecordingConsentAndInitiateCall() - if (isVoiceOnlyCall) { binding!!.selfVideoViewWrapper.visibility = View.GONE } else if (permissionUtil!!.isCameraPermissionGranted()) { - Log.d(TAG, "DEBUG: Camera permission granted, showing video") binding!!.selfVideoViewWrapper.visibility = View.VISIBLE // don't enable the camera if call was answered via notification if (!isIncomingCallFromNotification) { @@ -1190,8 +1185,6 @@ class CallActivity : CallBaseActivity() { if (cameraEnumerator!!.deviceNames.isEmpty()) { binding!!.cameraButton.visibility = View.GONE } - } else { - Log.w(TAG, "DEBUG: Camera permission not granted, hiding video") } } @@ -1209,29 +1202,15 @@ class CallActivity : CallBaseActivity() { rationalesWithLineBreaks.append(rationale).append("\n\n") } - // Log when permission rationale dialog is shown - Log.d(TAG, "Showing permission rationale dialog for permissions: $permissionsToRequest") - val hasNotificationPerm = permissionsToRequest - .contains(Manifest.permission.POST_NOTIFICATIONS) - Log.d(TAG, "Rationale includes notification permission: $hasNotificationPerm") - val dialogBuilder = MaterialAlertDialogBuilder(this) .setTitle(R.string.nc_permissions_rationale_dialog_title) .setMessage(rationalesWithLineBreaks) .setPositiveButton(R.string.nc_permissions_ask) { _, _ -> - Log.d(TAG, "DEBUG: User clicked 'Ask' for permissions") requestPermissionLauncher.launch(permissionsToRequest.toTypedArray()) } .setNegativeButton(R.string.nc_common_dismiss) { _, _ -> - // DEBUG: Log when user dismisses permission request - Log.w(TAG, "DEBUG: User dismissed permission request for: $permissionsToRequest") - if (permissionsToRequest.contains(Manifest.permission.POST_NOTIFICATIONS)) { - Log.w(TAG, "DEBUG: Notification permission specifically dismissed - proceeding with call anyway") - } - - // Proceed with call even when notification permission is dismissed + // Proceed with the call even when permissions (e.g. notifications) are dismissed if (!isConnectionEstablished) { - Log.d(TAG, "DEBUG: Proceeding with prepareCall() after dismissing notification permission") prepareCall() } } @@ -1516,57 +1495,38 @@ class CallActivity : CallBaseActivity() { } public override fun onDestroy() { - Log.d(TAG, "onDestroy called") - Log.d(TAG, "onDestroy: isIntentionallyLeavingCall=$isIntentionallyLeavingCall") Log.d(TAG, "onDestroy: currentCallStatus=$currentCallStatus") - val isSystemInitiatedDestroy = !isIntentionallyLeavingCall && currentCallStatus !== CallStatus.LEAVING - + // The call cannot survive the activity being destroyed (WebRTC connections, local stream and + // signaling listeners all live here), so always clean up and hang up. Background survival is + // achieved via moveTaskToBack/PiP, which do not destroy the activity. if (signalingMessageReceiver != null) { - if (!isSystemInitiatedDestroy) { - signalingMessageReceiver!!.removeListener(localParticipantMessageListener) - signalingMessageReceiver!!.removeListener(offerMessageListener) - } else { - Log.d(TAG, "System-initiated destroy, keeping signaling listeners for foreground service") - } + signalingMessageReceiver!!.removeListener(localParticipantMessageListener) + signalingMessageReceiver!!.removeListener(offerMessageListener) } if (localStream != null) { - if (!isSystemInitiatedDestroy) { - localStream!!.dispose() - localStream = null - Log.d(TAG, "Disposed localStream (intentionally leaving)") - } else { - Log.d(TAG, "System-initiated destroy, keeping localStream alive for foreground service") - } + localStream!!.dispose() + localStream = null + Log.d(TAG, "Disposed localStream") } else { Log.d(TAG, "localStream is null") } if (currentCallStatus !== CallStatus.LEAVING) { - if (isIntentionallyLeavingCall) { - hangup(true, false) - } + hangup(true, false) } CallForegroundService.stop(applicationContext) - Log.d(TAG, "Foreground service stop requested from onDestroy()") - if (!isSystemInitiatedDestroy) { - Log.d(TAG, "onDestroy: Releasing proximity sensor - updating to IDLE state") - powerManagerUtils!!.updatePhoneState(PowerManagerUtils.PhoneState.IDLE) - Log.d(TAG, "onDestroy: Proximity sensor released") - } else { - Log.d(TAG, "System-initiated destroy, keeping proximity sensor active") + powerManagerUtils!!.updatePhoneState(PowerManagerUtils.PhoneState.IDLE) + + try { + unregisterReceiver(endCallFromNotificationReceiver) + } catch (e: IllegalArgumentException) { + Log.w(TAG, "Failed to unregister endCallFromNotificationReceiver", e) } - if (!isSystemInitiatedDestroy) { - try { - Log.d(TAG, "Unregistering endCallFromNotificationReceiver...") - unregisterReceiver(endCallFromNotificationReceiver) - Log.d(TAG, "endCallFromNotificationReceiver unregistered successfully") - } catch (e: IllegalArgumentException) { - Log.w(TAG, "Failed to unregister endCallFromNotificationReceiver", e) - } - } else { - Log.d(TAG, "System-initiated destroy, keeping endCallFromNotificationReceiver registered") + pendingCallIntent?.let { + Log.d(TAG, "onDestroy: starting CallActivity for pending call intent") + startActivity(it) } super.onDestroy() @@ -2096,8 +2056,11 @@ class CallActivity : CallBaseActivity() { } "roomJoined" -> { - Log.d(TAG, "onMessageEvent 'roomJoined' joinRoomInitiated=$joinRoomInitiated" + - " currentCallStatus=$currentCallStatus") + Log.d( + TAG, + "onMessageEvent 'roomJoined' joinRoomInitiated=$joinRoomInitiated" + + " currentCallStatus=$currentCallStatus" + ) if (!joinRoomInitiated) { Log.d(TAG, "Ignoring stale roomJoined event (joinRoomAndCall not yet called)") return @@ -2105,8 +2068,11 @@ class CallActivity : CallBaseActivity() { startSendingNick() if (webSocketCommunicationEvent.getHashMap()!!["roomToken"] == roomToken) { if (currentCallStatus === CallStatus.IN_CONVERSATION) { - Log.d(TAG, "Already in conversation, skipping performCall()" + - " (ChatActivity resume triggered spurious roomJoined)") + Log.d( + TAG, + "Already in conversation, skipping performCall()" + + " (ChatActivity resume triggered spurious roomJoined)" + ) } else { roomJoinRefreshes = 0 performCall() @@ -2179,8 +2145,6 @@ class CallActivity : CallBaseActivity() { private fun hangup(shutDownView: Boolean, endCallForAll: Boolean) { Log.d(TAG, "hangup! shutDownView=$shutDownView, endCallForAll=$endCallForAll") joinRoomInitiated = false - Log.d(TAG, "hangup! isIntentionallyLeavingCall=$isIntentionallyLeavingCall") - Log.d(TAG, "hangup! powerManagerUtils state before cleanup: ${powerManagerUtils != null}") if (shutDownView) { setCallState(CallStatus.LEAVING) } @@ -2287,7 +2251,14 @@ class CallActivity : CallBaseActivity() { { e -> Log.w(TAG, "Something went wrong when leaving the call", e) } ) - if (switchToRoomToken.isNotEmpty()) { + val conversationModel = currentConversation?.let { + ConversationModel.mapToConversationModel(it, conversationUser) + } + + if (conversationModel?.checkIfVoiceRoom() == true) { + openConversationListInPrimaryTask() + finishAndRemoveTask() + } else if (switchToRoomToken.isNotEmpty()) { val intent = Intent(context, ChatActivity::class.java) intent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP) val bundle = Bundle() @@ -3279,8 +3250,11 @@ class CallActivity : CallBaseActivity() { override fun onPictureInPictureModeChanged(isInPictureInPictureMode: Boolean, newConfig: Configuration) { super.onPictureInPictureModeChanged(isInPictureInPictureMode, newConfig) - Log.d(TAG, "onPictureInPictureModeChanged: isInPictureInPictureMode=$isInPictureInPictureMode" + - " currentCallStatus=$currentCallStatus isIntentionallyLeavingCall=$isIntentionallyLeavingCall") + Log.d( + TAG, + "onPictureInPictureModeChanged: isInPictureInPictureMode=$isInPictureInPictureMode" + + " currentCallStatus=$currentCallStatus" + ) isInPipMode = isInPictureInPictureMode if (isInPictureInPictureMode) { mReceiver = object : BroadcastReceiver() { @@ -3411,7 +3385,6 @@ class CallActivity : CallBaseActivity() { private val endCallFromNotificationReceiver = object : BroadcastReceiver() { override fun onReceive(context: Context, intent: Intent) { if (intent.action == END_CALL_FROM_NOTIFICATION) { - isIntentionallyLeavingCall = true powerManagerUtils?.updatePhoneState(PowerManagerUtils.PhoneState.IDLE) hangup(shutDownView = true, endCallForAll = false) } diff --git a/app/src/main/java/com/nextcloud/talk/activities/CallBaseActivity.java b/app/src/main/java/com/nextcloud/talk/activities/CallBaseActivity.java index d834915913..0051f0d306 100644 --- a/app/src/main/java/com/nextcloud/talk/activities/CallBaseActivity.java +++ b/app/src/main/java/com/nextcloud/talk/activities/CallBaseActivity.java @@ -13,7 +13,6 @@ import android.content.pm.PackageManager; import android.os.Build; import android.os.Bundle; -import android.os.PowerManager; import android.util.Log; import android.util.Rational; import android.view.View; @@ -156,8 +155,8 @@ && isPipModePossible() return; } // On API 31+: if auto-enter didn't handle it (task switch), move the - // task to back so the activity survives instead of being destroyed - // (excludeFromRecents + separate taskAffinity causes task death). + // task to back so the activity survives. The ongoing call notification + // is the way back, as the call task is excluded from recents. if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S && !isInPictureInPictureMode() && isPipModePossible()) { @@ -192,19 +191,6 @@ boolean isPipModePossible() { return deviceHasPipFeature && isPipFeatureGranted; } - private boolean shouldFinishOnStop() { - if (!isInPipMode) { - return false; - } - - PowerManager powerManager = (PowerManager) getSystemService(Context.POWER_SERVICE); - if (powerManager == null) { - return true; - } - - return powerManager.isInteractive(); - } - public abstract void updateUiForPipMode(); public abstract void updateUiForNormalMode(); diff --git a/app/src/main/java/com/nextcloud/talk/chat/ChatActivity.kt b/app/src/main/java/com/nextcloud/talk/chat/ChatActivity.kt index 31265a95e4..0b16d72b06 100644 --- a/app/src/main/java/com/nextcloud/talk/chat/ChatActivity.kt +++ b/app/src/main/java/com/nextcloud/talk/chat/ChatActivity.kt @@ -391,7 +391,6 @@ class ChatActivity : private lateinit var path: String var myFirstMessage: CharSequence? = null - var checkingLobbyStatus: Boolean = false private var isLeavingRoom: Boolean = false private var lastHandledHighlightNonce: Long? = null @@ -516,8 +515,6 @@ class ChatActivity : val typingParticipants = HashMap() - var callStarted = false - private val leaveRoomObserver = androidx.lifecycle.Observer { state -> when (state) { is ChatViewModel.LeaveRoomSuccessState -> { @@ -525,22 +522,11 @@ class ChatActivity : isLeavingRoom = false - checkingLobbyStatus = false - if (getRoomInfoTimerHandler != null) { getRoomInfoTimerHandler?.removeCallbacksAndMessages(null) } ApplicationWideCurrentRoomHolder.getInstance().clear() - - if (webSocketInstance != null && currentConversation != null) { - webSocketInstance?.joinRoomWithRoomTokenAndSession( - "", - sessionIdAfterRoomJoined - ) - } - - sessionIdAfterRoomJoined = "0" } else -> {} @@ -2070,6 +2056,9 @@ class ChatActivity : pullChatMessagesPending = false + // reset in case a previously started leave failed (success already resets this in leaveRoomObserver) + isLeavingRoom = false + webSocketInstance?.getSignalingMessageReceiver()?.addListener(localParticipantMessageListener) webSocketInstance?.getSignalingMessageReceiver()?.addListener(conversationMessageListener) diff --git a/app/src/main/java/com/nextcloud/talk/services/CallForegroundService.kt b/app/src/main/java/com/nextcloud/talk/services/CallForegroundService.kt index 92edf79942..dc0d7ea804 100644 --- a/app/src/main/java/com/nextcloud/talk/services/CallForegroundService.kt +++ b/app/src/main/java/com/nextcloud/talk/services/CallForegroundService.kt @@ -38,7 +38,7 @@ import com.nextcloud.talk.utils.singletons.ApplicationWideCurrentRoomHolder class CallForegroundService : Service() { private val handler = Handler(Looper.getMainLooper()) - private var currentNotificationId: Int = NOTIFICATION_ID + private var currentCallExtras: Bundle? = null override fun onBind(intent: Intent?): IBinder? = null @@ -49,6 +49,7 @@ class CallForegroundService : Service() { val conversationName = intent?.getStringExtra(EXTRA_CONVERSATION_NAME) val callExtras = intent?.getBundleExtra(EXTRA_CALL_INTENT_EXTRAS) + currentCallExtras = callExtras val notification = buildNotification(conversationName, callExtras) val foregroundServiceType = resolveForegroundServiceType(callExtras) @@ -117,10 +118,7 @@ class CallForegroundService : Service() { } @SuppressLint("NewApi") - private fun buildCallStyleNotification( - contentTitle: String, - pendingIntent: PendingIntent - ): Notification { + private fun buildCallStyleNotification(contentTitle: String, pendingIntent: PendingIntent): Notification { val caller = Person.Builder() .setName(contentTitle) .setIcon(Icon.createWithResource(this, R.drawable.ic_call_white_24dp)) @@ -164,7 +162,7 @@ class CallForegroundService : Service() { val conversationName = ApplicationWideCurrentRoomHolder.getInstance() .userInRoom?.displayName ?: getString(R.string.nc_call_ongoing_notification_default_title) - val pendingIntent = createContentIntent(null) + val pendingIntent = createContentIntent(currentCallExtras) val notification = buildCallStyleNotification(conversationName, pendingIntent) startForeground(NOTIFICATION_ID, notification) From 8b83917f2a18d48f338893f34d033f3540ad3f8e Mon Sep 17 00:00:00 2001 From: Tarek Loubani Date: Thu, 20 Aug 2026 11:05:21 -0400 Subject: [PATCH 25/30] fix(call): don't move task to back on onUserLeaveHint onUserLeaveHint also fires when a transient overlay such as the runtime permission dialog appears at call start. Calling moveTaskToBack there threw the just-started call to the background while it kept ringing, leaving the user back in chat with no visible call. Backgrounding via home or task switch never destroys the activity, so this was unnecessary; the back button case remains handled by the OnBackPressedCallback. Assisted-by: OpenCode:Kimi-K3 Signed-off-by: Tarek Loubani --- .../talk/activities/CallBaseActivity.java | 15 ++++----------- 1 file changed, 4 insertions(+), 11 deletions(-) diff --git a/app/src/main/java/com/nextcloud/talk/activities/CallBaseActivity.java b/app/src/main/java/com/nextcloud/talk/activities/CallBaseActivity.java index 0051f0d306..ec6ff7930f 100644 --- a/app/src/main/java/com/nextcloud/talk/activities/CallBaseActivity.java +++ b/app/src/main/java/com/nextcloud/talk/activities/CallBaseActivity.java @@ -147,21 +147,14 @@ protected void onUserLeaveHint() { super.onUserLeaveHint(); Log.d(TAG, "onUserLeaveHint: isInPipMode=" + isInPipMode + " isInPictureInPictureMode=" + isInPictureInPictureMode()); - // On API 26-30, enter PIP manually. + // On API 26-30, enter PIP manually. On API 31+ auto-enter handles swipe-up/home, and plain + // backgrounding (e.g. task switch) keeps the activity alive on its own. Deliberately no + // moveTaskToBack here: onUserLeaveHint also fires when a transient overlay like the + // permission dialog appears at call start, which would throw the call to the background. if (!isInPipMode && isPipModePossible() && Build.VERSION.SDK_INT < Build.VERSION_CODES.S) { enterPipMode(); - return; - } - // On API 31+: if auto-enter didn't handle it (task switch), move the - // task to back so the activity survives. The ongoing call notification - // is the way back, as the call task is excluded from recents. - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S - && !isInPictureInPictureMode() - && isPipModePossible()) { - Log.d(TAG, "onUserLeaveHint: not PIP, moving task to back to survive task switch"); - moveTaskToBack(true); } } From 0709b63814b0df705d769bfe8b5b89b52133bf78 Mon Sep 17 00:00:00 2001 From: Marcel Hibbe Date: Fri, 11 Sep 2026 11:13:11 +0200 Subject: [PATCH 26/30] fixes after merge conflicts Signed-off-by: Marcel Hibbe --- .../nextcloud/talk/activities/CallActivity.kt | 18 ------------------ 1 file changed, 18 deletions(-) diff --git a/app/src/main/java/com/nextcloud/talk/activities/CallActivity.kt b/app/src/main/java/com/nextcloud/talk/activities/CallActivity.kt index b85d1fc7a0..119ff33eb6 100644 --- a/app/src/main/java/com/nextcloud/talk/activities/CallActivity.kt +++ b/app/src/main/java/com/nextcloud/talk/activities/CallActivity.kt @@ -1139,10 +1139,6 @@ class CallActivity : CallBaseActivity() { } } else if (!isConnectionEstablished) { prepareCall() - } else { - // DEBUG: All permissions granted but connection not established - Log.d(TAG, "DEBUG: All permissions granted but connection not established, proceeding with prepareCall()") - prepareCall() } } @@ -3454,18 +3450,4 @@ class CallActivity : CallBaseActivity() { private const val SESSION_ID_PREFFIX_END: Int = 4 } - - // Broadcast receiver to handle end call from notification - private val endCallFromNotificationReceiver = object : BroadcastReceiver() { - override fun onReceive(context: Context, intent: Intent) { - if (intent.action == "com.nextcloud.talk.END_CALL_FROM_NOTIFICATION") { - Log.d(TAG, "Received end call from notification broadcast") - Log.d(TAG, "endCallFromNotificationReceiver: Setting isIntentionallyLeavingCall=true") - isIntentionallyLeavingCall = true - Log.d(TAG, "endCallFromNotificationReceiver: Releasing proximity sensor before hangup") - powerManagerUtils?.updatePhoneState(PowerManagerUtils.PhoneState.IDLE) - hangup(shutDownView = true, endCallForAll = false) - } - } - } } From 4e82ccf8cb6e69be2a3ec905b40b8bfcb1f55df9 Mon Sep 17 00:00:00 2001 From: Marcel Hibbe Date: Fri, 11 Sep 2026 11:47:48 +0200 Subject: [PATCH 27/30] fix(call): pad call screen for system bars and force light status/nav bar icons targetSdk 36 forces edge-to-edge, but CallActivity never applied inset padding like other activities do, so its top bar was drawn under the status bar. Also, the base activity's default status bar style follows the system light/dark theme, which produced black icons on the call screen's always-dark background. Assisted-by: Claude Code:claude-sonnet-5 Signed-off-by: Marcel Hibbe --- .../nextcloud/talk/activities/CallActivity.kt | 27 +++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/app/src/main/java/com/nextcloud/talk/activities/CallActivity.kt b/app/src/main/java/com/nextcloud/talk/activities/CallActivity.kt index 119ff33eb6..7ef70297f2 100644 --- a/app/src/main/java/com/nextcloud/talk/activities/CallActivity.kt +++ b/app/src/main/java/com/nextcloud/talk/activities/CallActivity.kt @@ -42,6 +42,8 @@ import android.view.OrientationEventListener import android.view.View import android.view.View.OnTouchListener import android.widget.Toast +import androidx.activity.SystemBarStyle +import androidx.activity.enableEdgeToEdge import androidx.activity.result.contract.ActivityResultContracts import androidx.annotation.DrawableRes import androidx.appcompat.app.AlertDialog @@ -54,6 +56,8 @@ import androidx.compose.runtime.setValue import androidx.core.app.NotificationManagerCompat import androidx.core.graphics.drawable.DrawableCompat import androidx.core.net.toUri +import androidx.core.view.ViewCompat +import androidx.core.view.WindowInsetsCompat import androidx.lifecycle.ViewModelProvider import autodagger.AutoInjector import com.bluelinelabs.logansquare.LoganSquare @@ -434,6 +438,29 @@ class CallActivity : CallBaseActivity() { binding = CallActivityBinding.inflate(layoutInflater) setContentView(binding!!.root) + // the call screen background is always dark, regardless of the system light/dark theme, + // so status/navigation bar icons must always be light rather than following the system theme + enableEdgeToEdge( + statusBarStyle = SystemBarStyle.dark(Color.TRANSPARENT), + navigationBarStyle = SystemBarStyle.dark(Color.TRANSPARENT) + ) + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.VANILLA_ICE_CREAM) { + ViewCompat.setOnApplyWindowInsetsListener(binding!!.callLayout) { view, insets -> + val systemBarInsets = insets.getInsets( + WindowInsetsCompat.Type.systemBars() or + WindowInsetsCompat.Type.displayCutout() + ) + view.setPadding( + systemBarInsets.left, + systemBarInsets.top, + systemBarInsets.right, + systemBarInsets.bottom + ) + WindowInsetsCompat.CONSUMED + } + } + binding!!.screenShareFullscreenView.setContent { MaterialTheme { val screenShareParticipantUiState by callViewModel.activeScreenShareSession.collectAsState() From 5a94481ff92928ec7f3e3c59763bffd0dbb14ea1 Mon Sep 17 00:00:00 2001 From: Marcel Hibbe Date: Fri, 11 Sep 2026 11:52:04 +0200 Subject: [PATCH 28/30] fix(call): show conversation avatar in ongoing-call notification The CallStyle notification's Person always used a generic phone icon instead of the actual conversation avatar. Fetch the avatar in the background via a CoroutineScope tied to the service's lifecycle (cancelled in onDestroy), then re-post the notification once it's available. roomToken/baseUrl/credentials are passed explicitly as an AvatarInfo built from CallActivity's already-resolved fields, rather than read from ApplicationWideCurrentRoomHolder (only populated once the call-join network request completes, far too late) or forwarded via CallActivity's raw launch-intent extras (which don't reflect fallbacks CallActivity applies locally, e.g. baseUrl defaulting to the current user's baseUrl) - both caused the avatar to intermittently or always fail to load. Also log the actual throwable behind a failed avatar fetch, previously swallowed silently in NotificationUtils. Assisted-by: Claude Code:claude-sonnet-5 Signed-off-by: Marcel Hibbe --- .../nextcloud/talk/activities/CallActivity.kt | 7 +- .../talk/services/CallForegroundService.kt | 82 ++++++++++++++++++- .../nextcloud/talk/utils/NotificationUtils.kt | 6 +- 3 files changed, 91 insertions(+), 4 deletions(-) diff --git a/app/src/main/java/com/nextcloud/talk/activities/CallActivity.kt b/app/src/main/java/com/nextcloud/talk/activities/CallActivity.kt index 7ef70297f2..99783920ad 100644 --- a/app/src/main/java/com/nextcloud/talk/activities/CallActivity.kt +++ b/app/src/main/java/com/nextcloud/talk/activities/CallActivity.kt @@ -1187,7 +1187,12 @@ class CallActivity : CallBaseActivity() { SEC_10 ).show() } else { - CallForegroundService.start(applicationContext, conversationName, intent.extras) + CallForegroundService.start( + applicationContext, + conversationName, + intent.extras, + CallForegroundService.AvatarInfo(roomToken, baseUrl, credentials) + ) } if (!microphoneOn && !appPreferences.callMicrophoneMuted) { diff --git a/app/src/main/java/com/nextcloud/talk/services/CallForegroundService.kt b/app/src/main/java/com/nextcloud/talk/services/CallForegroundService.kt index dc0d7ea804..fa3cbdd7bd 100644 --- a/app/src/main/java/com/nextcloud/talk/services/CallForegroundService.kt +++ b/app/src/main/java/com/nextcloud/talk/services/CallForegroundService.kt @@ -15,6 +15,7 @@ import android.app.Service import android.content.Context import android.content.Intent import android.content.pm.ServiceInfo +import android.graphics.Bitmap import android.graphics.drawable.Icon import android.os.Build import android.os.Bundle @@ -34,11 +35,32 @@ import com.nextcloud.talk.utils.NotificationUtils import com.nextcloud.talk.utils.bundle.BundleKeys.KEY_CALL_VOICE_ONLY import com.nextcloud.talk.utils.bundle.BundleKeys.KEY_PARTICIPANT_PERMISSION_CAN_PUBLISH_VIDEO import com.nextcloud.talk.utils.singletons.ApplicationWideCurrentRoomHolder +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext class CallForegroundService : Service() { + /** + * Data needed to resolve the conversation avatar for the notification. Passed explicitly rather + * than forwarded via the call intent extras or read from [ApplicationWideCurrentRoomHolder]: the + * extras on CallActivity's own launch intent don't reflect fallbacks CallActivity applies + * afterwards (e.g. baseUrl falling back to the current user's baseUrl), and the room holder is + * only populated once the call-join network request completes - both too late/unreliable for this. + */ + data class AvatarInfo(val roomToken: String?, val baseUrl: String?, val credentials: String?) + private val handler = Handler(Looper.getMainLooper()) + private val serviceScope = CoroutineScope(SupervisorJob() + Dispatchers.IO) private var currentCallExtras: Bundle? = null + private var currentConversationName: String? = null + private var currentRoomToken: String? = null + private var currentBaseUrl: String? = null + private var currentCredentials: String? = null + private var conversationAvatarBitmap: Bitmap? = null override fun onBind(intent: Intent?): IBinder? = null @@ -50,6 +72,11 @@ class CallForegroundService : Service() { val conversationName = intent?.getStringExtra(EXTRA_CONVERSATION_NAME) val callExtras = intent?.getBundleExtra(EXTRA_CALL_INTENT_EXTRAS) currentCallExtras = callExtras + currentConversationName = conversationName + currentRoomToken = intent?.getStringExtra(EXTRA_ROOM_TOKEN) + currentBaseUrl = intent?.getStringExtra(EXTRA_BASE_URL) + currentCredentials = intent?.getStringExtra(EXTRA_CREDENTIALS) + conversationAvatarBitmap = null val notification = buildNotification(conversationName, callExtras) val foregroundServiceType = resolveForegroundServiceType(callExtras) @@ -60,6 +87,7 @@ class CallForegroundService : Service() { startForeground(NOTIFICATION_ID, notification) } + loadConversationAvatarAsync() startTimeBasedNotificationUpdates() return START_STICKY @@ -68,6 +96,7 @@ class CallForegroundService : Service() { override fun onDestroy() { Log.d(TAG, "onDestroy called") handler.removeCallbacksAndMessages(null) + serviceScope.cancel() stopForeground(STOP_FOREGROUND_REMOVE) super.onDestroy() } @@ -119,9 +148,12 @@ class CallForegroundService : Service() { @SuppressLint("NewApi") private fun buildCallStyleNotification(contentTitle: String, pendingIntent: PendingIntent): Notification { + val callerIcon = conversationAvatarBitmap?.let { Icon.createWithBitmap(it) } + ?: Icon.createWithResource(this, R.drawable.ic_call_white_24dp) + val caller = Person.Builder() .setName(contentTitle) - .setIcon(Icon.createWithResource(this, R.drawable.ic_call_white_24dp)) + .setIcon(callerIcon) .setImportant(true) .build() @@ -151,6 +183,46 @@ class CallForegroundService : Service() { .build() } + /** + * The CallStyle notification's Person icon defaults to a generic phone icon; this fetches the + * conversation avatar in the background and re-posts the notification once it is available. + */ + private fun loadConversationAvatarAsync() { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.S) return + + val roomToken = currentRoomToken + if (roomToken.isNullOrBlank()) { + Log.w(TAG, "loadConversationAvatarAsync: roomToken is blank, skipping avatar load") + return + } + if (currentBaseUrl.isNullOrBlank()) { + Log.w(TAG, "loadConversationAvatarAsync: baseUrl is blank, avatar load will likely fail") + } + + serviceScope.launch { + val bitmap = NotificationUtils.loadConversationAvatarBitmapSync( + currentBaseUrl, + roomToken, + currentCredentials, + applicationContext + ) + Log.d(TAG, "loadConversationAvatarAsync: avatar bitmap loaded=${bitmap != null}") + if (bitmap != null) { + conversationAvatarBitmap = bitmap + withContext(Dispatchers.Main) { refreshCallStyleNotification() } + } + } + } + + @SuppressLint("NewApi") + private fun refreshCallStyleNotification() { + val contentTitle = currentConversationName?.takeIf { it.isNotBlank() } + ?: getString(R.string.nc_call_ongoing_notification_default_title) + val pendingIntent = createContentIntent(currentCallExtras) + val notification = buildCallStyleNotification(contentTitle, pendingIntent) + startForeground(NOTIFICATION_ID, notification) + } + @SuppressLint("NewApi", "ForegroundServiceType") private fun startTimeBasedNotificationUpdates() { if (Build.VERSION.SDK_INT < Build.VERSION_CODES.S) return @@ -236,11 +308,17 @@ class CallForegroundService : Service() { private const val FOREGROUND_SERVICE_TYPE_ZERO = 0 private const val EXTRA_CONVERSATION_NAME = "extra_conversation_name" private const val EXTRA_CALL_INTENT_EXTRAS = "extra_call_intent_extras" + private const val EXTRA_ROOM_TOKEN = "extra_room_token" + private const val EXTRA_BASE_URL = "extra_base_url" + private const val EXTRA_CREDENTIALS = "extra_credentials" private const val CALL_DURATION_UPDATE_INTERVAL = 1000L - fun start(context: Context, conversationName: String?, callIntentExtras: Bundle?) { + fun start(context: Context, conversationName: String?, callIntentExtras: Bundle?, avatarInfo: AvatarInfo) { val serviceIntent = Intent(context, CallForegroundService::class.java).apply { putExtra(EXTRA_CONVERSATION_NAME, conversationName) + putExtra(EXTRA_ROOM_TOKEN, avatarInfo.roomToken) + putExtra(EXTRA_BASE_URL, avatarInfo.baseUrl) + putExtra(EXTRA_CREDENTIALS, avatarInfo.credentials) callIntentExtras?.let { putExtra(EXTRA_CALL_INTENT_EXTRAS, Bundle(it)) } } ContextCompat.startForegroundService(context, serviceIntent) diff --git a/app/src/main/java/com/nextcloud/talk/utils/NotificationUtils.kt b/app/src/main/java/com/nextcloud/talk/utils/NotificationUtils.kt index 15f80f0202..4fbb12e444 100644 --- a/app/src/main/java/com/nextcloud/talk/utils/NotificationUtils.kt +++ b/app/src/main/java/com/nextcloud/talk/utils/NotificationUtils.kt @@ -335,6 +335,11 @@ object NotificationUtils { .data(avatarUrl) .transformations(CircleCropTransformation()) .placeholder(R.drawable.account_circle_96dp) + .listener( + onError = { _, result -> + Log.w(TAG, "Can't load avatar for URL: $avatarUrl", result.throwable) + } + ) .target( onSuccess = { result -> avatarBitmap = (result as BitmapDrawable).bitmap @@ -343,7 +348,6 @@ object NotificationUtils { error?.let { avatarBitmap = (error as BitmapDrawable).bitmap } - Log.w(TAG, "Can't load avatar for URL: $avatarUrl") } ) From 543e17382cbaa8f708c36a6254a6975cb58bd708 Mon Sep 17 00:00:00 2001 From: Marcel Hibbe Date: Fri, 11 Sep 2026 12:05:08 +0200 Subject: [PATCH 29/30] fix(call): re-apply dark status bar style after returning from PIP Exiting picture-in-picture resets the window's system bar appearance to the system default, which brought back black-on-black status bar icons when returning to the call. Re-apply the dark style in updateUiForNormalMode(), extracted into applyDarkSystemBarStyle(). Assisted-by: Claude Code:claude-sonnet-5 Signed-off-by: Marcel Hibbe --- .../nextcloud/talk/activities/CallActivity.kt | 22 ++++++++++++++----- 1 file changed, 16 insertions(+), 6 deletions(-) diff --git a/app/src/main/java/com/nextcloud/talk/activities/CallActivity.kt b/app/src/main/java/com/nextcloud/talk/activities/CallActivity.kt index 99783920ad..ee88fd9a78 100644 --- a/app/src/main/java/com/nextcloud/talk/activities/CallActivity.kt +++ b/app/src/main/java/com/nextcloud/talk/activities/CallActivity.kt @@ -438,12 +438,7 @@ class CallActivity : CallBaseActivity() { binding = CallActivityBinding.inflate(layoutInflater) setContentView(binding!!.root) - // the call screen background is always dark, regardless of the system light/dark theme, - // so status/navigation bar icons must always be light rather than following the system theme - enableEdgeToEdge( - statusBarStyle = SystemBarStyle.dark(Color.TRANSPARENT), - navigationBarStyle = SystemBarStyle.dark(Color.TRANSPARENT) - ) + applyDarkSystemBarStyle() if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.VANILLA_ICE_CREAM) { ViewCompat.setOnApplyWindowInsetsListener(binding!!.callLayout) { view, insets -> @@ -3381,12 +3376,27 @@ class CallActivity : CallBaseActivity() { binding!!.callControls.visibility = View.VISIBLE initViews() binding!!.selfVideoViewWrapper.visibility = View.VISIBLE + + // returning from PIP resets the system bar appearance, so it must be re-applied + applyDarkSystemBarStyle() } override fun suppressFitsSystemWindows() { binding!!.callLayout.fitsSystemWindows = false } + /** + * The call screen background is always dark, regardless of the system light/dark theme, so + * status/navigation bar icons must always be light rather than following the system theme. + * Must be re-applied after returning from PIP, since exiting PIP resets it to the system default. + */ + private fun applyDarkSystemBarStyle() { + enableEdgeToEdge( + statusBarStyle = SystemBarStyle.dark(Color.TRANSPARENT), + navigationBarStyle = SystemBarStyle.dark(Color.TRANSPARENT) + ) + } + override fun onConfigurationChanged(newConfig: Configuration) { super.onConfigurationChanged(newConfig) eventBus.post(ConfigurationChangeEvent()) From 83b383e705dcbfc741c5cda44f9a526bc694918b Mon Sep 17 00:00:00 2001 From: Marcel Hibbe Date: Fri, 11 Sep 2026 13:11:30 +0200 Subject: [PATCH 30/30] fix(call): never heads-up the ongoing-call notification setOnlyAlertOnce(true) only suppresses re-alerting on updates, not the initial post, so the ongoing-call notification still peeked/expanded the moment the call screen opened. Heads-up eligibility is gated by notification channel importance (IMPORTANCE_HIGH), so move the persistent "call in progress" foreground-service notification onto its own new low-importance channel (NOTIFICATION_CHANNEL_CALLS_ONGOING_V1), leaving the existing NOTIFICATION_CHANNEL_CALLS_V4 untouched since it's still used for the incoming-call ringing notification, which does need to alert. The status bar icon stays; the notification is now only visible by pulling down the shade. Assisted-by: Claude Code:claude-sonnet-5 Signed-off-by: Marcel Hibbe --- .../talk/services/CallForegroundService.kt | 8 ++++--- .../nextcloud/talk/utils/NotificationUtils.kt | 21 +++++++++++++++++++ app/src/main/res/values/strings.xml | 2 ++ 3 files changed, 28 insertions(+), 3 deletions(-) diff --git a/app/src/main/java/com/nextcloud/talk/services/CallForegroundService.kt b/app/src/main/java/com/nextcloud/talk/services/CallForegroundService.kt index fa3cbdd7bd..9255a704e1 100644 --- a/app/src/main/java/com/nextcloud/talk/services/CallForegroundService.kt +++ b/app/src/main/java/com/nextcloud/talk/services/CallForegroundService.kt @@ -102,7 +102,7 @@ class CallForegroundService : Service() { } private fun buildNotification(conversationName: String?, callExtras: Bundle?): Notification { - val channelId = NotificationUtils.NotificationChannels.NOTIFICATION_CHANNEL_CALLS_V4.name + val channelId = NotificationUtils.NotificationChannels.NOTIFICATION_CHANNEL_CALLS_ONGOING_V1.name ensureNotificationChannel() val contentTitle = conversationName?.takeIf { it.isNotBlank() } @@ -135,11 +135,12 @@ class CallForegroundService : Service() { .setSmallIcon(R.drawable.ic_call_white_24dp) .setOngoing(true) .setCategory(NotificationCompat.CATEGORY_CALL) - .setPriority(NotificationCompat.PRIORITY_HIGH) + .setPriority(NotificationCompat.PRIORITY_LOW) .setForegroundServiceBehavior(FOREGROUND_SERVICE_IMMEDIATE) .setVisibility(NotificationCompat.VISIBILITY_PUBLIC) .setContentIntent(pendingIntent) .setShowWhen(false) + .setOnlyAlertOnce(true) .addAction(returnToCallAction) .addAction(endCallAction) .setAutoCancel(false) @@ -162,7 +163,7 @@ class CallForegroundService : Service() { createHangupPendingIntent() ) - val channelId = NotificationUtils.NotificationChannels.NOTIFICATION_CHANNEL_CALLS_V4.name + val channelId = NotificationUtils.NotificationChannels.NOTIFICATION_CHANNEL_CALLS_ONGOING_V1.name val callStartTime = ApplicationWideCurrentRoomHolder.getInstance().callStartTime @@ -174,6 +175,7 @@ class CallForegroundService : Service() { .setCategory(Notification.CATEGORY_CALL) .setForegroundServiceBehavior(FOREGROUND_SERVICE_IMMEDIATE) .setShowWhen(false) + .setOnlyAlertOnce(true) .also { builder -> if (callStartTime != null && callStartTime > 0) { builder.setWhen(callStartTime) diff --git a/app/src/main/java/com/nextcloud/talk/utils/NotificationUtils.kt b/app/src/main/java/com/nextcloud/talk/utils/NotificationUtils.kt index 4fbb12e444..5ca854d970 100644 --- a/app/src/main/java/com/nextcloud/talk/utils/NotificationUtils.kt +++ b/app/src/main/java/com/nextcloud/talk/utils/NotificationUtils.kt @@ -46,6 +46,7 @@ object NotificationUtils { enum class NotificationChannels { NOTIFICATION_CHANNEL_MESSAGES_V4, NOTIFICATION_CHANNEL_CALLS_V4, + NOTIFICATION_CHANNEL_CALLS_ONGOING_V1, NOTIFICATION_CHANNEL_UPLOADS } @@ -117,6 +118,25 @@ object NotificationUtils { ) } + /** + * Separate from [NotificationChannels.NOTIFICATION_CHANNEL_CALLS_V4] (the incoming-ring channel, + * which must stay IMPORTANCE_HIGH) so the persistent "call in progress" foreground-service + * notification never heads-ups/peeks - it should only be visible by pulling down the status bar. + */ + private fun createOngoingCallNotificationChannel(context: Context) { + createNotificationChannel( + context, + Channel( + NotificationChannels.NOTIFICATION_CHANNEL_CALLS_ONGOING_V1.name, + context.resources.getString(R.string.nc_notification_channel_calls_ongoing), + context.resources.getString(R.string.nc_notification_channel_calls_ongoing_description), + false + ), + null, + null + ) + } + private fun createMessagesNotificationChannel(context: Context, appPreferences: AppPreferences) { val audioAttributes = AudioAttributes.Builder() @@ -154,6 +174,7 @@ object NotificationUtils { fun registerNotificationChannels(context: Context, appPreferences: AppPreferences) { createCallsNotificationChannel(context, appPreferences) + createOngoingCallNotificationChannel(context) createMessagesNotificationChannel(context, appPreferences) createUploadsNotificationChannel(context) } diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index c28c415518..6e506ec213 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -439,9 +439,11 @@ How to translate with transifex: %1$s on %2$s notification channel Calls + Ongoing call Messages Uploads Notify about incoming calls + Persistent status for a call in progress Notify about incoming messages Notify about upload progress Notification settings