From d0e4a36f03fa5091c1e5a6f16b8aa5b260a06cbf Mon Sep 17 00:00:00 2001 From: Tarek Loubani Date: Thu, 11 Dec 2025 02:10:07 -0500 Subject: [PATCH 01/25] 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 | 1 + .../nextcloud/talk/activities/CallActivity.kt | 162 +++++++++++++++++- .../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, 254 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 ca34e3f292..bc96ae54ea 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -280,6 +280,7 @@ + -> + // 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)) } } @@ -333,6 +340,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)) } } @@ -342,6 +350,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()) { @@ -350,11 +359,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() } } @@ -383,6 +413,21 @@ class CallActivity : CallBaseActivity() { Log.d(TAG, "onCreate") 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] @@ -782,6 +827,7 @@ class CallActivity : CallBaseActivity() { true } binding!!.hangupButton.setOnClickListener { + isIntentionallyLeavingCall = true hangup(shutDownView = true, endCallForAll = true) } binding!!.endCallPopupMenu.setOnClickListener { @@ -796,6 +842,7 @@ class CallActivity : CallBaseActivity() { } } binding!!.hangupButton.setOnClickListener { + isIntentionallyLeavingCall = true hangup(shutDownView = true, endCallForAll = false) } binding!!.endCallPopupMenu.setOnClickListener { @@ -1022,6 +1069,18 @@ class CallActivity : CallBaseActivity() { permissionsToRequest.add(Manifest.permission.BLUETOOTH_CONNECT) } } + + // 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()) { @@ -1031,30 +1090,65 @@ 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() { + 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) { 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 onCameraClick() if (cameraEnumerator!!.deviceNames.isEmpty()) { binding!!.cameraButton.visibility = View.GONE } + } else { + Log.w(TAG, "DEBUG: Camera permission not granted, hiding video") } } @@ -1071,13 +1165,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() } @@ -1401,6 +1513,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) @@ -1413,10 +1529,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() } @@ -1989,7 +2124,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") + Log.d(TAG, "hangup! isIntentionallyLeavingCall=$isIntentionallyLeavingCall") + Log.d(TAG, "hangup! powerManagerUtils state before cleanup: ${powerManagerUtils != null}") + if (shutDownView) { setCallState(CallStatus.LEAVING) } @@ -3163,4 +3301,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 45fd67a587..28f20ba6d8 100644 --- a/app/src/main/java/com/nextcloud/talk/activities/CallBaseActivity.java +++ b/app/src/main/java/com/nextcloud/talk/activities/CallBaseActivity.java @@ -39,6 +39,9 @@ public abstract class CallBaseActivity extends BaseActivity { public void handleOnBackPressed() { if (isPipModePossible()) { enterPipMode(); + } else { + // Move the task to background instead of finishing + moveTaskToBack(true); } } }; @@ -98,8 +101,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(); + } } } @@ -124,10 +132,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 f6a53d8487..c9a288f9b6 100644 --- a/app/src/main/java/com/nextcloud/talk/services/CallForegroundService.kt +++ b/app/src/main/java/com/nextcloud/talk/services/CallForegroundService.kt @@ -15,12 +15,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 @@ -57,6 +59,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) @@ -69,6 +91,9 @@ class CallForegroundService : Service() { .setVisibility(NotificationCompat.VISIBILITY_PUBLIC) .setContentIntent(pendingIntent) .setShowWhen(false) + .addAction(returnToCallAction) + .addAction(endCallAction) + .setAutoCancel(false) .build() } @@ -79,13 +104,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 5c15d936af..306fb0ad0d 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -299,6 +299,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 @@ -320,6 +321,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 17a3b865c4e3987318dfbad7d0ff593e81f5dd1b Mon Sep 17 00:00:00 2001 From: Tarek Loubani Date: Sun, 21 Dec 2025 04:15:29 -0500 Subject: [PATCH 02/25] Make logging of warnings and errors more consistent with repository style Signed-off-by: Tarek Loubani --- .../nextcloud/talk/activities/CallActivity.kt | 52 +++++++++---------- 1 file changed, 26 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 03533c7d9c..41974e1965 100644 --- a/app/src/main/java/com/nextcloud/talk/activities/CallActivity.kt +++ b/app/src/main/java/com/nextcloud/talk/activities/CallActivity.kt @@ -322,8 +322,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] @@ -331,7 +331,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 was denied") rationaleList.add(resources.getString(R.string.nc_microphone_permission_hint)) } } @@ -340,7 +340,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 was denied") rationaleList.add(resources.getString(R.string.nc_camera_permission_hint)) } } @@ -350,7 +350,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 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()) { @@ -365,7 +365,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.w(TAG, "Notification permission was denied - this may cause call hang") rationaleList.add(resources.getString(R.string.nc_notification_permission_hint)) } } @@ -374,17 +374,17 @@ class CallActivity : CallBaseActivity() { showRationaleDialogForSettings(rationaleList) } - // DEBUG: Check if we should proceed with call despite notification permission + // 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") + Log.d(TAG, "DEBUGNotification permission granted: $notificationPermissionGranted, isConnectionEstablished: $isConnectionEstablished") if (!isConnectionEstablished) { - Log.d(TAG, "DEBUG: Proceeding with prepareCall() despite notification permission status") + Log.d(TAG, "Proceeding with prepareCall() despite notification permission status") prepareCall() } } @@ -1091,14 +1091,14 @@ 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()") + // All permissions granted but connection not established + Log.d(TAG, "All permissions granted but connection not established, proceeding with prepareCall()") prepareCall() } } private fun prepareCall() { - Log.d(TAG, "DEBUG: prepareCall() started") + Log.d(TAG, "prepareCall() started") basicInitialization() initViews() // updateSelfVideoViewPosition(true) @@ -1110,7 +1110,7 @@ class CallActivity : CallBaseActivity() { 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") + Log.d(TAG, "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") @@ -1123,7 +1123,7 @@ class CallActivity : CallBaseActivity() { } } else { // Android 12 and below - notification permission is automatically granted - Log.d(TAG, "DEBUG: Starting foreground service (Android 12-)") + Log.d(TAG, "Starting foreground service (Android 12-)") CallForegroundService.start(applicationContext, conversationName, intent.extras) } @@ -1131,24 +1131,24 @@ class CallActivity : CallBaseActivity() { onMicrophoneClick() } } else { - Log.w(TAG, "DEBUG: Microphone permission not granted - skipping foreground service start") + Log.w(TAG, "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") if (isVoiceOnlyCall) { binding!!.selfVideoViewWrapper.visibility = View.GONE } else if (permissionUtil!!.isCameraPermissionGranted()) { - Log.d(TAG, "DEBUG: Camera permission granted, showing video") + Log.d(TAG, "Camera permission granted, showing video") binding!!.selfVideoViewWrapper.visibility = View.VISIBLE onCameraClick() if (cameraEnumerator!!.deviceNames.isEmpty()) { binding!!.cameraButton.visibility = View.GONE } } else { - Log.w(TAG, "DEBUG: Camera permission not granted, hiding video") + Log.w(TAG, "Camera permission not granted, hiding video") } } @@ -1166,27 +1166,27 @@ class CallActivity : CallBaseActivity() { 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") + Log.d(TAG, "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") + Log.d(TAG, "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") + // Log when user dismisses permission request + Log.w(TAG, "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") + Log.w(TAG, "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") + Log.d(TAG, "Proceeding with prepareCall() after dismissing notification permission") prepareCall() } } From 40ac5b2daceb7c71049e62239f1179bad18f29de Mon Sep 17 00:00:00 2001 From: Tarek Loubani Date: Thu, 26 Mar 2026 02:39:07 -0400 Subject: [PATCH 03/25] 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 8a43e8d13c..a14337fbdd 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 719bb275ce61e9a5864d8718da90bb033c6f71bd Mon Sep 17 00:00:00 2001 From: Tarek Loubani Date: Thu, 26 Mar 2026 02:40:41 -0400 Subject: [PATCH 04/25] 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 c9a288f9b6..e89042278c 100644 --- a/app/src/main/java/com/nextcloud/talk/services/CallForegroundService.kt +++ b/app/src/main/java/com/nextcloud/talk/services/CallForegroundService.kt @@ -70,10 +70,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 1eba98f6daed3e051780f2e36ed417d9d5e6405b Mon Sep 17 00:00:00 2001 From: Tarek Loubani Date: Thu, 26 Mar 2026 02:42:44 -0400 Subject: [PATCH 05/25] 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 41974e1965..01e2830e7d 100644 --- a/app/src/main/java/com/nextcloud/talk/activities/CallActivity.kt +++ b/app/src/main/java/com/nextcloud/talk/activities/CallActivity.kt @@ -331,7 +331,7 @@ class CallActivity : CallBaseActivity() { if (java.lang.Boolean.TRUE == audioPermission) { Log.d(TAG, "Microphone permission was granted") } else { - Log.d(TAG, "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 2ca1e9f8765a4a46947b6bd80cd7850524fa3bbc Mon Sep 17 00:00:00 2001 From: Tarek Loubani Date: Thu, 26 Mar 2026 02:48:51 -0400 Subject: [PATCH 06/25] 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 01e2830e7d..56640d6c0b 100644 --- a/app/src/main/java/com/nextcloud/talk/activities/CallActivity.kt +++ b/app/src/main/java/com/nextcloud/talk/activities/CallActivity.kt @@ -3242,6 +3242,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 277869df9d4d7751869a750dc35686bb27907218 Mon Sep 17 00:00:00 2001 From: Tarek Loubani Date: Thu, 26 Mar 2026 03:05:20 -0400 Subject: [PATCH 07/25] 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 a14337fbdd..0ea67b2093 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 44b57815e7e2f7a4df01ee2a294443eda0b24407 Mon Sep 17 00:00:00 2001 From: Tarek Loubani Date: Thu, 26 Mar 2026 03:06:13 -0400 Subject: [PATCH 08/25] Incorporate refactor from PR #5957 by @rapterjet2004 Signed-off-by: Tarek Loubani --- .../nextcloud/talk/activities/CallActivity.kt | 26 ++++--------------- .../talk/activities/CallBaseActivity.java | 4 +-- .../talk/receivers/EndCallReceiver.kt | 17 ++++++------ .../talk/services/CallForegroundService.kt | 14 ++++------ 4 files changed, 21 insertions(+), 40 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 56640d6c0b..4e9cb579b0 100644 --- a/app/src/main/java/com/nextcloud/talk/activities/CallActivity.kt +++ b/app/src/main/java/com/nextcloud/talk/activities/CallActivity.kt @@ -98,6 +98,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 @@ -190,7 +191,6 @@ import java.util.Objects import java.util.concurrent.TimeUnit import java.util.concurrent.atomic.AtomicInteger import javax.inject.Inject -import kotlin.String import kotlin.math.abs @AutoInjector(NextcloudTalkApplication::class) @@ -415,7 +415,7 @@ 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 @@ -1118,7 +1118,7 @@ class CallActivity : CallBaseActivity() { Snackbar.make( binding!!.root, resources.getString(R.string.nc_notification_permission_hint), - 10000 + SEC_10 ).show() } } else { @@ -3245,11 +3245,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) } @@ -3307,6 +3304,7 @@ class CallActivity : CallBaseActivity() { private const val INTRO_ANIMATION_DURATION: Long = 300 private const val FADE_IN_ANIMATION_DURATION: Long = 400 private const val PULSE_ANIMATION_DURATION: Int = 310 + private const val SEC_10 = 10000 private const val SPOTLIGHT_HEADING_SIZE: Int = 20 private const val SPOTLIGHT_SUBHEADING_SIZE: Int = 16 @@ -3315,18 +3313,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) - } - } - } } 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 28f20ba6d8..93f85aab8e 100644 --- a/app/src/main/java/com/nextcloud/talk/activities/CallBaseActivity.java +++ b/app/src/main/java/com/nextcloud/talk/activities/CallBaseActivity.java @@ -34,7 +34,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()) { @@ -65,7 +65,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 e89042278c..ac8a44fa99 100644 --- a/app/src/main/java/com/nextcloud/talk/services/CallForegroundService.kt +++ b/app/src/main/java/com/nextcloud/talk/services/CallForegroundService.kt @@ -23,6 +23,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 @@ -71,10 +72,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) @@ -109,16 +110,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 6551dc253c86d4cdd13b5003cc6f041727286ab2 Mon Sep 17 00:00:00 2001 From: Tarek Loubani Date: Thu, 26 Mar 2026 06:59:35 -0400 Subject: [PATCH 09/25] 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 | 61 ++++++++----------- .../talk/activities/CallBaseActivity.java | 36 +++++++++++ 2 files changed, 62 insertions(+), 35 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 4e9cb579b0..c83244332f 100644 --- a/app/src/main/java/com/nextcloud/talk/activities/CallActivity.kt +++ b/app/src/main/java/com/nextcloud/talk/activities/CallActivity.kt @@ -831,6 +831,7 @@ class CallActivity : CallBaseActivity() { hangup(shutDownView = true, endCallForAll = true) } binding!!.endCallPopupMenu.setOnClickListener { + isIntentionallyLeavingCall = true hangup(shutDownView = true, endCallForAll = true) binding!!.endCallPopupMenu.visibility = View.GONE } @@ -846,6 +847,7 @@ class CallActivity : CallBaseActivity() { hangup(shutDownView = true, endCallForAll = false) } binding!!.endCallPopupMenu.setOnClickListener { + isIntentionallyLeavingCall = true hangup(shutDownView = true, endCallForAll = false) binding!!.endCallPopupMenu.visibility = View.GONE } @@ -2209,44 +2211,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) { - 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 startVideoCapture(isPortrait: Boolean) { 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 93f85aab8e..3aa43b69a5 100644 --- a/app/src/main/java/com/nextcloud/talk/activities/CallBaseActivity.java +++ b/app/src/main/java/com/nextcloud/talk/activities/CallBaseActivity.java @@ -60,6 +60,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); @@ -98,6 +102,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 1bbdea8443fe75362881d11aca7ba13c370d7e73 Mon Sep 17 00:00:00 2001 From: Tarek Loubani Date: Tue, 31 Mar 2026 20:04:24 -0400 Subject: [PATCH 10/25] 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 --- .../nextcloud/talk/activities/CallActivity.kt | 2 +- .../com/nextcloud/talk/chat/ChatActivity.kt | 74 +++++++++++-------- 2 files changed, 44 insertions(+), 32 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 c83244332f..6c91fae303 100644 --- a/app/src/main/java/com/nextcloud/talk/activities/CallActivity.kt +++ b/app/src/main/java/com/nextcloud/talk/activities/CallActivity.kt @@ -2129,7 +2129,7 @@ class CallActivity : CallBaseActivity() { Log.d(TAG, "hangup! shutDownView=$shutDownView, endCallForAll=$endCallForAll") Log.d(TAG, "hangup! isIntentionallyLeavingCall=$isIntentionallyLeavingCall") Log.d(TAG, "hangup! powerManagerUtils state before cleanup: ${powerManagerUtils != null}") - + if (shutDownView) { setCallState(CallStatus.LEAVING) } 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 f38bab5c77..ed3ed1883f 100644 --- a/app/src/main/java/com/nextcloud/talk/chat/ChatActivity.kt +++ b/app/src/main/java/com/nextcloud/talk/chat/ChatActivity.kt @@ -389,6 +389,7 @@ class ChatActivity : var myFirstMessage: CharSequence? = null var checkingLobbyStatus: Boolean = false + private var isLeavingRoom: Boolean = false private var conversationVoiceCallMenuItem: MenuItem? = null private var conversationVideoMenuItem: MenuItem? = null @@ -427,6 +428,40 @@ class ChatActivity : 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 = object : SignalingMessageReceiver.LocalParticipantMessageListener { override fun onSwitchTo(token: String?) { if (token != null) { @@ -856,35 +891,7 @@ class ChatActivity : } } - chatViewModel.leaveRoomViewState.observe(this) { state -> - when (state) { - is ChatViewModel.LeaveRoomSuccessState -> { - logConversationInfos("leaveRoom#onNext") - - checkingLobbyStatus = false - - if (getRoomInfoTimerHandler != null) { - getRoomInfoTimerHandler?.removeCallbacksAndMessages(null) - } - - 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 -> {} - } - } + chatViewModel.leaveRoomViewState.observeForever(leaveRoomObserver) messageInputViewModel.sendChatMessageViewState.observe(this) { state -> when (state) { @@ -2717,11 +2724,13 @@ class ChatActivity : } if (conversationUser != null && 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...") @@ -2801,6 +2810,8 @@ class ChatActivity : super.onDestroy() logConversationInfos("onDestroy") + chatViewModel.leaveRoomViewState.removeObserver(leaveRoomObserver) + findViewById(R.id.toolbar)?.setOnClickListener(null) if (actionBar != null) { @@ -2837,6 +2848,7 @@ class ChatActivity : fun leaveRoom(funToCallWhenLeaveSuccessful: (() -> Unit)?) { logConversationInfos("leaveRoom") + isLeavingRoom = true var apiVersion = 1 // FIXME Fix API checking with guests? From 468415441c027cdfab92ea28cb40b2e160ece2b8 Mon Sep 17 00:00:00 2001 From: Tarek Loubani Date: Thu, 2 Apr 2026 04:32:15 -0400 Subject: [PATCH 11/25] 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 | 16 +- .../talk/activities/CallBaseActivity.java | 28 +- .../activities/CallBaseActivityPipTest.kt | 332 ++++++++++++ .../ChatActivityLeaveRoomLifecycleTest.kt | 487 ++++++++++++++++++ 4 files changed, 851 insertions(+), 12 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 6c91fae303..a20ad7087b 100644 --- a/app/src/main/java/com/nextcloud/talk/activities/CallActivity.kt +++ b/app/src/main/java/com/nextcloud/talk/activities/CallActivity.kt @@ -723,6 +723,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) { @@ -2058,10 +2060,16 @@ class CallActivity : CallBaseActivity() { } "roomJoined" -> { - Log.d(TAG, "onMessageEvent 'roomJoined'") + Log.d(TAG, "onMessageEvent 'roomJoined'" + + " currentCallStatus=$currentCallStatus") startSendingNick() if (webSocketCommunicationEvent.getHashMap()!!["roomToken"] == roomToken) { - performCall() + if (currentCallStatus === CallStatus.IN_CONVERSATION) { + Log.d(TAG, "Already in conversation, skipping performCall()" + + " (ChatActivity resume triggered spurious roomJoined)") + } else { + performCall() + } } } @@ -3118,8 +3126,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 3aa43b69a5..4f26f2b1c1 100644 --- a/app/src/main/java/com/nextcloud/talk/activities/CallBaseActivity.java +++ b/app/src/main/java/com/nextcloud/talk/activities/CallBaseActivity.java @@ -60,10 +60,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); @@ -112,11 +114,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(); } } @@ -124,8 +132,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() @@ -137,6 +145,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()) { @@ -152,16 +161,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 44419d755f29e1e8f21ede2e4f9cbebebc30c14c Mon Sep 17 00:00:00 2001 From: Tarek Loubani Date: Thu, 2 Apr 2026 17:51:52 -0400 Subject: [PATCH 12/25] 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 | 83 ++--- .../activities/CallBaseActivityPipTest.kt | 296 ++++++------------ 3 files changed, 133 insertions(+), 265 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 a20ad7087b..c9b799bdce 100644 --- a/app/src/main/java/com/nextcloud/talk/activities/CallActivity.kt +++ b/app/src/main/java/com/nextcloud/talk/activities/CallActivity.kt @@ -3009,6 +3009,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) @@ -3176,8 +3183,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 @@ -3195,7 +3209,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 @@ -3212,6 +3226,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 4f26f2b1c1..75c0006b59 100644 --- a/app/src/main/java/com/nextcloud/talk/activities/CallBaseActivity.java +++ b/app/src/main/java/com/nextcloud/talk/activities/CallBaseActivity.java @@ -14,7 +14,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; @@ -31,8 +30,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 @@ -40,7 +37,6 @@ public void handleOnBackPressed() { if (isPipModePossible()) { enterPipMode(); } else { - // Move the task to background instead of finishing moveTaskToBack(true); } } @@ -51,8 +47,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); @@ -62,9 +56,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()); } @@ -105,26 +99,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(); } } @@ -132,9 +122,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()) { @@ -146,28 +139,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(); } } @@ -178,7 +160,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."); @@ -197,24 +180,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; - } - - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.KITKAT_WATCH) { - return powerManager.isInteractive(); - } else { - //noinspection deprecation - return powerManager.isScreenOn(); - } - } - public abstract void updateUiForPipMode(); public abstract void updateUiForNormalMode(); 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 45748c257762b2c04eefd1757a414b4de1f5953e Mon Sep 17 00:00:00 2001 From: Tarek Loubani Date: Fri, 3 Apr 2026 18:41:25 -0400 Subject: [PATCH 13/25] 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 | 61 ++++++++++++------- .../talk/activities/CallBaseActivity.java | 39 ++++++++---- 3 files changed, 66 insertions(+), 35 deletions(-) diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index bc96ae54ea..1e2db1d322 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -171,7 +171,6 @@ Date: Sat, 11 Apr 2026 18:46:47 -0400 Subject: [PATCH 14/25] 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 ac8a44fa99..491d966e62 100644 --- a/app/src/main/java/com/nextcloud/talk/services/CallForegroundService.kt +++ b/app/src/main/java/com/nextcloud/talk/services/CallForegroundService.kt @@ -2,19 +2,24 @@ * 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 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 @@ -27,9 +32,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 @Suppress("ForegroundServiceType") @@ -45,10 +54,13 @@ class CallForegroundService : Service() { startForeground(NOTIFICATION_ID, notification) } + startTimeBasedNotificationUpdates() + return START_STICKY } override fun onDestroy() { + handler.removeCallbacksAndMessages(null) stopForeground(STOP_FOREGROUND_REMOVE) super.onDestroy() } @@ -77,6 +89,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)) @@ -94,6 +110,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) @@ -119,6 +194,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) { @@ -141,6 +228,8 @@ class CallForegroundService : Service() { private const val NOTIFICATION_ID = 47001 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 c9ca00a8d852c2a9a718c11c249b7aaaf81c3976 Mon Sep 17 00:00:00 2001 From: Tarek Loubani Date: Thu, 16 Apr 2026 16:22:20 -0400 Subject: [PATCH 15/25] 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 | 33 +++++++++---------- .../talk/services/CallForegroundService.kt | 6 ++++ 2 files changed, 21 insertions(+), 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 433af52e5e..bf9459916f 100644 --- a/app/src/main/java/com/nextcloud/talk/activities/CallActivity.kt +++ b/app/src/main/java/com/nextcloud/talk/activities/CallActivity.kt @@ -1103,22 +1103,14 @@ class CallActivity : CallBaseActivity() { private fun prepareCall() { Log.d(TAG, "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()) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { - // Android 13+ requires explicit notification permission if (permissionUtil!!.isPostNotificationsPermissionGranted()) { Log.d(TAG, "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), @@ -1126,11 +1118,10 @@ class CallActivity : CallBaseActivity() { ).show() } } else { - // Android 12 and below - notification permission is automatically granted Log.d(TAG, "Starting foreground service (Android 12-)") CallForegroundService.start(applicationContext, conversationName, intent.extras) } - + if (!microphoneOn) { onMicrophoneClick() } @@ -1138,10 +1129,12 @@ class CallActivity : CallBaseActivity() { Log.w(TAG, "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, "Ensuring call proceeds even without notification permission") - + + basicInitialization() + initViews() + checkRecordingConsentAndInitiateCall() + if (isVoiceOnlyCall) { binding!!.selfVideoViewWrapper.visibility = View.GONE } else if (permissionUtil!!.isCameraPermissionGranted()) { @@ -1547,11 +1540,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") @@ -2185,6 +2175,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 491d966e62..8dd007abb0 100644 --- a/app/src/main/java/com/nextcloud/talk/services/CallForegroundService.kt +++ b/app/src/main/java/com/nextcloud/talk/services/CallForegroundService.kt @@ -7,6 +7,7 @@ */ package com.nextcloud.talk.services +import android.annotation.SuppressLint import android.app.Notification import android.app.PendingIntent import android.app.Person @@ -43,6 +44,9 @@ class CallForegroundService : Service() { @Suppress("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) @@ -60,6 +64,7 @@ class CallForegroundService : Service() { } override fun onDestroy() { + Log.d(TAG, "onDestroy called") handler.removeCallbacksAndMessages(null) stopForeground(STOP_FOREGROUND_REMOVE) super.onDestroy() @@ -225,6 +230,7 @@ class CallForegroundService : Service() { } companion object { + private val TAG = CallForegroundService::class.java.simpleName private const val NOTIFICATION_ID = 47001 private const val EXTRA_CONVERSATION_NAME = "extra_conversation_name" private const val EXTRA_CALL_INTENT_EXTRAS = "extra_call_intent_extras" From c4b19830a87d5ee1a7dc63708995a82c8c6a00b0 Mon Sep 17 00:00:00 2001 From: Tarek Loubani Date: Thu, 16 Apr 2026 17:13:27 -0400 Subject: [PATCH 16/25] 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 | 22 +- .../talk/receivers/EndCallReceiver.kt | 2 +- .../talk/services/CallForegroundService.kt | 6 +- .../activities/CallBaseActivityPipTest.kt | 1 - .../ChatActivityLeaveRoomLifecycleTest.kt | 67 +- gradle/verification-metadata.xml | 3629 +---------------- 6 files changed, 72 insertions(+), 3655 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 feff2b329a..086b59fa6c 100644 --- a/app/src/main/java/com/nextcloud/talk/activities/CallActivity.kt +++ b/app/src/main/java/com/nextcloud/talk/activities/CallActivity.kt @@ -252,7 +252,7 @@ class CallActivity : CallBaseActivity() { private val cameraSwitchHandler = Handler() private val callTimeHandler = Handler(Looper.getMainLooper()) - + // Track if we're intentionally leaving the call private var isIntentionallyLeavingCall = false @@ -382,7 +382,11 @@ class CallActivity : CallBaseActivity() { true // Older Android versions have permission by default } - Log.d(TAG, "DEBUGNotification permission granted: $notificationPermissionGranted, isConnectionEstablished: $isConnectionEstablished") + Log.d( + TAG, + "Notification permission granted: $notificationPermissionGranted, " + + "isConnectionEstablished: $isConnectionEstablished" + ) if (!isConnectionEstablished) { Log.d(TAG, "Proceeding with prepareCall() despite notification permission status") @@ -1117,7 +1121,11 @@ class CallActivity : CallBaseActivity() { Log.d(TAG, "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), @@ -1175,7 +1183,9 @@ class CallActivity : CallBaseActivity() { // Log when permission rationale dialog is shown Log.d(TAG, "Showing permission rationale dialog for permissions: $permissionsToRequest") - Log.d(TAG, "Rationale includes notification permission: ${permissionsToRequest.contains(Manifest.permission.POST_NOTIFICATIONS)}") + 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) @@ -1492,7 +1502,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") @@ -1518,7 +1528,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) } /** diff --git a/gradle/verification-metadata.xml b/gradle/verification-metadata.xml index a3a2a8588b..db3fecd160 100644 --- a/gradle/verification-metadata.xml +++ b/gradle/verification-metadata.xml @@ -13,15 +13,7 @@ - - - - - - - - @@ -42,17 +34,6 @@ - - - - - - - - - - - @@ -61,8 +42,6 @@ - - @@ -75,14 +54,12 @@ - - @@ -100,26 +77,18 @@ - - - - - - - - @@ -131,11 +100,6 @@ - - - - - @@ -164,12 +128,7 @@ - - - - - - + @@ -187,8 +146,6 @@ - - @@ -200,7 +157,6 @@ - @@ -223,9 +179,7 @@ - - @@ -241,8 +195,6 @@ - - @@ -260,7 +212,6 @@ - @@ -270,7 +221,6 @@ - @@ -307,7 +257,6 @@ - @@ -315,10 +264,7 @@ - - - - + @@ -333,7 +279,6 @@ - @@ -366,19 +311,13 @@ - - - - - - + - @@ -395,7 +334,6 @@ - @@ -444,10 +382,7 @@ - - - - + @@ -494,33 +429,6 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - @@ -574,38 +482,6 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - @@ -630,38 +506,6 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - @@ -1028,14 +872,6 @@ - - - - - - - - @@ -1068,14 +904,6 @@ - - - - - - - - @@ -1108,14 +936,6 @@ - - - - - - - - @@ -1148,14 +968,6 @@ - - - - - - - - @@ -1188,14 +1000,6 @@ - - - - - - - - @@ -1212,14 +1016,6 @@ - - - - - - - - @@ -1484,31 +1280,6 @@ - - - - - - - - - - - - - - - - - - - - - - - - - @@ -1517,21 +1288,6 @@ - - - - - - - - - - - - - - - @@ -1585,30 +1341,6 @@ - - - - - - - - - - - - - - - - - - - - - - - - @@ -1662,21 +1394,6 @@ - - - - - - - - - - - - - - - @@ -1730,30 +1447,6 @@ - - - - - - - - - - - - - - - - - - - - - - - - @@ -1807,21 +1500,6 @@ - - - - - - - - - - - - - - - @@ -1878,30 +1556,6 @@ - - - - - - - - - - - - - - - - - - - - - - - - @@ -1958,21 +1612,6 @@ - - - - - - - - - - - - - - - @@ -2029,30 +1668,6 @@ - - - - - - - - - - - - - - - - - - - - - - - - @@ -2101,26 +1716,6 @@ - - - - - - - - - - - - - - - - - - - - @@ -2146,38 +1741,6 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - @@ -2266,39 +1829,6 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - @@ -2339,38 +1869,6 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - @@ -2472,21 +1970,6 @@ - - - - - - - - - - - - - - - @@ -2543,30 +2026,6 @@ - - - - - - - - - - - - - - - - - - - - - - - - @@ -2623,21 +2082,6 @@ - - - - - - - - - - - - - - - @@ -2662,30 +2106,6 @@ - - - - - - - - - - - - - - - - - - - - - - - - @@ -2710,21 +2130,6 @@ - - - - - - - - - - - - - - - @@ -2733,30 +2138,6 @@ - - - - - - - - - - - - - - - - - - - - - - - - @@ -2770,21 +2151,6 @@ - - - - - - - - - - - - - - - @@ -2846,30 +2212,6 @@ - - - - - - - - - - - - - - - - - - - - - - - - @@ -2934,21 +2276,6 @@ - - - - - - - - - - - - - - - @@ -3010,30 +2337,6 @@ - - - - - - - - - - - - - - - - - - - - - - - - @@ -3095,21 +2398,6 @@ - - - - - - - - - - - - - - - @@ -3166,30 +2454,6 @@ - - - - - - - - - - - - - - - - - - - - - - - - @@ -3246,21 +2510,6 @@ - - - - - - - - - - - - - - - @@ -3322,30 +2571,6 @@ - - - - - - - - - - - - - - - - - - - - - - - - @@ -3407,26 +2632,6 @@ - - - - - - - - - - - - - - - - - - - - @@ -3483,35 +2688,6 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - @@ -3568,26 +2744,6 @@ - - - - - - - - - - - - - - - - - - - - @@ -3644,35 +2800,6 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - @@ -3729,30 +2856,6 @@ - - - - - - - - - - - - - - - - - - - - - - - - @@ -3814,21 +2917,6 @@ - - - - - - - - - - - - - - - @@ -3890,30 +2978,6 @@ - - - - - - - - - - - - - - - - - - - - - - - - @@ -3972,21 +3036,6 @@ - - - - - - - - - - - - - - - @@ -4030,30 +3079,6 @@ - - - - - - - - - - - - - - - - - - - - - - - - @@ -4112,21 +3137,6 @@ - - - - - - - - - - - - - - - @@ -4170,30 +3180,6 @@ - - - - - - - - - - - - - - - - - - - - - - - - @@ -4255,21 +3241,6 @@ - - - - - - - - - - - - - - - @@ -4331,30 +3302,6 @@ - - - - - - - - - - - - - - - - - - - - - - - - @@ -4413,22 +3360,7 @@ - - - - - - - - - - - - - - - - + @@ -4487,30 +3419,6 @@ - - - - - - - - - - - - - - - - - - - - - - - - @@ -4567,21 +3475,6 @@ - - - - - - - - - - - - - - - @@ -4638,30 +3531,6 @@ - - - - - - - - - - - - - - - - - - - - - - - - @@ -4876,14 +3745,6 @@ - - - - - - - - @@ -4943,14 +3804,6 @@ - - - - - - - - @@ -5052,14 +3905,6 @@ - - - - - - - - @@ -5180,14 +4025,6 @@ - - - - - - - - @@ -5308,14 +4145,6 @@ - - - - - - - - @@ -6076,14 +4905,6 @@ - - - - - - - - @@ -7493,30 +6314,6 @@ - - - - - - - - - - - - - - - - - - - - - - - - @@ -7573,30 +6370,6 @@ - - - - - - - - - - - - - - - - - - - - - - - - @@ -7653,30 +6426,6 @@ - - - - - - - - - - - - - - - - - - - - - - - - @@ -7733,30 +6482,6 @@ - - - - - - - - - - - - - - - - - - - - - - - - @@ -7813,30 +6538,6 @@ - - - - - - - - - - - - - - - - - - - - - - - - @@ -7893,30 +6594,6 @@ - - - - - - - - - - - - - - - - - - - - - - - - @@ -7973,35 +6650,6 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - @@ -8058,30 +6706,6 @@ - - - - - - - - - - - - - - - - - - - - - - - - @@ -8090,58 +6714,6 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - @@ -9767,14 +8339,6 @@ - - - - - - - - @@ -9807,14 +8371,6 @@ - - - - - - - - @@ -9847,19 +8403,6 @@ - - - - - - - - - - - - - @@ -10022,14 +8565,6 @@ - - - - - - - - @@ -10150,14 +8685,6 @@ - - - - - - - - @@ -10278,14 +8805,6 @@ - - - - - - - - @@ -10358,102 +8877,6 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - @@ -10502,14 +8925,6 @@ - - - - - - - - @@ -10630,14 +9045,6 @@ - - - - - - - - @@ -10758,14 +9165,6 @@ - - - - - - - - @@ -10926,14 +9325,6 @@ - - - - - - - - @@ -11054,14 +9445,6 @@ - - - - - - - - @@ -11182,14 +9565,6 @@ - - - - - - - - @@ -11310,14 +9685,6 @@ - - - - - - - - @@ -11438,14 +9805,6 @@ - - - - - - - - @@ -11566,14 +9925,6 @@ - - - - - - - - @@ -11694,14 +10045,6 @@ - - - - - - - - @@ -11822,14 +10165,6 @@ - - - - - - - - @@ -11926,14 +10261,6 @@ - - - - - - - - @@ -12054,14 +10381,6 @@ - - - - - - - - @@ -12179,15 +10498,7 @@ - - - - - - - - - + @@ -12310,14 +10621,6 @@ - - - - - - - - @@ -12438,14 +10741,6 @@ - - - - - - - - @@ -12566,14 +10861,6 @@ - - - - - - - - @@ -12694,14 +10981,6 @@ - - - - - - - - @@ -12830,14 +11109,6 @@ - - - - - - - - @@ -12886,14 +11157,6 @@ - - - - - - - - @@ -13030,14 +11293,6 @@ - - - - - - - - @@ -13134,14 +11389,6 @@ - - - - - - - - @@ -13190,14 +11437,6 @@ - - - - - - - - @@ -13334,14 +11573,6 @@ - - - - - - - - @@ -13502,14 +11733,6 @@ - - - - - - - - @@ -13630,14 +11853,6 @@ - - - - - - - - @@ -13878,14 +12093,6 @@ - - - - - - - - @@ -14166,14 +12373,6 @@ - - - - - - - - @@ -14294,14 +12493,6 @@ - - - - - - - - @@ -14422,14 +12613,6 @@ - - - - - - - - @@ -14550,14 +12733,6 @@ - - - - - - - - @@ -14902,14 +13077,6 @@ - - - - - - - - @@ -14998,14 +13165,6 @@ - - - - - - - - @@ -15094,14 +13253,6 @@ - - - - - - - - @@ -15222,14 +13373,6 @@ - - - - - - - - @@ -15350,14 +13493,6 @@ - - - - - - - - @@ -15478,14 +13613,6 @@ - - - - - - - - @@ -15606,14 +13733,6 @@ - - - - - - - - @@ -15734,14 +13853,6 @@ - - - - - - - - @@ -15862,14 +13973,6 @@ - - - - - - - - @@ -15990,14 +14093,6 @@ - - - - - - - - @@ -16118,14 +14213,6 @@ - - - - - - - - @@ -16246,14 +14333,6 @@ - - - - - - - - @@ -16374,14 +14453,6 @@ - - - - - - - - @@ -16502,14 +14573,6 @@ - - - - - - - - @@ -16774,14 +14837,6 @@ - - - - - - - - @@ -16902,14 +14957,6 @@ - - - - - - - - @@ -17030,14 +15077,6 @@ - - - - - - - - @@ -17381,9 +15420,7 @@ - - - + @@ -17487,14 +15524,20 @@ + + + + + + + + - - - + @@ -17705,46 +15748,6 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - @@ -17861,46 +15864,6 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - @@ -18013,46 +15976,6 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - @@ -18205,14 +16128,6 @@ - - - - - - - - @@ -18229,14 +16144,6 @@ - - - - - - - - @@ -18359,14 +16266,6 @@ - - - - - - - - @@ -18423,14 +16322,6 @@ - - - - - - - - @@ -18527,14 +16418,6 @@ - - - - - - - - @@ -18845,14 +16728,6 @@ - - - - - - - - @@ -18916,21 +16791,6 @@ - - - - - - - - - - - - - - - @@ -19013,21 +16873,6 @@ - - - - - - - - - - - - - - - @@ -19076,30 +16921,6 @@ - - - - - - - - - - - - - - - - - - - - - - - - @@ -19196,14 +17017,6 @@ - - - - - - - - @@ -19220,30 +17033,6 @@ - - - - - - - - - - - - - - - - - - - - - - - - @@ -19404,30 +17193,6 @@ - - - - - - - - - - - - - - - - - - - - - - - - @@ -19516,30 +17281,6 @@ - - - - - - - - - - - - - - - - - - - - - - - - @@ -19660,24 +17401,8 @@ - - - - - - - - - - - - - - - - - - + + @@ -19765,16 +17490,6 @@ - - - - - - - - - - @@ -20064,14 +17779,6 @@ - - - - - - - - @@ -20080,11 +17787,6 @@ - - - - - @@ -20483,14 +18185,6 @@ - - - - - - - - @@ -20515,14 +18209,6 @@ - - - - - - - - @@ -20547,14 +18233,6 @@ - - - - - - - - @@ -20579,14 +18257,6 @@ - - - - - - - - @@ -20611,14 +18281,6 @@ - - - - - - - - @@ -20643,14 +18305,6 @@ - - - - - - - - @@ -20735,14 +18389,6 @@ - - - - - - - - @@ -20815,22 +18461,6 @@ - - - - - - - - - - - - - - - - @@ -21533,11 +19163,6 @@ - - - - - @@ -21742,14 +19367,6 @@ - - - - - - - - @@ -21805,19 +19422,6 @@ - - - - - - - - - - - - - @@ -23127,19 +20731,6 @@ - - - - - - - - - - - - - @@ -23259,11 +20850,6 @@ - - - - - @@ -23272,11 +20858,6 @@ - - - - - @@ -23382,11 +20963,6 @@ - - - - - @@ -24097,124 +21673,6 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - @@ -24223,14 +21681,6 @@ - - - - - - - - @@ -24247,22 +21697,6 @@ - - - - - - - - - - - - - - - - @@ -24289,22 +21723,6 @@ - - - - - - - - - - - - - - - - @@ -24345,38 +21763,6 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - @@ -24409,22 +21795,6 @@ - - - - - - - - - - - - - - - - @@ -24513,22 +21883,6 @@ - - - - - - - - - - - - - - - - @@ -24603,22 +21957,6 @@ - - - - - - - - - - - - - - - - @@ -24712,43 +22050,6 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - @@ -24797,22 +22098,6 @@ - - - - - - - - - - - - - - - - @@ -24910,22 +22195,6 @@ - - - - - - - - - - - - - - - - @@ -25019,27 +22288,6 @@ - - - - - - - - - - - - - - - - - - - - - @@ -25088,22 +22336,6 @@ - - - - - - - - - - - - - - - - @@ -25197,27 +22429,6 @@ - - - - - - - - - - - - - - - - - - - - - @@ -25315,22 +22526,6 @@ - - - - - - - - - - - - - - - - @@ -25379,22 +22574,6 @@ - - - - - - - - - - - - - - - - @@ -25443,22 +22622,6 @@ - - - - - - - - - - - - - - - - @@ -25525,22 +22688,6 @@ - - - - - - - - - - - - - - - - @@ -25589,22 +22736,6 @@ - - - - - - - - - - - - - - - - @@ -25653,22 +22784,6 @@ - - - - - - - - - - - - - - - - @@ -25782,54 +22897,17 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + - - - + + + - - + + @@ -25880,22 +22958,6 @@ - - - - - - - - - - - - - - - - @@ -25944,22 +23006,6 @@ - - - - - - - - - - - - - - - - @@ -25992,22 +23038,6 @@ - - - - - - - - - - - - - - - - @@ -26056,22 +23086,6 @@ - - - - - - - - - - - - - - - - @@ -26120,22 +23134,6 @@ - - - - - - - - - - - - - - - - @@ -26184,22 +23182,6 @@ - - - - - - - - - - - - - - - - @@ -26277,11 +23259,6 @@ - - - - - @@ -26354,22 +23331,6 @@ - - - - - - - - - - - - - - - - @@ -26610,22 +23571,6 @@ - - - - - - - - - - - - - - - - @@ -26674,22 +23619,6 @@ - - - - - - - - - - - - - - - - @@ -26858,14 +23787,6 @@ - - - - - - - - @@ -26874,22 +23795,6 @@ - - - - - - - - - - - - - - - - @@ -26987,16 +23892,6 @@ - - - - - - - - - - @@ -27101,11 +23996,6 @@ - - - - - @@ -27204,11 +24094,6 @@ - - - - - @@ -27257,27 +24142,6 @@ - - - - - - - - - - - - - - - - - - - - - @@ -27326,22 +24190,6 @@ - - - - - - - - - - - - - - - - @@ -27390,22 +24238,6 @@ - - - - - - - - - - - - - - - - @@ -27454,22 +24286,6 @@ - - - - - - - - - - - - - - - - @@ -27538,21 +24354,6 @@ - - - - - - - - - - - - - - - @@ -27815,19 +24616,6 @@ - - - - - - - - - - - - - @@ -27971,11 +24759,6 @@ - - - - - @@ -28001,11 +24784,6 @@ - - - - - @@ -28048,14 +24826,6 @@ - - - - - - - - @@ -28104,11 +24874,6 @@ - - - - - @@ -28143,14 +24908,6 @@ - - - - - - - - @@ -28279,22 +25036,6 @@ - - - - - - - - - - - - - - - - @@ -28432,9 +25173,6 @@ - - - @@ -28449,19 +25187,6 @@ - - - - - - - - - - - - - @@ -28542,22 +25267,6 @@ - - - - - - - - - - - - - - - - @@ -28638,22 +25347,6 @@ - - - - - - - - - - - - - - - - @@ -28670,22 +25363,6 @@ - - - - - - - - - - - - - - - - @@ -28766,22 +25443,6 @@ - - - - - - - - - - - - - - - - @@ -28790,100 +25451,6 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - @@ -29033,22 +25600,6 @@ - - - - - - - - - - - - - - - - @@ -29139,14 +25690,6 @@ - - - - - - - - @@ -29268,14 +25811,6 @@ - - - - - - - - @@ -29363,14 +25898,6 @@ - - - - - - - - @@ -29395,14 +25922,6 @@ - - - - - - - - @@ -29427,14 +25946,6 @@ - - - - - - - - @@ -29483,14 +25994,6 @@ - - - - - - - - @@ -29515,14 +26018,6 @@ - - - - - - - - @@ -29547,14 +26042,6 @@ - - - - - - - - @@ -29579,14 +26066,6 @@ - - - - - - - - @@ -29611,14 +26090,6 @@ - - - - - - - - @@ -29643,14 +26114,6 @@ - - - - - - - - @@ -29675,14 +26138,6 @@ - - - - - - - - @@ -29707,14 +26162,6 @@ - - - - - - - - @@ -29739,14 +26186,6 @@ - - - - - - - - @@ -29983,22 +26422,6 @@ - - - - - - - - - - - - - - - - From c524c926ca7456cda572a8b2a3a68795d95be39a Mon Sep 17 00:00:00 2001 From: Tarek Loubani Date: Thu, 16 Apr 2026 17:25:24 -0400 Subject: [PATCH 17/25] 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 fe2801414f10dcb165fa445cead8a1e9fffb3e4a Mon Sep 17 00:00:00 2001 From: Tarek Loubani Date: Thu, 16 Apr 2026 17:26:59 -0400 Subject: [PATCH 18/25] Remove trailing spaces Signed-off-by: Tarek Loubani --- .../nextcloud/talk/activities/CallActivity.kt | 2 +- .../ChatActivityLeaveRoomLifecycleTest.kt | 58 +++++++------------ 2 files changed, 23 insertions(+), 37 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 086b59fa6c..4899172e27 100644 --- a/app/src/main/java/com/nextcloud/talk/activities/CallActivity.kt +++ b/app/src/main/java/com/nextcloud/talk/activities/CallActivity.kt @@ -325,7 +325,7 @@ class CallActivity : CallBaseActivity() { ) { permissionMap: Map -> // Log permission results Log.d(TAG, "Permission request completed with results: $permissionMap") - + val rationaleList: MutableList = ArrayList() val audioPermission = permissionMap[Manifest.permission.RECORD_AUDIO] if (audioPermission != null) { 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 e3b29ca388faf2eeb2d6cf1eea63e8a5632c1499 Mon Sep 17 00:00:00 2001 From: Tarek Loubani Date: Thu, 16 Apr 2026 18:37:36 -0400 Subject: [PATCH 19/25] Clean up redundant functions Signed-off-by: Tarek Loubani --- .../com/nextcloud/talk/chat/ChatActivity.kt | 21 ------------------- 1 file changed, 21 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 54fb520984..f344ba3746 100644 --- a/app/src/main/java/com/nextcloud/talk/chat/ChatActivity.kt +++ b/app/src/main/java/com/nextcloud/talk/chat/ChatActivity.kt @@ -450,27 +450,6 @@ class ChatActivity : } } - private val localParticipantMessageListener = object : SignalingMessageReceiver.LocalParticipantMessageListener { - override fun onSwitchTo(token: String?) { - if (token != null) { - if (CallActivity.active) { - Log.d(TAG, "CallActivity is running. Ignore to switch chat in ChatActivity...") - } else { - switchToRoom(token, false, false) - } - } - private val localParticipantMessageListener = SignalingMessageReceiver.LocalParticipantMessageListener { token -> - if (CallActivity.active) { - Log.d(TAG, "CallActivity is running. Ignore to switch chat in ChatActivity...") - } else { - switchToRoom( - token = token, - startCallAfterRoomSwitch = false, - isVoiceOnlyCall = false - ) - } - } - private val conversationMessageListener = object : SignalingMessageReceiver.ConversationMessageListener { override fun onStartTyping(userId: String?, session: String?) { val userIdOrGuestSession = userId ?: session From d1c7e4ab831f05de97540f483a7df31510ba3525 Mon Sep 17 00:00:00 2001 From: Tarek Loubani Date: Thu, 16 Apr 2026 18:38:25 -0400 Subject: [PATCH 20/25] 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 91e666b86efdf83421405f7870e1de2eb88ad867 Mon Sep 17 00:00:00 2001 From: Tarek Loubani Date: Thu, 16 Apr 2026 18:51:05 -0400 Subject: [PATCH 21/25] 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 e360605fe7bfd0644bdb68cc05935af14d23f0d8 Mon Sep 17 00:00:00 2001 From: Tarek Loubani Date: Thu, 16 Apr 2026 19:39:31 -0400 Subject: [PATCH 22/25] 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 4899172e27..bab878a252 100644 --- a/app/src/main/java/com/nextcloud/talk/activities/CallActivity.kt +++ b/app/src/main/java/com/nextcloud/talk/activities/CallActivity.kt @@ -381,7 +381,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 59f265fd386c18e0ca34b2ff0c3ed05457735538 Mon Sep 17 00:00:00 2001 From: Tarek Loubani Date: Thu, 16 Apr 2026 19:43:26 -0400 Subject: [PATCH 23/25] 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 bab878a252..11ff3c0b18 100644 --- a/app/src/main/java/com/nextcloud/talk/activities/CallActivity.kt +++ b/app/src/main/java/com/nextcloud/talk/activities/CallActivity.kt @@ -387,7 +387,7 @@ class CallActivity : CallBaseActivity() { "Notification permission granted: $notificationPermissionGranted, " + "isConnectionEstablished: $isConnectionEstablished" ) - + if (!isConnectionEstablished) { Log.d(TAG, "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 6a632bd54ec3cfcf5f58789904fc9626222b10a2 Mon Sep 17 00:00:00 2001 From: Tarek Loubani Date: Thu, 16 Apr 2026 19:48:05 -0400 Subject: [PATCH 24/25] style: remove trailing whitespace in CallActivity Signed-off-by: Tarek Loubani --- .../com/nextcloud/talk/activities/CallActivity.kt | 14 +++++++------- 1 file changed, 7 insertions(+), 7 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 11ff3c0b18..5751cef570 100644 --- a/app/src/main/java/com/nextcloud/talk/activities/CallActivity.kt +++ b/app/src/main/java/com/nextcloud/talk/activities/CallActivity.kt @@ -418,10 +418,10 @@ class CallActivity : CallBaseActivity() { Log.d(TAG, "onCreate") super.onCreate(savedInstanceState) sharedApplication!!.componentApplication.inject(this) - + // 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( @@ -431,7 +431,7 @@ class CallActivity : CallBaseActivity() { null, ReceiverFlag.NotExported ) - + Log.d(TAG, "Broadcast receiver registered successfully") callViewModel = ViewModelProvider(this, viewModelFactory)[CallViewModel::class.java] @@ -1084,7 +1084,7 @@ class CallActivity : CallBaseActivity() { permissionsToRequest.add(Manifest.permission.BLUETOOTH_CONNECT) } } - + // Check notification permission for Android 13+ (API 33+) if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { if (permissionUtil!!.isPostNotificationsPermissionGranted()) { @@ -1180,13 +1180,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) @@ -1200,7 +1200,7 @@ class CallActivity : CallBaseActivity() { if (permissionsToRequest.contains(Manifest.permission.POST_NOTIFICATIONS)) { Log.w(TAG, "Notification permission specifically dismissed - proceeding with call anyway") } - + // Proceed with call even when notification permission is dismissed if (!isConnectionEstablished) { Log.d(TAG, "Proceeding with prepareCall() after dismissing notification permission") From c2d8175a3dba64dfef3f5de126a8ed22b53ccfd4 Mon Sep 17 00:00:00 2001 From: Tarek Loubani Date: Fri, 17 Apr 2026 16:52:54 -0400 Subject: [PATCH 25/25] chore: restore verification-metadata.xml from master Signed-off-by: Tarek Loubani --- gradle/verification-metadata.xml | 3628 +++++++++++++++++++++++++++++- 1 file changed, 3602 insertions(+), 26 deletions(-) diff --git a/gradle/verification-metadata.xml b/gradle/verification-metadata.xml index db3fecd160..5fa3cff5e1 100644 --- a/gradle/verification-metadata.xml +++ b/gradle/verification-metadata.xml @@ -13,7 +13,15 @@ + + + + + + + + @@ -34,6 +42,17 @@ + + + + + + + + + + + @@ -42,6 +61,8 @@ + + @@ -54,12 +75,14 @@ + + @@ -77,18 +100,25 @@ + + + + + + + @@ -100,6 +130,11 @@ + + + + + @@ -128,7 +163,12 @@ - + + + + + + @@ -146,6 +186,8 @@ + + @@ -157,6 +199,7 @@ + @@ -179,7 +222,9 @@ + + @@ -195,6 +240,8 @@ + + @@ -212,6 +259,7 @@ + @@ -221,6 +269,7 @@ + @@ -257,6 +306,7 @@ + @@ -264,7 +314,10 @@ - + + + + @@ -279,6 +332,7 @@ + @@ -311,13 +365,19 @@ - + + + + + + + @@ -334,6 +394,7 @@ + @@ -382,7 +443,10 @@ - + + + + @@ -429,6 +493,33 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + @@ -482,6 +573,38 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + @@ -506,6 +629,38 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + @@ -872,6 +1027,14 @@ + + + + + + + + @@ -904,6 +1067,14 @@ + + + + + + + + @@ -936,6 +1107,14 @@ + + + + + + + + @@ -968,6 +1147,14 @@ + + + + + + + + @@ -1000,6 +1187,14 @@ + + + + + + + + @@ -1016,6 +1211,14 @@ + + + + + + + + @@ -1280,6 +1483,31 @@ + + + + + + + + + + + + + + + + + + + + + + + + + @@ -1288,6 +1516,21 @@ + + + + + + + + + + + + + + + @@ -1341,6 +1584,30 @@ + + + + + + + + + + + + + + + + + + + + + + + + @@ -1394,6 +1661,21 @@ + + + + + + + + + + + + + + + @@ -1447,6 +1729,30 @@ + + + + + + + + + + + + + + + + + + + + + + + + @@ -1500,6 +1806,21 @@ + + + + + + + + + + + + + + + @@ -1556,6 +1877,30 @@ + + + + + + + + + + + + + + + + + + + + + + + + @@ -1612,6 +1957,21 @@ + + + + + + + + + + + + + + + @@ -1668,6 +2028,30 @@ + + + + + + + + + + + + + + + + + + + + + + + + @@ -1716,6 +2100,26 @@ + + + + + + + + + + + + + + + + + + + + @@ -1741,6 +2145,38 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + @@ -1829,6 +2265,39 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + @@ -1869,6 +2338,38 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + @@ -1970,6 +2471,21 @@ + + + + + + + + + + + + + + + @@ -2026,6 +2542,30 @@ + + + + + + + + + + + + + + + + + + + + + + + + @@ -2082,6 +2622,21 @@ + + + + + + + + + + + + + + + @@ -2106,6 +2661,30 @@ + + + + + + + + + + + + + + + + + + + + + + + + @@ -2130,6 +2709,21 @@ + + + + + + + + + + + + + + + @@ -2138,6 +2732,30 @@ + + + + + + + + + + + + + + + + + + + + + + + + @@ -2151,6 +2769,21 @@ + + + + + + + + + + + + + + + @@ -2212,6 +2845,30 @@ + + + + + + + + + + + + + + + + + + + + + + + + @@ -2276,6 +2933,21 @@ + + + + + + + + + + + + + + + @@ -2337,6 +3009,30 @@ + + + + + + + + + + + + + + + + + + + + + + + + @@ -2398,6 +3094,21 @@ + + + + + + + + + + + + + + + @@ -2454,6 +3165,30 @@ + + + + + + + + + + + + + + + + + + + + + + + + @@ -2510,6 +3245,21 @@ + + + + + + + + + + + + + + + @@ -2571,6 +3321,30 @@ + + + + + + + + + + + + + + + + + + + + + + + + @@ -2632,6 +3406,26 @@ + + + + + + + + + + + + + + + + + + + + @@ -2688,6 +3482,35 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + @@ -2744,6 +3567,26 @@ + + + + + + + + + + + + + + + + + + + + @@ -2800,6 +3643,35 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + @@ -2856,6 +3728,30 @@ + + + + + + + + + + + + + + + + + + + + + + + + @@ -2917,6 +3813,21 @@ + + + + + + + + + + + + + + + @@ -2978,6 +3889,30 @@ + + + + + + + + + + + + + + + + + + + + + + + + @@ -3036,6 +3971,21 @@ + + + + + + + + + + + + + + + @@ -3079,6 +4029,30 @@ + + + + + + + + + + + + + + + + + + + + + + + + @@ -3137,6 +4111,21 @@ + + + + + + + + + + + + + + + @@ -3180,6 +4169,30 @@ + + + + + + + + + + + + + + + + + + + + + + + + @@ -3241,6 +4254,21 @@ + + + + + + + + + + + + + + + @@ -3302,6 +4330,30 @@ + + + + + + + + + + + + + + + + + + + + + + + + @@ -3359,8 +4411,23 @@ - - + + + + + + + + + + + + + + + + + @@ -3419,6 +4486,30 @@ + + + + + + + + + + + + + + + + + + + + + + + + @@ -3475,6 +4566,21 @@ + + + + + + + + + + + + + + + @@ -3531,6 +4637,30 @@ + + + + + + + + + + + + + + + + + + + + + + + + @@ -3745,6 +4875,14 @@ + + + + + + + + @@ -3804,6 +4942,14 @@ + + + + + + + + @@ -3905,6 +5051,14 @@ + + + + + + + + @@ -4025,6 +5179,14 @@ + + + + + + + + @@ -4145,6 +5307,14 @@ + + + + + + + + @@ -4905,6 +6075,14 @@ + + + + + + + + @@ -6314,6 +7492,30 @@ + + + + + + + + + + + + + + + + + + + + + + + + @@ -6370,6 +7572,30 @@ + + + + + + + + + + + + + + + + + + + + + + + + @@ -6426,6 +7652,30 @@ + + + + + + + + + + + + + + + + + + + + + + + + @@ -6482,6 +7732,30 @@ + + + + + + + + + + + + + + + + + + + + + + + + @@ -6538,6 +7812,30 @@ + + + + + + + + + + + + + + + + + + + + + + + + @@ -6594,6 +7892,30 @@ + + + + + + + + + + + + + + + + + + + + + + + + @@ -6650,6 +7972,35 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + @@ -6706,6 +8057,30 @@ + + + + + + + + + + + + + + + + + + + + + + + + @@ -6714,6 +8089,58 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + @@ -8339,6 +9766,14 @@ + + + + + + + + @@ -8371,6 +9806,14 @@ + + + + + + + + @@ -8403,6 +9846,19 @@ + + + + + + + + + + + + + @@ -8565,6 +10021,14 @@ + + + + + + + + @@ -8685,6 +10149,14 @@ + + + + + + + + @@ -8805,6 +10277,14 @@ + + + + + + + + @@ -8877,6 +10357,102 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + @@ -8925,6 +10501,14 @@ + + + + + + + + @@ -9045,6 +10629,14 @@ + + + + + + + + @@ -9165,6 +10757,14 @@ + + + + + + + + @@ -9325,6 +10925,14 @@ + + + + + + + + @@ -9445,6 +11053,14 @@ + + + + + + + + @@ -9565,6 +11181,14 @@ + + + + + + + + @@ -9685,6 +11309,14 @@ + + + + + + + + @@ -9805,6 +11437,14 @@ + + + + + + + + @@ -9925,6 +11565,14 @@ + + + + + + + + @@ -10045,6 +11693,14 @@ + + + + + + + + @@ -10165,6 +11821,14 @@ + + + + + + + + @@ -10261,6 +11925,14 @@ + + + + + + + + @@ -10381,6 +12053,14 @@ + + + + + + + + @@ -10501,6 +12181,14 @@ + + + + + + + + @@ -10621,6 +12309,14 @@ + + + + + + + + @@ -10741,6 +12437,14 @@ + + + + + + + + @@ -10861,6 +12565,14 @@ + + + + + + + + @@ -10981,6 +12693,14 @@ + + + + + + + + @@ -11109,6 +12829,14 @@ + + + + + + + + @@ -11157,6 +12885,14 @@ + + + + + + + + @@ -11293,6 +13029,14 @@ + + + + + + + + @@ -11389,6 +13133,14 @@ + + + + + + + + @@ -11437,6 +13189,14 @@ + + + + + + + + @@ -11573,6 +13333,14 @@ + + + + + + + + @@ -11733,6 +13501,14 @@ + + + + + + + + @@ -11853,6 +13629,14 @@ + + + + + + + + @@ -12093,6 +13877,14 @@ + + + + + + + + @@ -12373,6 +14165,14 @@ + + + + + + + + @@ -12493,6 +14293,14 @@ + + + + + + + + @@ -12613,6 +14421,14 @@ + + + + + + + + @@ -12733,6 +14549,14 @@ + + + + + + + + @@ -13077,6 +14901,14 @@ + + + + + + + + @@ -13165,6 +14997,14 @@ + + + + + + + + @@ -13253,6 +15093,14 @@ + + + + + + + + @@ -13373,6 +15221,14 @@ + + + + + + + + @@ -13493,6 +15349,14 @@ + + + + + + + + @@ -13613,6 +15477,14 @@ + + + + + + + + @@ -13733,6 +15605,14 @@ + + + + + + + + @@ -13853,6 +15733,14 @@ + + + + + + + + @@ -13973,6 +15861,14 @@ + + + + + + + + @@ -14093,6 +15989,14 @@ + + + + + + + + @@ -14213,6 +16117,14 @@ + + + + + + + + @@ -14333,6 +16245,14 @@ + + + + + + + + @@ -14453,6 +16373,14 @@ + + + + + + + + @@ -14573,6 +16501,14 @@ + + + + + + + + @@ -14837,6 +16773,14 @@ + + + + + + + + @@ -14957,6 +16901,14 @@ + + + + + + + + @@ -15077,6 +17029,14 @@ + + + + + + + + @@ -15420,7 +17380,9 @@ - + + + @@ -15524,20 +17486,14 @@ - - - - - - - - - + + + @@ -15748,6 +17704,46 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + @@ -15864,6 +17860,46 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + @@ -15976,6 +18012,46 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + @@ -16128,6 +18204,14 @@ + + + + + + + + @@ -16144,6 +18228,14 @@ + + + + + + + + @@ -16266,6 +18358,14 @@ + + + + + + + + @@ -16322,6 +18422,14 @@ + + + + + + + + @@ -16418,6 +18526,14 @@ + + + + + + + + @@ -16728,6 +18844,14 @@ + + + + + + + + @@ -16791,6 +18915,21 @@ + + + + + + + + + + + + + + + @@ -16873,6 +19012,21 @@ + + + + + + + + + + + + + + + @@ -16921,6 +19075,30 @@ + + + + + + + + + + + + + + + + + + + + + + + + @@ -17017,6 +19195,14 @@ + + + + + + + + @@ -17033,6 +19219,30 @@ + + + + + + + + + + + + + + + + + + + + + + + + @@ -17193,6 +19403,30 @@ + + + + + + + + + + + + + + + + + + + + + + + + @@ -17281,6 +19515,30 @@ + + + + + + + + + + + + + + + + + + + + + + + + @@ -17401,8 +19659,24 @@ - - + + + + + + + + + + + + + + + + + + @@ -17490,6 +19764,16 @@ + + + + + + + + + + @@ -17779,6 +20063,14 @@ + + + + + + + + @@ -17787,6 +20079,11 @@ + + + + + @@ -18185,6 +20482,14 @@ + + + + + + + + @@ -18209,6 +20514,14 @@ + + + + + + + + @@ -18233,6 +20546,14 @@ + + + + + + + + @@ -18257,6 +20578,14 @@ + + + + + + + + @@ -18281,6 +20610,14 @@ + + + + + + + + @@ -18305,6 +20642,14 @@ + + + + + + + + @@ -18389,6 +20734,14 @@ + + + + + + + + @@ -18461,6 +20814,22 @@ + + + + + + + + + + + + + + + + @@ -19163,6 +21532,11 @@ + + + + + @@ -19367,6 +21741,14 @@ + + + + + + + + @@ -19422,6 +21804,19 @@ + + + + + + + + + + + + + @@ -20731,6 +23126,19 @@ + + + + + + + + + + + + + @@ -20850,6 +23258,11 @@ + + + + + @@ -20858,6 +23271,11 @@ + + + + + @@ -20963,6 +23381,11 @@ + + + + + @@ -21673,6 +24096,124 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + @@ -21681,6 +24222,14 @@ + + + + + + + + @@ -21697,6 +24246,22 @@ + + + + + + + + + + + + + + + + @@ -21723,6 +24288,22 @@ + + + + + + + + + + + + + + + + @@ -21763,6 +24344,38 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + @@ -21795,6 +24408,22 @@ + + + + + + + + + + + + + + + + @@ -21883,6 +24512,22 @@ + + + + + + + + + + + + + + + + @@ -21957,6 +24602,22 @@ + + + + + + + + + + + + + + + + @@ -22050,6 +24711,43 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + @@ -22098,6 +24796,22 @@ + + + + + + + + + + + + + + + + @@ -22195,6 +24909,22 @@ + + + + + + + + + + + + + + + + @@ -22288,6 +25018,27 @@ + + + + + + + + + + + + + + + + + + + + + @@ -22336,6 +25087,22 @@ + + + + + + + + + + + + + + + + @@ -22429,6 +25196,27 @@ + + + + + + + + + + + + + + + + + + + + + @@ -22526,6 +25314,22 @@ + + + + + + + + + + + + + + + + @@ -22574,6 +25378,22 @@ + + + + + + + + + + + + + + + + @@ -22622,6 +25442,22 @@ + + + + + + + + + + + + + + + + @@ -22688,6 +25524,22 @@ + + + + + + + + + + + + + + + + @@ -22736,6 +25588,22 @@ + + + + + + + + + + + + + + + + @@ -22784,6 +25652,22 @@ + + + + + + + + + + + + + + + + @@ -22897,17 +25781,54 @@ - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - - - + + + - - + + @@ -22958,6 +25879,22 @@ + + + + + + + + + + + + + + + + @@ -23006,6 +25943,22 @@ + + + + + + + + + + + + + + + + @@ -23038,6 +25991,22 @@ + + + + + + + + + + + + + + + + @@ -23086,6 +26055,22 @@ + + + + + + + + + + + + + + + + @@ -23134,6 +26119,22 @@ + + + + + + + + + + + + + + + + @@ -23182,6 +26183,22 @@ + + + + + + + + + + + + + + + + @@ -23259,6 +26276,11 @@ + + + + + @@ -23331,6 +26353,22 @@ + + + + + + + + + + + + + + + + @@ -23571,6 +26609,22 @@ + + + + + + + + + + + + + + + + @@ -23619,6 +26673,22 @@ + + + + + + + + + + + + + + + + @@ -23787,6 +26857,14 @@ + + + + + + + + @@ -23795,6 +26873,22 @@ + + + + + + + + + + + + + + + + @@ -23892,6 +26986,16 @@ + + + + + + + + + + @@ -23996,6 +27100,11 @@ + + + + + @@ -24094,6 +27203,11 @@ + + + + + @@ -24142,6 +27256,27 @@ + + + + + + + + + + + + + + + + + + + + + @@ -24190,6 +27325,22 @@ + + + + + + + + + + + + + + + + @@ -24238,6 +27389,22 @@ + + + + + + + + + + + + + + + + @@ -24286,6 +27453,22 @@ + + + + + + + + + + + + + + + + @@ -24354,6 +27537,21 @@ + + + + + + + + + + + + + + + @@ -24616,6 +27814,19 @@ + + + + + + + + + + + + + @@ -24759,6 +27970,11 @@ + + + + + @@ -24784,6 +28000,11 @@ + + + + + @@ -24826,6 +28047,14 @@ + + + + + + + + @@ -24874,6 +28103,11 @@ + + + + + @@ -24908,6 +28142,14 @@ + + + + + + + + @@ -25036,6 +28278,22 @@ + + + + + + + + + + + + + + + + @@ -25173,6 +28431,9 @@ + + + @@ -25187,6 +28448,19 @@ + + + + + + + + + + + + + @@ -25267,6 +28541,22 @@ + + + + + + + + + + + + + + + + @@ -25347,6 +28637,22 @@ + + + + + + + + + + + + + + + + @@ -25363,6 +28669,22 @@ + + + + + + + + + + + + + + + + @@ -25443,6 +28765,22 @@ + + + + + + + + + + + + + + + + @@ -25451,6 +28789,100 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + @@ -25600,6 +29032,22 @@ + + + + + + + + + + + + + + + + @@ -25690,6 +29138,14 @@ + + + + + + + + @@ -25811,6 +29267,14 @@ + + + + + + + + @@ -25898,6 +29362,14 @@ + + + + + + + + @@ -25922,6 +29394,14 @@ + + + + + + + + @@ -25946,6 +29426,14 @@ + + + + + + + + @@ -25994,6 +29482,14 @@ + + + + + + + + @@ -26018,6 +29514,14 @@ + + + + + + + + @@ -26042,6 +29546,14 @@ + + + + + + + + @@ -26066,6 +29578,14 @@ + + + + + + + + @@ -26090,6 +29610,14 @@ + + + + + + + + @@ -26114,6 +29642,14 @@ + + + + + + + + @@ -26138,6 +29674,14 @@ + + + + + + + + @@ -26162,6 +29706,14 @@ + + + + + + + + @@ -26186,6 +29738,14 @@ + + + + + + + + @@ -26422,6 +29982,22 @@ + + + + + + + + + + + + + + + +