From 9cd7d55a8690fbd2c384bda5407303c850a297c5 Mon Sep 17 00:00:00 2001 From: Dmitrij Rysanow Date: Mon, 5 Oct 2026 10:40:23 +0200 Subject: [PATCH 1/8] feat(chat): integrate with Android Telecom Signed-off-by: Dmitrij Rysanow --- app/build.gradle.kts | 1 + app/src/main/AndroidManifest.xml | 4 +- .../nextcloud/talk/activities/CallActivity.kt | 103 +++++++------ .../application/NextcloudTalkApplication.kt | 10 +- .../com/nextcloud/talk/call/TelecomManager.kt | 144 ++++++++++++++++++ .../CallNotificationActivity.kt | 18 +++ .../talk/dagger/modules/ManagerModule.kt | 6 + .../talk/services/CallForegroundService.kt | 6 +- 8 files changed, 245 insertions(+), 47 deletions(-) create mode 100644 app/src/main/java/com/nextcloud/talk/call/TelecomManager.kt diff --git a/app/build.gradle.kts b/app/build.gradle.kts index bf70a909531..946a688be79 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -197,6 +197,7 @@ configurations.configureEach { } dependencies { + implementation("androidx.core:core-telecom:1.0.0") implementation("androidx.media3:media3-session:1.11.0") kapt("org.jetbrains.kotlin:kotlin-metadata-jvm:$kotlinVersion") implementation("androidx.room:room-testing-android:$roomVersion") diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 3923ae833d7..e14f8eeb914 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -52,6 +52,8 @@ + + + android:foregroundServiceType="phoneCall|microphone|camera" /> 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 490eb4a5db6..8f26e559890 100644 --- a/app/src/main/java/com/nextcloud/talk/activities/CallActivity.kt +++ b/app/src/main/java/com/nextcloud/talk/activities/CallActivity.kt @@ -78,6 +78,7 @@ import com.nextcloud.talk.call.MessageSenderMcu import com.nextcloud.talk.call.MessageSenderNoMcu import com.nextcloud.talk.call.MutableLocalCallParticipantModel import com.nextcloud.talk.call.ReactionAnimator +import com.nextcloud.talk.call.TelecomManager import com.nextcloud.talk.call.components.ParticipantGrid import com.nextcloud.talk.call.components.SelfVideoView import com.nextcloud.talk.call.components.screenshare.ScreenShareComponent @@ -220,6 +221,9 @@ class CallActivity : CallBaseActivity() { @Inject var permissionUtil: PlatformPermissionUtil? = null + @Inject + lateinit var telecomManager: TelecomManager + @Inject lateinit var viewModelFactory: ViewModelProvider.Factory lateinit var callViewModel: CallViewModel @@ -2218,6 +2222,7 @@ class CallActivity : CallBaseActivity() { private fun hangup(shutDownView: Boolean, endCallForAll: Boolean) { Log.d(TAG, "hangup! shutDownView=$shutDownView, endCallForAll=$endCallForAll") + telecomManager.endCurrentCall() joinRoomInitiated = false if (shutDownView) { setCallState(CallStatus.LEAVING) @@ -2357,52 +2362,55 @@ class CallActivity : CallBaseActivity() { } private fun openConversationListInPrimaryTask() { - val activityManager = getSystemService(Context.ACTIVITY_SERVICE) as? ActivityManager - val appTasks = activityManager - ?.appTasks - ?.filter { appTask -> appTask.taskInfo?.baseActivity?.packageName == packageName } - .orEmpty() - - val primaryTask = appTasks.firstOrNull { appTask -> - val taskInfo = appTask.taskInfo - val baseActivityName = taskInfo?.baseActivity?.className - taskInfo != null && - taskInfo.taskId != taskId && - baseActivityName == MainActivity::class.java.name - } ?: appTasks.firstOrNull { appTask -> - val taskInfo = appTask.taskInfo - val baseActivityName = taskInfo?.baseActivity?.className - taskInfo != null && - taskInfo.taskId != taskId && - baseActivityName != CallActivity::class.java.name && - baseActivityName != CallNotificationActivity::class.java.name - } - - if (primaryTask != null) { - val intent = Intent(context, ConversationsListActivity::class.java).apply { - putExtra(KEY_INTERNAL_USER_ID, conversationUser.id) - addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_SINGLE_TOP) - } - primaryTask.startActivity(context, intent, null) - primaryTask.moveToFront() - val primaryTaskId = primaryTask.taskInfo?.taskId - appTasks - .filter { appTask -> - val taskInfo = appTask.taskInfo - val baseActivityName = taskInfo?.baseActivity?.className - val isCallTask = baseActivityName == CallActivity::class.java.name || - baseActivityName == CallNotificationActivity::class.java.name - taskInfo != null && !isCallTask && taskInfo.taskId != primaryTaskId && taskInfo.taskId != taskId + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + val activityManager = getSystemService(Context.ACTIVITY_SERVICE) as? ActivityManager + val appTasks = activityManager + ?.appTasks + ?.filter { appTask -> appTask.taskInfo?.baseActivity?.packageName == packageName } + .orEmpty() + + val primaryTask = appTasks.firstOrNull { appTask -> + val taskInfo = appTask.taskInfo + val baseActivityName = taskInfo?.baseActivity?.className + taskInfo != null && + taskInfo.taskId != taskId && + baseActivityName == MainActivity::class.java.name + } ?: appTasks.firstOrNull { appTask -> + val taskInfo = appTask.taskInfo + val baseActivityName = taskInfo?.baseActivity?.className + taskInfo != null && + taskInfo.taskId != taskId && + baseActivityName != CallActivity::class.java.name && + baseActivityName != CallNotificationActivity::class.java.name + } + + if (primaryTask != null) { + val intent = Intent(context, ConversationsListActivity::class.java).apply { + putExtra(KEY_INTERNAL_USER_ID, conversationUser.id) + addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_SINGLE_TOP) } - .forEach { it.finishAndRemoveTask() } - } else { - val mainIntent = Intent(context, MainActivity::class.java).apply { - action = Intent.ACTION_MAIN - addCategory(Intent.CATEGORY_LAUNCHER) - addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK) + primaryTask.startActivity(context, intent, null) + primaryTask.moveToFront() + val primaryTaskId = primaryTask.taskInfo?.taskId + appTasks + .filter { appTask -> + val taskInfo = appTask.taskInfo + val baseActivityName = taskInfo?.baseActivity?.className + val isCallTask = baseActivityName == CallActivity::class.java.name || + baseActivityName == CallNotificationActivity::class.java.name + taskInfo != null && !isCallTask && taskInfo.taskId != primaryTaskId && taskInfo.taskId != taskId + } + .forEach { it.finishAndRemoveTask() } + return } - startActivity(mainIntent) } + + val mainIntent = Intent(context, MainActivity::class.java).apply { + action = Intent.ACTION_MAIN + addCategory(Intent.CATEGORY_LAUNCHER) + addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK) + } + startActivity(mainIntent) } private fun startVideoCapture(isPortrait: Boolean) { @@ -2964,6 +2972,15 @@ class CallActivity : CallBaseActivity() { } private fun handleCallStateJoined() { + if (!telecomManager.hasActiveCall()) { + telecomManager.addOutgoingCall( + displayName = conversationName ?: "Nextcloud Talk Call", + roomToken = roomToken ?: "", + isVideo = !isVoiceOnlyCall + ) + } else { + telecomManager.setCallActive() + } if (isIncomingCallFromNotification) { binding!!.callStates.callStateTextView.setText(R.string.nc_call_incoming) } else { diff --git a/app/src/main/java/com/nextcloud/talk/application/NextcloudTalkApplication.kt b/app/src/main/java/com/nextcloud/talk/application/NextcloudTalkApplication.kt index cec49766a69..b9f24074a96 100644 --- a/app/src/main/java/com/nextcloud/talk/application/NextcloudTalkApplication.kt +++ b/app/src/main/java/com/nextcloud/talk/application/NextcloudTalkApplication.kt @@ -40,6 +40,7 @@ import com.nextcloud.talk.errorhandling.ExceptionHandler import com.nextcloud.talk.logger.LoggerImpl import com.nextcloud.talk.account.BrowserLoginActivity import com.nextcloud.talk.dagger.modules.ApplicationScope +import com.nextcloud.talk.call.TelecomManager import com.nextcloud.talk.dagger.modules.BusModule import com.nextcloud.talk.dagger.modules.ContextModule import com.nextcloud.talk.dagger.modules.DaosModule @@ -123,6 +124,9 @@ class NextcloudTalkApplication : lateinit var logger: LoggerImpl //endregion + @Inject + lateinit var telecomManager: TelecomManager + //region private methods private fun installExceptionHandler() { val defaultHandler = Thread.getDefaultUncaughtExceptionHandler() @@ -214,11 +218,13 @@ class NextcloudTalkApplication : securityKeyManager.init(this, securityKeyConfigBuilder.build()) initializeWebRtc() buildComponent() + + componentApplication.inject(this) + + telecomManager.registerAppWithTelecom() DavUtils.registerCustomFactories() Security.insertProviderAt(Conscrypt.newProvider(), 1) - - componentApplication.inject(this) installExceptionHandler() ProcessLifecycleOwner.get().lifecycle.addObserver(object : DefaultLifecycleObserver { diff --git a/app/src/main/java/com/nextcloud/talk/call/TelecomManager.kt b/app/src/main/java/com/nextcloud/talk/call/TelecomManager.kt new file mode 100644 index 00000000000..b66473c2edb --- /dev/null +++ b/app/src/main/java/com/nextcloud/talk/call/TelecomManager.kt @@ -0,0 +1,144 @@ +/* + * Nextcloud Talk - Android Client + * + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +package com.nextcloud.talk.call + +import android.content.Context +import android.net.Uri +import android.telecom.DisconnectCause +import android.util.Log +import androidx.core.telecom.CallAttributesCompat +import androidx.core.telecom.CallControlResult +import androidx.core.telecom.CallControlScope +import androidx.core.telecom.CallsManager +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class TelecomManager @Inject constructor(private val context: Context) { + private val callsManager = CallsManager(context) + private val telecomScope = CoroutineScope(Dispatchers.Main) + + private var currentCallControlScope: CallControlScope? = null + + fun registerAppWithTelecom() { + try { + val capabilities: @CallsManager.Companion.Capability Int = CallsManager.CAPABILITY_BASELINE or CallsManager + .CAPABILITY_SUPPORTS_VIDEO_CALLING + callsManager.registerAppWithTelecom(capabilities) + Log.d(TAG, "Registered app with Core-Telecom successfully") + } catch (e: Exception) { + Log.e(TAG, "Failed to register app with Core-Telecom", e) + } + } + + fun hasActiveCall(): Boolean = currentCallControlScope != null + + fun addIncomingCall( + displayName: String, + roomToken: String, + isVideo: Boolean, + onAnswerCall: () -> Unit, + onRejectCall: () -> Unit + ) { + val callAttributes = CallAttributesCompat( + displayName = displayName, + address = Uri.parse("talk:$roomToken"), + direction = CallAttributesCompat.DIRECTION_INCOMING, + callType = getCallType(isVideo), + callCapabilities = CallAttributesCompat.SUPPORTS_SET_INACTIVE + ) + + telecomScope.launch { + try { + callsManager.addCall( + callAttributes = callAttributes, + onAnswer = { _ -> + onAnswerCall() + CallControlResult.Success() + }, + onDisconnect = { _ -> + onRejectCall() + CallControlResult.Success() + }, + onSetActive = { CallControlResult.Success() }, + onSetInactive = { CallControlResult.Success() } + ) { + currentCallControlScope = this + Log.d(TAG, "Incoming call registered in CallControlScope") + } + } catch (e: Exception) { + Log.e(TAG, "Error adding incoming call to Telecom", e) + } + } + } + + fun addOutgoingCall( + displayName: String, + roomToken: String, + isVideo: Boolean, + onDisconnectCall: (() -> Unit)? = null + ) { + if (currentCallControlScope != null) { + Log.d(TAG, "Call already registered in Telecom") + return + } + + val callAttributes = CallAttributesCompat( + displayName = displayName, + address = Uri.parse("talk:$roomToken"), + direction = CallAttributesCompat.DIRECTION_OUTGOING, + callType = getCallType(isVideo), + callCapabilities = CallAttributesCompat.SUPPORTS_SET_INACTIVE + ) + + telecomScope.launch { + try { + callsManager.addCall( + callAttributes = callAttributes, + onAnswer = { CallControlResult.Success() }, + onDisconnect = { _ -> + onDisconnectCall?.invoke() + CallControlResult.Success() + }, + onSetActive = { CallControlResult.Success() }, + onSetInactive = { CallControlResult.Success() } + ) { + currentCallControlScope = this + launch { setActive() } + Log.d(TAG, "Outgoing call registered and active in CallControlScope") + } + } catch (e: Exception) { + Log.e(TAG, "Error adding outgoing call to Telecom", e) + } + } + } + + + fun endCurrentCall() { + telecomScope.launch { + currentCallControlScope?.disconnect(DisconnectCause(DisconnectCause.LOCAL)) + currentCallControlScope = null + } + } + + fun setCallActive() { + telecomScope.launch { + currentCallControlScope?.setActive() + } + } + + private fun getCallType(isVideo: Boolean): Int = + if (isVideo) CallAttributesCompat.CALL_TYPE_VIDEO_CALL else CallAttributesCompat.CALL_TYPE_AUDIO_CALL + + companion object { + private val TAG = TelecomManager::class.java.simpleName + } +} diff --git a/app/src/main/java/com/nextcloud/talk/callnotification/CallNotificationActivity.kt b/app/src/main/java/com/nextcloud/talk/callnotification/CallNotificationActivity.kt index 52f81718151..92d88835f38 100644 --- a/app/src/main/java/com/nextcloud/talk/callnotification/CallNotificationActivity.kt +++ b/app/src/main/java/com/nextcloud/talk/callnotification/CallNotificationActivity.kt @@ -24,6 +24,7 @@ import com.nextcloud.talk.activities.CallBaseActivity import com.nextcloud.talk.api.NcApi import com.nextcloud.talk.application.NextcloudTalkApplication import com.nextcloud.talk.application.NextcloudTalkApplication.Companion.sharedApplication +import com.nextcloud.talk.call.TelecomManager import com.nextcloud.talk.data.user.model.User import com.nextcloud.talk.databinding.CallNotificationActivityBinding import com.nextcloud.talk.extensions.loadUserAvatar @@ -52,6 +53,8 @@ class CallNotificationActivity : CallBaseActivity() { @Inject var cache: Cache? = null + @Inject + lateinit var telecomManager: TelecomManager private var roomToken: String? = null private var notificationTimestamp: Int? = null private var displayName: String? = null @@ -79,6 +82,21 @@ class CallNotificationActivity : CallBaseActivity() { setupAvatar(isOneToOneCall, conversationName) initClickListeners() setupNotificationCanceledRoutine() + telecomManager.addIncomingCall( + displayName = displayName ?: "Nextcloud Talk Call", + roomToken = roomToken ?: "", + isVideo = isInCallWithVideo(callFlag), + onAnswerCall = { + runOnUiThread { + proceedToCall() + } + }, + onRejectCall = { + runOnUiThread { + hangup() + } + } + ) } private fun handleExtras() { diff --git a/app/src/main/java/com/nextcloud/talk/dagger/modules/ManagerModule.kt b/app/src/main/java/com/nextcloud/talk/dagger/modules/ManagerModule.kt index cc7b14ff60b..69009974a8e 100644 --- a/app/src/main/java/com/nextcloud/talk/dagger/modules/ManagerModule.kt +++ b/app/src/main/java/com/nextcloud/talk/dagger/modules/ManagerModule.kt @@ -8,6 +8,7 @@ package com.nextcloud.talk.dagger.modules import android.content.Context +import com.nextcloud.talk.call.TelecomManager import com.nextcloud.talk.chat.data.io.AudioFocusRequestManager import com.nextcloud.talk.chat.data.io.AudioRecorderManager import com.nextcloud.talk.chat.data.io.MediaPlayerManager @@ -15,6 +16,7 @@ import com.nextcloud.talk.chat.data.io.MediaRecorderManager import com.nextcloud.talk.utils.preferences.AppPreferences import dagger.Module import dagger.Provides +import javax.inject.Singleton @Module class ManagerModule { @@ -33,4 +35,8 @@ class ManagerModule { @Provides fun provideAudioFocusManager(context: Context): AudioFocusRequestManager = AudioFocusRequestManager(context) + + @Provides + @Singleton + fun provideTelecomManager(context: Context): TelecomManager = TelecomManager(context) } 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 01049c347bd..477175852b5 100644 --- a/app/src/main/java/com/nextcloud/talk/services/CallForegroundService.kt +++ b/app/src/main/java/com/nextcloud/talk/services/CallForegroundService.kt @@ -176,7 +176,7 @@ class CallForegroundService : Service() { .setContentIntent(pendingIntent) .setOngoing(true) .setCategory(Notification.CATEGORY_CALL) - .setForegroundServiceBehavior(FOREGROUND_SERVICE_IMMEDIATE) + .setForegroundServiceBehavior(Notification.FOREGROUND_SERVICE_IMMEDIATE) .setShowWhen(false) .setOnlyAlertOnce(true) .also { builder -> @@ -294,6 +294,10 @@ class CallForegroundService : Service() { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { serviceType = serviceType or ServiceInfo.FOREGROUND_SERVICE_TYPE_MICROPHONE + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) { + serviceType = serviceType or ServiceInfo.FOREGROUND_SERVICE_TYPE_PHONE_CALL + } + val isVoiceOnlyCall = callExtras?.getBoolean(KEY_CALL_VOICE_ONLY, false) ?: false val canPublishVideo = callExtras?.getBoolean( KEY_PARTICIPANT_PERMISSION_CAN_PUBLISH_VIDEO, From 16249bcf3bcaf5adecf883d1d6952463193b1a82 Mon Sep 17 00:00:00 2001 From: Dmitrij Rysanow Date: Mon, 5 Oct 2026 11:01:03 +0200 Subject: [PATCH 2/8] feat(chat): integrate with Android Telecom - unit tests Signed-off-by: Dmitrij Rysanow --- app/src/main/java/com/nextcloud/talk/call/TelecomManager.kt | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/app/src/main/java/com/nextcloud/talk/call/TelecomManager.kt b/app/src/main/java/com/nextcloud/talk/call/TelecomManager.kt index b66473c2edb..53baa1d2a66 100644 --- a/app/src/main/java/com/nextcloud/talk/call/TelecomManager.kt +++ b/app/src/main/java/com/nextcloud/talk/call/TelecomManager.kt @@ -11,6 +11,7 @@ import android.content.Context import android.net.Uri import android.telecom.DisconnectCause import android.util.Log +import androidx.annotation.VisibleForTesting import androidx.core.telecom.CallAttributesCompat import androidx.core.telecom.CallControlResult import androidx.core.telecom.CallControlScope @@ -121,7 +122,6 @@ class TelecomManager @Inject constructor(private val context: Context) { } } - fun endCurrentCall() { telecomScope.launch { currentCallControlScope?.disconnect(DisconnectCause(DisconnectCause.LOCAL)) @@ -135,7 +135,8 @@ class TelecomManager @Inject constructor(private val context: Context) { } } - private fun getCallType(isVideo: Boolean): Int = + @VisibleForTesting + internal fun getCallType(isVideo: Boolean): Int = if (isVideo) CallAttributesCompat.CALL_TYPE_VIDEO_CALL else CallAttributesCompat.CALL_TYPE_AUDIO_CALL companion object { From 581983e5371a98a7780d4f4b78b679c0ef353618 Mon Sep 17 00:00:00 2001 From: Dmitrij Rysanow Date: Mon, 5 Oct 2026 11:14:05 +0200 Subject: [PATCH 3/8] feat(chat): integrate with Android Telecom - unit tests and fixes Signed-off-by: Dmitrij Rysanow --- .../nextcloud/talk/activities/CallActivity.kt | 3 +- .../nextcloud/talk/call/TelecomManagerTest.kt | 118 ++++++++++++++++++ 2 files changed, 120 insertions(+), 1 deletion(-) create mode 100644 app/src/test/java/com/nextcloud/talk/call/TelecomManagerTest.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 8f26e559890..0bc488c441d 100644 --- a/app/src/main/java/com/nextcloud/talk/activities/CallActivity.kt +++ b/app/src/main/java/com/nextcloud/talk/activities/CallActivity.kt @@ -2222,7 +2222,7 @@ class CallActivity : CallBaseActivity() { private fun hangup(shutDownView: Boolean, endCallForAll: Boolean) { Log.d(TAG, "hangup! shutDownView=$shutDownView, endCallForAll=$endCallForAll") - telecomManager.endCurrentCall() + joinRoomInitiated = false if (shutDownView) { setCallState(CallStatus.LEAVING) @@ -2264,6 +2264,7 @@ class CallActivity : CallBaseActivity() { } hangupNetworkCalls(shutDownView, endCallForAll) + telecomManager.endCurrentCall() } private fun terminateAudioVideo() { diff --git a/app/src/test/java/com/nextcloud/talk/call/TelecomManagerTest.kt b/app/src/test/java/com/nextcloud/talk/call/TelecomManagerTest.kt new file mode 100644 index 00000000000..38494718863 --- /dev/null +++ b/app/src/test/java/com/nextcloud/talk/call/TelecomManagerTest.kt @@ -0,0 +1,118 @@ +/* + * Nextcloud Talk - Android Client + * + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +package com.nextcloud.talk.call + +import android.app.Application +import android.content.Context +import androidx.core.telecom.CallAttributesCompat +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.setMain +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.ArgumentMatchers.any +import org.mockito.Mockito.`when` +import org.mockito.Mockito.mock +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +@RunWith(RobolectricTestRunner::class) +@Config(application = Application::class, sdk = [34]) +@OptIn(ExperimentalCoroutinesApi::class) +class TelecomManagerTest { + + private val testDispatcher = UnconfinedTestDispatcher() + private lateinit var context: Context + private lateinit var platformTelecomManager: android.telecom.TelecomManager + private lateinit var telecomManager: TelecomManager + + @Before + fun setUp() { + Dispatchers.setMain(testDispatcher) + + context = mock(Context::class.java) + platformTelecomManager = mock(android.telecom.TelecomManager::class.java) + `when`(context.getSystemService(Context.TELECOM_SERVICE)).thenReturn(platformTelecomManager) + `when`(context.getSystemService(android.telecom.TelecomManager::class.java)).thenReturn(platformTelecomManager) + `when`(context.applicationContext).thenReturn(context) + + telecomManager = TelecomManager(context) + } + + @After + fun tearDown() { + Dispatchers.resetMain() + } + + @Test + fun testHasActiveCallInitialState() { + assertFalse(telecomManager.hasActiveCall()) + } + + @Test + fun testEndCurrentCallDeregistersActiveCallForOwnerOrParticipant() { + // Ending current call ensures telecom call scope is disconnected and active call state is cleared + telecomManager.endCurrentCall() + assertFalse(telecomManager.hasActiveCall()) + } + + @Test + fun testGetCallTypeReturnsVideoCallWhenIsVideoIsTrue() { + val callType = telecomManager.getCallType(isVideo = true) + assertEquals(CallAttributesCompat.CALL_TYPE_VIDEO_CALL, callType) + } + + @Test + fun testGetCallTypeReturnsAudioCallWhenIsVideoIsFalse() { + val callType = telecomManager.getCallType(isVideo = false) + assertEquals(CallAttributesCompat.CALL_TYPE_AUDIO_CALL, callType) + } + + @Test + fun testRegisterAppWithTelecomHandlesExceptionGracefully() { + `when`(platformTelecomManager.registerPhoneAccount(any())) + .thenThrow(UnsupportedOperationException("Telecom not supported on device")) + + // Should catch the exception and log without throwing/crashing + telecomManager.registerAppWithTelecom() + } + + @Test + fun testAddIncomingCallHandlesExceptionGracefully() { + `when`(platformTelecomManager.registerPhoneAccount(any())) + .thenThrow(UnsupportedOperationException("Telecom not supported on device")) + + // Should catch any exception thrown by CallsManager without crashing + telecomManager.addIncomingCall( + displayName = "Test User", + roomToken = "token123", + isVideo = false, + onAnswerCall = {}, + onRejectCall = {} + ) + } + + @Test + fun testAddOutgoingCallHandlesExceptionGracefully() { + `when`(platformTelecomManager.registerPhoneAccount(any())) + .thenThrow(UnsupportedOperationException("Telecom not supported on device")) + + // Should catch any exception thrown by CallsManager without crashing + telecomManager.addOutgoingCall( + displayName = "Test User", + roomToken = "token123", + isVideo = false + ) + } +} From 352cdced3c25ba1c647361e865d2182c7359df1c Mon Sep 17 00:00:00 2001 From: Dmitrij Rysanow Date: Mon, 5 Oct 2026 14:09:33 +0200 Subject: [PATCH 4/8] feat(chat): integrate with Android Telecom - after review fixes Signed-off-by: Dmitrij Rysanow --- .../nextcloud/talk/activities/CallActivity.kt | 17 ++- .../com/nextcloud/talk/call/TelecomManager.kt | 132 ++++++++++++++---- .../CallNotificationActivity.kt | 32 +++-- .../nextcloud/talk/call/TelecomManagerTest.kt | 95 ++++++++++++- 4 files changed, 230 insertions(+), 46 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 0bc488c441d..32a8a485203 100644 --- a/app/src/main/java/com/nextcloud/talk/activities/CallActivity.kt +++ b/app/src/main/java/com/nextcloud/talk/activities/CallActivity.kt @@ -2264,7 +2264,9 @@ class CallActivity : CallBaseActivity() { } hangupNetworkCalls(shutDownView, endCallForAll) - telecomManager.endCurrentCall() + if (shutDownView) { + telecomManager.endCurrentCall(roomToken) + } } private fun terminateAudioVideo() { @@ -2973,14 +2975,21 @@ class CallActivity : CallBaseActivity() { } private fun handleCallStateJoined() { - if (!telecomManager.hasActiveCall()) { + if (!telecomManager.hasActiveCall(roomToken)) { telecomManager.addOutgoingCall( displayName = conversationName ?: "Nextcloud Talk Call", roomToken = roomToken ?: "", - isVideo = !isVoiceOnlyCall + isVideo = !isVoiceOnlyCall, + onDisconnectCall = { + runOnUiThread { + if (!isFinishing && !isDestroyed && currentCallStatus != CallStatus.LEAVING) { + hangup(shutDownView = true, endCallForAll = false) + } + } + } ) } else { - telecomManager.setCallActive() + telecomManager.setCallActive(roomToken) } if (isIncomingCallFromNotification) { binding!!.callStates.callStateTextView.setText(R.string.nc_call_incoming) diff --git a/app/src/main/java/com/nextcloud/talk/call/TelecomManager.kt b/app/src/main/java/com/nextcloud/talk/call/TelecomManager.kt index 53baa1d2a66..527b4a3127b 100644 --- a/app/src/main/java/com/nextcloud/talk/call/TelecomManager.kt +++ b/app/src/main/java/com/nextcloud/talk/call/TelecomManager.kt @@ -28,19 +28,29 @@ class TelecomManager @Inject constructor(private val context: Context) { private val telecomScope = CoroutineScope(Dispatchers.Main) private var currentCallControlScope: CallControlScope? = null + private var currentRoomToken: String? = null + private var isPendingSetActive: Boolean = false + private var isLocallyDisconnecting: Boolean = false fun registerAppWithTelecom() { - try { - val capabilities: @CallsManager.Companion.Capability Int = CallsManager.CAPABILITY_BASELINE or CallsManager - .CAPABILITY_SUPPORTS_VIDEO_CALLING - callsManager.registerAppWithTelecom(capabilities) - Log.d(TAG, "Registered app with Core-Telecom successfully") - } catch (e: Exception) { - Log.e(TAG, "Failed to register app with Core-Telecom", e) + telecomScope.launch(Dispatchers.IO) { + try { + val capabilities: @CallsManager.Companion.Capability Int = + CallsManager.CAPABILITY_BASELINE or CallsManager.CAPABILITY_SUPPORTS_VIDEO_CALLING + callsManager.registerAppWithTelecom(capabilities) + Log.d(TAG, "Registered app with Core-Telecom successfully") + } catch (e: Exception) { + Log.e(TAG, "Failed to register app with Core-Telecom", e) + } } } - fun hasActiveCall(): Boolean = currentCallControlScope != null + fun hasActiveCall(roomToken: String? = null): Boolean { + if (roomToken.isNullOrBlank()) { + return currentRoomToken != null || currentCallControlScope != null + } + return currentRoomToken == roomToken + } fun addIncomingCall( displayName: String, @@ -49,6 +59,8 @@ class TelecomManager @Inject constructor(private val context: Context) { onAnswerCall: () -> Unit, onRejectCall: () -> Unit ) { + if (!prepareCallRegistration(roomToken, "Incoming")) return + val callAttributes = CallAttributesCompat( displayName = displayName, address = Uri.parse("talk:$roomToken"), @@ -66,17 +78,22 @@ class TelecomManager @Inject constructor(private val context: Context) { CallControlResult.Success() }, onDisconnect = { _ -> - onRejectCall() + val wasLocal = isLocallyDisconnecting + handleCallDisconnected(roomToken) + if (!wasLocal) { + onRejectCall() + } CallControlResult.Success() }, onSetActive = { CallControlResult.Success() }, onSetInactive = { CallControlResult.Success() } ) { - currentCallControlScope = this - Log.d(TAG, "Incoming call registered in CallControlScope") + onScopeAssigned(roomToken) } } catch (e: Exception) { - Log.e(TAG, "Error adding incoming call to Telecom", e) + Log.e(TAG, "Error adding incoming call to Telecom for room $roomToken", e) + } finally { + handleCallDisconnected(roomToken) } } } @@ -87,10 +104,7 @@ class TelecomManager @Inject constructor(private val context: Context) { isVideo: Boolean, onDisconnectCall: (() -> Unit)? = null ) { - if (currentCallControlScope != null) { - Log.d(TAG, "Call already registered in Telecom") - return - } + if (!prepareCallRegistration(roomToken, "Outgoing")) return val callAttributes = CallAttributesCompat( displayName = displayName, @@ -106,32 +120,98 @@ class TelecomManager @Inject constructor(private val context: Context) { callAttributes = callAttributes, onAnswer = { CallControlResult.Success() }, onDisconnect = { _ -> - onDisconnectCall?.invoke() + val wasLocal = isLocallyDisconnecting + handleCallDisconnected(roomToken) + if (!wasLocal) { + onDisconnectCall?.invoke() + } CallControlResult.Success() }, onSetActive = { CallControlResult.Success() }, onSetInactive = { CallControlResult.Success() } ) { - currentCallControlScope = this - launch { setActive() } - Log.d(TAG, "Outgoing call registered and active in CallControlScope") + onScopeAssigned(roomToken, autoActivate = true) } } catch (e: Exception) { - Log.e(TAG, "Error adding outgoing call to Telecom", e) + Log.e(TAG, "Error adding outgoing call to Telecom for room $roomToken", e) + } finally { + handleCallDisconnected(roomToken) } } } - fun endCurrentCall() { + fun endCurrentCall(roomToken: String? = null) { + if (roomToken != null && currentRoomToken != null && currentRoomToken != roomToken) { + Log.d(TAG, "endCurrentCall ignored: target room $roomToken does not match active room $currentRoomToken") + return + } + val scopeToDisconnect = currentCallControlScope + isLocallyDisconnecting = true + handleCallDisconnected(currentRoomToken ?: roomToken ?: "") + telecomScope.launch { - currentCallControlScope?.disconnect(DisconnectCause(DisconnectCause.LOCAL)) + try { + scopeToDisconnect?.disconnect(DisconnectCause(DisconnectCause.LOCAL)) + } finally { + isLocallyDisconnecting = false + } + } + } + + fun setCallActive(roomToken: String? = null) { + if (roomToken != null && currentRoomToken != null && currentRoomToken != roomToken) { + Log.d(TAG, "setCallActive ignored: target room $roomToken does not match active room $currentRoomToken") + return + } + val scope = currentCallControlScope + if (scope != null) { + telecomScope.launch { + scope.setActive() + } + } else if (currentRoomToken != null) { + isPendingSetActive = true + Log.d(TAG, "setCallActive queued: registration pending for room $currentRoomToken") + } + } + + private fun prepareCallRegistration(roomToken: String, callTypeLabel: String): Boolean { + if (roomToken.isBlank()) { + Log.e(TAG, "Cannot add $callTypeLabel call: roomToken is blank") + return false + } + if (currentRoomToken == roomToken || currentCallControlScope != null) { + Log.d(TAG, "$callTypeLabel call ignored: registration or scope already active for room $currentRoomToken") + return false + } + if (currentRoomToken != null) { + Log.d(TAG, "$callTypeLabel call ignored: active call already exists for room $currentRoomToken") + return false + } + currentRoomToken = roomToken + isPendingSetActive = false + return true + } + + private fun handleCallDisconnected(roomToken: String) { + if (roomToken.isNotEmpty() && (currentRoomToken == roomToken || currentRoomToken == null)) { currentCallControlScope = null + currentRoomToken = null + isPendingSetActive = false } } - fun setCallActive() { - telecomScope.launch { - currentCallControlScope?.setActive() + private fun CallControlScope.onScopeAssigned(roomToken: String, autoActivate: Boolean = false) { + if (currentRoomToken == roomToken) { + currentCallControlScope = this + Log.d(TAG, "Call registered in CallControlScope for room $roomToken") + if (autoActivate || isPendingSetActive) { + launch { setActive() } + isPendingSetActive = false + Log.d(TAG, "Call set active for room $roomToken") + } + } else { + Log.w(TAG, "CallControlScope obtained for stale room $roomToken, disconnecting") + launch { disconnect(DisconnectCause(DisconnectCause.CANCELED)) } } } diff --git a/app/src/main/java/com/nextcloud/talk/callnotification/CallNotificationActivity.kt b/app/src/main/java/com/nextcloud/talk/callnotification/CallNotificationActivity.kt index 92d88835f38..5208acd8886 100644 --- a/app/src/main/java/com/nextcloud/talk/callnotification/CallNotificationActivity.kt +++ b/app/src/main/java/com/nextcloud/talk/callnotification/CallNotificationActivity.kt @@ -82,21 +82,25 @@ class CallNotificationActivity : CallBaseActivity() { setupAvatar(isOneToOneCall, conversationName) initClickListeners() setupNotificationCanceledRoutine() - telecomManager.addIncomingCall( - displayName = displayName ?: "Nextcloud Talk Call", - roomToken = roomToken ?: "", - isVideo = isInCallWithVideo(callFlag), - onAnswerCall = { - runOnUiThread { - proceedToCall() + if (savedInstanceState == null && !roomToken.isNullOrBlank()) { + telecomManager.addIncomingCall( + displayName = displayName ?: "Nextcloud Talk Call", + roomToken = roomToken!!, + isVideo = isInCallWithVideo(callFlag), + onAnswerCall = { + runOnUiThread { + proceedToCall() + } + }, + onRejectCall = { + runOnUiThread { + if (!leavingScreen && !isFinishing && !isDestroyed) { + hangup() + } + } } - }, - onRejectCall = { - runOnUiThread { - hangup() - } - } - ) + ) + } } private fun handleExtras() { diff --git a/app/src/test/java/com/nextcloud/talk/call/TelecomManagerTest.kt b/app/src/test/java/com/nextcloud/talk/call/TelecomManagerTest.kt index 38494718863..488c9903088 100644 --- a/app/src/test/java/com/nextcloud/talk/call/TelecomManagerTest.kt +++ b/app/src/test/java/com/nextcloud/talk/call/TelecomManagerTest.kt @@ -18,6 +18,7 @@ import kotlinx.coroutines.test.setMain 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.Test import org.junit.runner.RunWith @@ -42,6 +43,7 @@ class TelecomManagerTest { Dispatchers.setMain(testDispatcher) context = mock(Context::class.java) + `when`(context.packageName).thenReturn("com.nextcloud.talk2") platformTelecomManager = mock(android.telecom.TelecomManager::class.java) `when`(context.getSystemService(Context.TELECOM_SERVICE)).thenReturn(platformTelecomManager) `when`(context.getSystemService(android.telecom.TelecomManager::class.java)).thenReturn(platformTelecomManager) @@ -91,7 +93,7 @@ class TelecomManagerTest { @Test fun testAddIncomingCallHandlesExceptionGracefully() { `when`(platformTelecomManager.registerPhoneAccount(any())) - .thenThrow(UnsupportedOperationException("Telecom not supported on device")) + .thenThrow(UnsupportedOperationException("Unsupported")) // Should catch any exception thrown by CallsManager without crashing telecomManager.addIncomingCall( @@ -106,7 +108,7 @@ class TelecomManagerTest { @Test fun testAddOutgoingCallHandlesExceptionGracefully() { `when`(platformTelecomManager.registerPhoneAccount(any())) - .thenThrow(UnsupportedOperationException("Telecom not supported on device")) + .thenThrow(UnsupportedOperationException("Unsupported")) // Should catch any exception thrown by CallsManager without crashing telecomManager.addOutgoingCall( @@ -115,4 +117,93 @@ class TelecomManagerTest { isVideo = false ) } + + @Test + fun testAddOutgoingCallConfiguresDisconnectHandler() { + var disconnected = false + telecomManager.addOutgoingCall( + displayName = "Test User", + roomToken = "token123", + isVideo = false, + onDisconnectCall = { disconnected = true } + ) + + assertFalse(disconnected) + } + + @Test + fun testAddCallGuardsAgainstConcurrentDuplicateRegistrations() { + telecomManager.addOutgoingCall( + displayName = "User 1", + roomToken = "roomA", + isVideo = false + ) + + assertTrue(telecomManager.hasActiveCall("roomA")) + assertFalse(telecomManager.hasActiveCall("roomB")) + + // Duplicate incoming or outgoing registration for another room while roomA is active should be ignored + telecomManager.addIncomingCall( + displayName = "User 2", + roomToken = "roomB", + isVideo = false, + onAnswerCall = {}, + onRejectCall = {} + ) + + assertTrue(telecomManager.hasActiveCall("roomA")) + assertFalse(telecomManager.hasActiveCall("roomB")) + } + + @Test + fun testEndCurrentCallIgnoresMismatchedRoomToken() { + telecomManager.addOutgoingCall( + displayName = "User 1", + roomToken = "roomA", + isVideo = false + ) + + telecomManager.endCurrentCall("roomB") + assertTrue(telecomManager.hasActiveCall("roomA")) + + telecomManager.endCurrentCall("roomA") + assertFalse(telecomManager.hasActiveCall("roomA")) + } + + @Test + fun testAddOutgoingCallIsIdempotentForSameRoomToken() { + telecomManager.addOutgoingCall( + displayName = "User 1", + roomToken = "roomA", + isVideo = false + ) + + assertTrue(telecomManager.hasActiveCall("roomA")) + + // Duplicate call for roomA should be ignored cleanly + telecomManager.addOutgoingCall( + displayName = "User 1", + roomToken = "roomA", + isVideo = false + ) + + assertTrue(telecomManager.hasActiveCall("roomA")) + } + + @Test + fun testSetCallActiveQueuesPendingActivationForPendingRoomToken() { + telecomManager.addIncomingCall( + displayName = "User 1", + roomToken = "roomA", + isVideo = false, + onAnswerCall = {}, + onRejectCall = {} + ) + + assertTrue(telecomManager.hasActiveCall("roomA")) + + // When answered, setCallActive is called even if registration is pending + telecomManager.setCallActive("roomA") + assertTrue(telecomManager.hasActiveCall("roomA")) + } } From 643a1d8ffb72b7ec53c7bc9108de50129225531c Mon Sep 17 00:00:00 2001 From: Dmitrij Rysanow Date: Mon, 5 Oct 2026 15:02:15 +0200 Subject: [PATCH 5/8] feat(chat): integrate with Android Telecom - after review - unique registration token for telecom Signed-off-by: Dmitrij Rysanow --- .../com/nextcloud/talk/call/TelecomManager.kt | 55 ++++++++++++------- 1 file changed, 34 insertions(+), 21 deletions(-) diff --git a/app/src/main/java/com/nextcloud/talk/call/TelecomManager.kt b/app/src/main/java/com/nextcloud/talk/call/TelecomManager.kt index 527b4a3127b..b45e09cebca 100644 --- a/app/src/main/java/com/nextcloud/talk/call/TelecomManager.kt +++ b/app/src/main/java/com/nextcloud/talk/call/TelecomManager.kt @@ -19,6 +19,7 @@ import androidx.core.telecom.CallsManager import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch +import java.util.UUID import javax.inject.Inject import javax.inject.Singleton @@ -29,6 +30,7 @@ class TelecomManager @Inject constructor(private val context: Context) { private var currentCallControlScope: CallControlScope? = null private var currentRoomToken: String? = null + private var currentRegistrationId: String? = null private var isPendingSetActive: Boolean = false private var isLocallyDisconnecting: Boolean = false @@ -59,7 +61,7 @@ class TelecomManager @Inject constructor(private val context: Context) { onAnswerCall: () -> Unit, onRejectCall: () -> Unit ) { - if (!prepareCallRegistration(roomToken, "Incoming")) return + val registrationId = prepareCallRegistration(roomToken, "Incoming") ?: return val callAttributes = CallAttributesCompat( displayName = displayName, @@ -79,7 +81,7 @@ class TelecomManager @Inject constructor(private val context: Context) { }, onDisconnect = { _ -> val wasLocal = isLocallyDisconnecting - handleCallDisconnected(roomToken) + handleCallDisconnected(registrationId) if (!wasLocal) { onRejectCall() } @@ -88,12 +90,12 @@ class TelecomManager @Inject constructor(private val context: Context) { onSetActive = { CallControlResult.Success() }, onSetInactive = { CallControlResult.Success() } ) { - onScopeAssigned(roomToken) + onScopeAssigned(registrationId, roomToken) } } catch (e: Exception) { Log.e(TAG, "Error adding incoming call to Telecom for room $roomToken", e) } finally { - handleCallDisconnected(roomToken) + handleCallDisconnected(registrationId) } } } @@ -104,7 +106,7 @@ class TelecomManager @Inject constructor(private val context: Context) { isVideo: Boolean, onDisconnectCall: (() -> Unit)? = null ) { - if (!prepareCallRegistration(roomToken, "Outgoing")) return + val registrationId = prepareCallRegistration(roomToken, "Outgoing") ?: return val callAttributes = CallAttributesCompat( displayName = displayName, @@ -121,7 +123,7 @@ class TelecomManager @Inject constructor(private val context: Context) { onAnswer = { CallControlResult.Success() }, onDisconnect = { _ -> val wasLocal = isLocallyDisconnecting - handleCallDisconnected(roomToken) + handleCallDisconnected(registrationId) if (!wasLocal) { onDisconnectCall?.invoke() } @@ -130,12 +132,12 @@ class TelecomManager @Inject constructor(private val context: Context) { onSetActive = { CallControlResult.Success() }, onSetInactive = { CallControlResult.Success() } ) { - onScopeAssigned(roomToken, autoActivate = true) + onScopeAssigned(registrationId, roomToken, autoActivate = true) } } catch (e: Exception) { Log.e(TAG, "Error adding outgoing call to Telecom for room $roomToken", e) } finally { - handleCallDisconnected(roomToken) + handleCallDisconnected(registrationId) } } } @@ -146,8 +148,9 @@ class TelecomManager @Inject constructor(private val context: Context) { return } val scopeToDisconnect = currentCallControlScope + val registrationIdToClear = currentRegistrationId isLocallyDisconnecting = true - handleCallDisconnected(currentRoomToken ?: roomToken ?: "") + handleCallDisconnected(registrationIdToClear ?: "") telecomScope.launch { try { @@ -174,43 +177,53 @@ class TelecomManager @Inject constructor(private val context: Context) { } } - private fun prepareCallRegistration(roomToken: String, callTypeLabel: String): Boolean { + private fun prepareCallRegistration(roomToken: String, callTypeLabel: String): String? { if (roomToken.isBlank()) { Log.e(TAG, "Cannot add $callTypeLabel call: roomToken is blank") - return false + return null } if (currentRoomToken == roomToken || currentCallControlScope != null) { Log.d(TAG, "$callTypeLabel call ignored: registration or scope already active for room $currentRoomToken") - return false + return null } if (currentRoomToken != null) { Log.d(TAG, "$callTypeLabel call ignored: active call already exists for room $currentRoomToken") - return false + return null } + val registrationId = UUID.randomUUID().toString() + currentRegistrationId = registrationId currentRoomToken = roomToken isPendingSetActive = false - return true + return registrationId } - private fun handleCallDisconnected(roomToken: String) { - if (roomToken.isNotEmpty() && (currentRoomToken == roomToken || currentRoomToken == null)) { + private fun handleCallDisconnected(registrationId: String) { + if (registrationId.isNotEmpty() && currentRegistrationId == registrationId) { currentCallControlScope = null currentRoomToken = null + currentRegistrationId = null isPendingSetActive = false } } - private fun CallControlScope.onScopeAssigned(roomToken: String, autoActivate: Boolean = false) { - if (currentRoomToken == roomToken) { + private fun CallControlScope.onScopeAssigned( + registrationId: String, + roomToken: String, + autoActivate: Boolean = false + ) { + if (currentRegistrationId == registrationId) { currentCallControlScope = this - Log.d(TAG, "Call registered in CallControlScope for room $roomToken") + Log.d(TAG, "Call registered in CallControlScope for room $roomToken (id=$registrationId)") if (autoActivate || isPendingSetActive) { launch { setActive() } isPendingSetActive = false - Log.d(TAG, "Call set active for room $roomToken") + Log.d(TAG, "Call set active for room $roomToken (id=$registrationId)") } } else { - Log.w(TAG, "CallControlScope obtained for stale room $roomToken, disconnecting") + Log.w( + TAG, + "CallControlScope obtained for stale registration (id=$registrationId, room=$roomToken), disconnecting" + ) launch { disconnect(DisconnectCause(DisconnectCause.CANCELED)) } } } From 74cbd5e4f86cbdf6148df06f09f09dcac56059e6 Mon Sep 17 00:00:00 2001 From: Dmitrij Rysanow Date: Mon, 5 Oct 2026 22:03:10 +0200 Subject: [PATCH 6/8] feat(chat): integrate with Android Telecom - after review fixes Signed-off-by: Dmitrij Rysanow --- app/src/main/java/com/nextcloud/talk/call/TelecomManager.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/src/main/java/com/nextcloud/talk/call/TelecomManager.kt b/app/src/main/java/com/nextcloud/talk/call/TelecomManager.kt index b45e09cebca..c94926f9eff 100644 --- a/app/src/main/java/com/nextcloud/talk/call/TelecomManager.kt +++ b/app/src/main/java/com/nextcloud/talk/call/TelecomManager.kt @@ -224,7 +224,7 @@ class TelecomManager @Inject constructor(private val context: Context) { TAG, "CallControlScope obtained for stale registration (id=$registrationId, room=$roomToken), disconnecting" ) - launch { disconnect(DisconnectCause(DisconnectCause.CANCELED)) } + launch { disconnect(DisconnectCause(DisconnectCause.REJECTED)) } } } From 83807202b9663353fe8dd2354543b260c805f623 Mon Sep 17 00:00:00 2001 From: Dmitrij Rysanow Date: Sun, 18 Oct 2026 11:47:36 +0200 Subject: [PATCH 7/8] Add voice-only call option in CallNotificationActivity Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> Signed-off-by: Dmitrij Rysanow --- .../nextcloud/talk/callnotification/CallNotificationActivity.kt | 1 + 1 file changed, 1 insertion(+) diff --git a/app/src/main/java/com/nextcloud/talk/callnotification/CallNotificationActivity.kt b/app/src/main/java/com/nextcloud/talk/callnotification/CallNotificationActivity.kt index 5208acd8886..78c273d3991 100644 --- a/app/src/main/java/com/nextcloud/talk/callnotification/CallNotificationActivity.kt +++ b/app/src/main/java/com/nextcloud/talk/callnotification/CallNotificationActivity.kt @@ -89,6 +89,7 @@ class CallNotificationActivity : CallBaseActivity() { isVideo = isInCallWithVideo(callFlag), onAnswerCall = { runOnUiThread { + intent.putExtra(KEY_CALL_VOICE_ONLY, !isInCallWithVideo(callFlag)) proceedToCall() } }, From 4ea2c06efdde988e92a4f24b7175ce807d70fb0d Mon Sep 17 00:00:00 2001 From: Dmitrij Rysanow Date: Mon, 19 Oct 2026 01:09:55 +0200 Subject: [PATCH 8/8] Copilot review fixes + synchronization issues --- .../nextcloud/talk/activities/CallActivity.kt | 54 ++++++--- .../com/nextcloud/talk/call/TelecomManager.kt | 104 +++++++++++----- .../CallNotificationActivity.kt | 28 ++--- .../talk/dagger/modules/ManagerModule.kt | 6 - .../nextcloud/talk/jobs/NotificationWorker.kt | 33 ++++- .../talk/receivers/DeclineCallReceiver.kt | 2 + .../nextcloud/talk/call/TelecomManagerTest.kt | 114 ++++++++++++++++++ 7 files changed, 263 insertions(+), 78 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 32a8a485203..c8cf595f810 100644 --- a/app/src/main/java/com/nextcloud/talk/activities/CallActivity.kt +++ b/app/src/main/java/com/nextcloud/talk/activities/CallActivity.kt @@ -1606,6 +1606,7 @@ class CallActivity : CallBaseActivity() { // The screen that started the call marked it as dialing, which hangup() would reset. Without resetting // it here, chats would keep treating the app as being in a call. ApplicationWideCurrentRoomHolder.getInstance().isDialing = false + telecomManager.endCurrentCall(roomToken) } super.onDestroy() @@ -2233,8 +2234,12 @@ class CallActivity : CallBaseActivity() { dispose(nickSendingDisposable) nickSendingDisposable = null - if (shutDownView) { + if (shutDownView || currentCallStatus == CallStatus.CALLING_TIMEOUT) { terminateAudioVideo() + if (webSocketClient != null) { + webSocketClient!!.sendBye() + webSocketClient = null + } } val peerConnectionIdsToEnd: MutableList = ArrayList(peerConnectionWrapperList.size) @@ -2306,7 +2311,9 @@ class CallActivity : CallBaseActivity() { } localAudioTrack = null localVideoTrack = null - if (TextUtils.isEmpty(credentials) && hasExternalSignalingServer) { + if (::conversationUser.isInitialized && conversationUser.id != null) { + WebSocketConnectionHelper.deleteExternalSignalingInstanceForUserEntity(conversationUser.id!!) + } else if (TextUtils.isEmpty(credentials) && hasExternalSignalingServer) { WebSocketConnectionHelper.deleteExternalSignalingInstanceForUserEntity(-1) } } @@ -2356,7 +2363,7 @@ class CallActivity : CallBaseActivity() { startActivity(intent) finish() } else if (shutDownView) { - finish() + finishAndRemoveTask() } else if (currentCallStatus === CallStatus.RECONNECTING || currentCallStatus === CallStatus.PUBLISHER_FAILED ) { @@ -2958,7 +2965,31 @@ class CallActivity : CallBaseActivity() { } } + private fun activateTelecomCall() { + val disconnectHandler: () -> Unit = { + runOnUiThread { + if (!isFinishing && !isDestroyed && currentCallStatus != CallStatus.LEAVING) { + hangup(shutDownView = true, endCallForAll = false) + } + } + } + + if (!telecomManager.hasActiveCall(roomToken)) { + telecomManager.addOutgoingCall( + displayName = conversationName.takeIf { + !it.isNullOrBlank() + } ?: getString(R.string.nc_app_product_name), + roomToken = roomToken ?: "", + isVideo = !isVoiceOnlyCall, + onDisconnectCall = disconnectHandler + ) + } else { + telecomManager.setCallActive(roomToken, onDisconnectCall = disconnectHandler) + } + } + private fun handleCallStateInConversation() { + activateTelecomCall() stopCallingSound() if (binding!!.callStates.callStateRelativeLayout.visibility != View.INVISIBLE) { binding!!.callStates.callStateRelativeLayout.visibility = View.INVISIBLE @@ -2975,22 +3006,7 @@ class CallActivity : CallBaseActivity() { } private fun handleCallStateJoined() { - if (!telecomManager.hasActiveCall(roomToken)) { - telecomManager.addOutgoingCall( - displayName = conversationName ?: "Nextcloud Talk Call", - roomToken = roomToken ?: "", - isVideo = !isVoiceOnlyCall, - onDisconnectCall = { - runOnUiThread { - if (!isFinishing && !isDestroyed && currentCallStatus != CallStatus.LEAVING) { - hangup(shutDownView = true, endCallForAll = false) - } - } - } - ) - } else { - telecomManager.setCallActive(roomToken) - } + activateTelecomCall() if (isIncomingCallFromNotification) { binding!!.callStates.callStateTextView.setText(R.string.nc_call_incoming) } else { diff --git a/app/src/main/java/com/nextcloud/talk/call/TelecomManager.kt b/app/src/main/java/com/nextcloud/talk/call/TelecomManager.kt index c94926f9eff..5271c5df4da 100644 --- a/app/src/main/java/com/nextcloud/talk/call/TelecomManager.kt +++ b/app/src/main/java/com/nextcloud/talk/call/TelecomManager.kt @@ -28,11 +28,21 @@ class TelecomManager @Inject constructor(private val context: Context) { private val callsManager = CallsManager(context) private val telecomScope = CoroutineScope(Dispatchers.Main) - private var currentCallControlScope: CallControlScope? = null - private var currentRoomToken: String? = null - private var currentRegistrationId: String? = null - private var isPendingSetActive: Boolean = false - private var isLocallyDisconnecting: Boolean = false + @Volatile private var currentCallControlScope: CallControlScope? = null + + @Volatile private var currentRoomToken: String? = null + + @Volatile private var currentRegistrationId: String? = null + + @Volatile private var isPendingSetActive: Boolean = false + + @Volatile private var currentDisconnectHandler: (() -> Unit)? = null + + @Volatile private var isIncomingCall: Boolean = false + + @Volatile private var isCallAnsweredOrActive: Boolean = false + + @Volatile private var currentCallType: Int = CallAttributesCompat.CALL_TYPE_AUDIO_CALL fun registerAppWithTelecom() { telecomScope.launch(Dispatchers.IO) { @@ -62,6 +72,10 @@ class TelecomManager @Inject constructor(private val context: Context) { onRejectCall: () -> Unit ) { val registrationId = prepareCallRegistration(roomToken, "Incoming") ?: return + currentDisconnectHandler = onRejectCall + isIncomingCall = true + isCallAnsweredOrActive = false + currentCallType = getCallType(isVideo) val callAttributes = CallAttributesCompat( displayName = displayName, @@ -76,19 +90,17 @@ class TelecomManager @Inject constructor(private val context: Context) { callsManager.addCall( callAttributes = callAttributes, onAnswer = { _ -> + isIncomingCall = false + isCallAnsweredOrActive = true onAnswerCall() - CallControlResult.Success() }, onDisconnect = { _ -> - val wasLocal = isLocallyDisconnecting + val handlerToInvoke = currentDisconnectHandler handleCallDisconnected(registrationId) - if (!wasLocal) { - onRejectCall() - } - CallControlResult.Success() + handlerToInvoke?.invoke() }, - onSetActive = { CallControlResult.Success() }, - onSetInactive = { CallControlResult.Success() } + onSetActive = { }, + onSetInactive = { } ) { onScopeAssigned(registrationId, roomToken) } @@ -107,6 +119,10 @@ class TelecomManager @Inject constructor(private val context: Context) { onDisconnectCall: (() -> Unit)? = null ) { val registrationId = prepareCallRegistration(roomToken, "Outgoing") ?: return + currentDisconnectHandler = onDisconnectCall + isIncomingCall = false + isCallAnsweredOrActive = true + currentCallType = getCallType(isVideo) val callAttributes = CallAttributesCompat( displayName = displayName, @@ -120,17 +136,14 @@ class TelecomManager @Inject constructor(private val context: Context) { try { callsManager.addCall( callAttributes = callAttributes, - onAnswer = { CallControlResult.Success() }, + onAnswer = { }, onDisconnect = { _ -> - val wasLocal = isLocallyDisconnecting + val handlerToInvoke = currentDisconnectHandler handleCallDisconnected(registrationId) - if (!wasLocal) { - onDisconnectCall?.invoke() - } - CallControlResult.Success() + handlerToInvoke?.invoke() }, - onSetActive = { CallControlResult.Success() }, - onSetInactive = { CallControlResult.Success() } + onSetActive = { }, + onSetInactive = { } ) { onScopeAssigned(registrationId, roomToken, autoActivate = true) } @@ -143,33 +156,41 @@ class TelecomManager @Inject constructor(private val context: Context) { } fun endCurrentCall(roomToken: String? = null) { - if (roomToken != null && currentRoomToken != null && currentRoomToken != roomToken) { - Log.d(TAG, "endCurrentCall ignored: target room $roomToken does not match active room $currentRoomToken") + val activeRoom = currentRoomToken ?: return + if (!roomToken.isNullOrBlank() && activeRoom != roomToken) { + Log.d(TAG, "endCurrentCall ignored: target room $roomToken does not match active room $activeRoom") + return + } + if (roomToken.isNullOrBlank() && isCallAnsweredOrActive) { + Log.d(TAG, "endCurrentCall ignored: blank roomToken provided for active call") return } + val scopeToDisconnect = currentCallControlScope val registrationIdToClear = currentRegistrationId - isLocallyDisconnecting = true handleCallDisconnected(registrationIdToClear ?: "") telecomScope.launch { - try { - scopeToDisconnect?.disconnect(DisconnectCause(DisconnectCause.LOCAL)) - } finally { - isLocallyDisconnecting = false - } + scopeToDisconnect?.disconnect(DisconnectCause(DisconnectCause.LOCAL)) } } - fun setCallActive(roomToken: String? = null) { + fun setCallActive(roomToken: String? = null, onDisconnectCall: (() -> Unit)? = null) { if (roomToken != null && currentRoomToken != null && currentRoomToken != roomToken) { Log.d(TAG, "setCallActive ignored: target room $roomToken does not match active room $currentRoomToken") return } + + if (onDisconnectCall != null) { + currentDisconnectHandler = onDisconnectCall + } + + isCallAnsweredOrActive = true + val scope = currentCallControlScope if (scope != null) { telecomScope.launch { - scope.setActive() + activateScope(scope) } } else if (currentRoomToken != null) { isPendingSetActive = true @@ -177,6 +198,22 @@ class TelecomManager @Inject constructor(private val context: Context) { } } + private suspend fun activateScope(scope: CallControlScope) { + val result = if (isIncomingCall) { + val res = scope.answer(currentCallType) + isIncomingCall = false + res + } else { + scope.setActive() + } + if (result is CallControlResult.Error) { + Log.e(TAG, "Failed to activate call in Core-Telecom: $result") + } else { + isCallAnsweredOrActive = true + Log.d(TAG, "Call activated successfully in Core-Telecom") + } + } + private fun prepareCallRegistration(roomToken: String, callTypeLabel: String): String? { if (roomToken.isBlank()) { Log.e(TAG, "Cannot add $callTypeLabel call: roomToken is blank") @@ -202,7 +239,10 @@ class TelecomManager @Inject constructor(private val context: Context) { currentCallControlScope = null currentRoomToken = null currentRegistrationId = null + currentDisconnectHandler = null isPendingSetActive = false + isIncomingCall = false + isCallAnsweredOrActive = false } } @@ -215,7 +255,7 @@ class TelecomManager @Inject constructor(private val context: Context) { currentCallControlScope = this Log.d(TAG, "Call registered in CallControlScope for room $roomToken (id=$registrationId)") if (autoActivate || isPendingSetActive) { - launch { setActive() } + launch { activateScope(this@onScopeAssigned) } isPendingSetActive = false Log.d(TAG, "Call set active for room $roomToken (id=$registrationId)") } diff --git a/app/src/main/java/com/nextcloud/talk/callnotification/CallNotificationActivity.kt b/app/src/main/java/com/nextcloud/talk/callnotification/CallNotificationActivity.kt index 78c273d3991..7e520f44f8d 100644 --- a/app/src/main/java/com/nextcloud/talk/callnotification/CallNotificationActivity.kt +++ b/app/src/main/java/com/nextcloud/talk/callnotification/CallNotificationActivity.kt @@ -38,6 +38,7 @@ import com.nextcloud.talk.utils.bundle.BundleKeys import com.nextcloud.talk.utils.bundle.BundleKeys.KEY_CALL_VOICE_ONLY import com.nextcloud.talk.utils.bundle.BundleKeys.KEY_ROOM_ONE_TO_ONE import com.nextcloud.talk.utils.bundle.BundleKeys.KEY_ROOM_TOKEN +import com.nextcloud.talk.receivers.DeclineCallReceiver import okhttp3.Cache import java.io.IOException import javax.inject.Inject @@ -82,26 +83,6 @@ class CallNotificationActivity : CallBaseActivity() { setupAvatar(isOneToOneCall, conversationName) initClickListeners() setupNotificationCanceledRoutine() - if (savedInstanceState == null && !roomToken.isNullOrBlank()) { - telecomManager.addIncomingCall( - displayName = displayName ?: "Nextcloud Talk Call", - roomToken = roomToken!!, - isVideo = isInCallWithVideo(callFlag), - onAnswerCall = { - runOnUiThread { - intent.putExtra(KEY_CALL_VOICE_ONLY, !isInCallWithVideo(callFlag)) - proceedToCall() - } - }, - onRejectCall = { - runOnUiThread { - if (!leavingScreen && !isFinishing && !isDestroyed) { - hangup() - } - } - } - ) - } } private fun handleExtras() { @@ -183,6 +164,9 @@ class CallNotificationActivity : CallBaseActivity() { if (NotificationUtils.isNotificationVisible(context, notificationTimestamp!!.toInt())) { notificationHandler.postDelayed(this, ONE_SECOND) } else { + if (!leavingScreen) { + telecomManager.endCurrentCall(roomToken) + } finish() } } @@ -217,6 +201,10 @@ class CallNotificationActivity : CallBaseActivity() { private fun hangup() { leavingScreen = true + val declineIntent = Intent(this, DeclineCallReceiver::class.java).apply { + putExtra(BundleKeys.KEY_NOTIFICATION_TIMESTAMP, notificationTimestamp) + } + sendBroadcast(declineIntent) finish() } diff --git a/app/src/main/java/com/nextcloud/talk/dagger/modules/ManagerModule.kt b/app/src/main/java/com/nextcloud/talk/dagger/modules/ManagerModule.kt index 69009974a8e..cc7b14ff60b 100644 --- a/app/src/main/java/com/nextcloud/talk/dagger/modules/ManagerModule.kt +++ b/app/src/main/java/com/nextcloud/talk/dagger/modules/ManagerModule.kt @@ -8,7 +8,6 @@ package com.nextcloud.talk.dagger.modules import android.content.Context -import com.nextcloud.talk.call.TelecomManager import com.nextcloud.talk.chat.data.io.AudioFocusRequestManager import com.nextcloud.talk.chat.data.io.AudioRecorderManager import com.nextcloud.talk.chat.data.io.MediaPlayerManager @@ -16,7 +15,6 @@ import com.nextcloud.talk.chat.data.io.MediaRecorderManager import com.nextcloud.talk.utils.preferences.AppPreferences import dagger.Module import dagger.Provides -import javax.inject.Singleton @Module class ManagerModule { @@ -35,8 +33,4 @@ class ManagerModule { @Provides fun provideAudioFocusManager(context: Context): AudioFocusRequestManager = AudioFocusRequestManager(context) - - @Provides - @Singleton - fun provideTelecomManager(context: Context): TelecomManager = TelecomManager(context) } diff --git a/app/src/main/java/com/nextcloud/talk/jobs/NotificationWorker.kt b/app/src/main/java/com/nextcloud/talk/jobs/NotificationWorker.kt index 6cf809d80f0..9f8d9473247 100644 --- a/app/src/main/java/com/nextcloud/talk/jobs/NotificationWorker.kt +++ b/app/src/main/java/com/nextcloud/talk/jobs/NotificationWorker.kt @@ -55,6 +55,7 @@ import com.nextcloud.talk.api.NcApi import com.nextcloud.talk.application.NextcloudTalkApplication import com.nextcloud.talk.application.NextcloudTalkApplication.Companion.sharedApplication import com.nextcloud.talk.arbitrarystorage.ArbitraryStorageManager +import com.nextcloud.talk.call.TelecomManager import com.nextcloud.talk.callnotification.CallNotificationActivity import com.nextcloud.talk.chat.data.network.ChatNetworkDataSource import com.nextcloud.talk.conversationlist.DirectShareHelper @@ -127,11 +128,15 @@ import javax.inject.Inject @AutoInjector(NextcloudTalkApplication::class) class NotificationWorker(context: Context, workerParams: WorkerParameters) : Worker(context, workerParams) { - @Inject lateinit var logger: Logger + @Inject + lateinit var logger: Logger @Inject lateinit var appPreferences: AppPreferences + @Inject + lateinit var telecomManager: TelecomManager + @JvmField @Inject var arbitraryStorageManager: ArbitraryStorageManager? = null @@ -183,12 +188,15 @@ class NotificationWorker(context: Context, workerParams: WorkerParameters) : Wor if (pushMessage.delete) { cancelNotification(context, user, pushMessage.notificationId) + telecomManager.endCurrentCall(pushMessage.id) } else if (pushMessage.deleteAll) { cancelAllNotificationsForAccount(context, user) + telecomManager.endCurrentCall() } else if (pushMessage.deleteMultiple) { for (notificationId in pushMessage.notificationIds!!) { cancelNotification(context, user, notificationId) } + telecomManager.endCurrentCall(pushMessage.id) } else if (isTalkNotification()) { logger.d(TAG, "pushMessage.type: " + pushMessage.type) when (pushMessage.type) { @@ -258,6 +266,7 @@ class NotificationWorker(context: Context, workerParams: WorkerParameters) : Wor bundle.putString(KEY_ROOM_TOKEN, pushMessage.id) bundle.putInt(KEY_NOTIFICATION_TIMESTAMP, pushMessage.timestamp.toInt()) bundle.putLong(KEY_INTERNAL_USER_ID, user.id!!) + bundle.putString(BundleKeys.KEY_MODIFIED_BASE_URL, user.baseUrl) bundle.putBoolean(KEY_FROM_NOTIFICATION_START_CALL, true) val isOneToOneCall = conversation.type == ConversationEnums.ConversationType.ROOM_TYPE_ONE_TO_ONE_CALL @@ -337,6 +346,28 @@ class NotificationWorker(context: Context, workerParams: WorkerParameters) : Wor pendingIntentFlags ) + telecomManager.addIncomingCall( + displayName = conversation.displayName.ifEmpty { + applicationContext.getString(R.string.nc_app_product_name) + }, + roomToken = pushMessage.id ?: "", + isVideo = isVideoCall, + onAnswerCall = { + try { + primaryAnswerIntent.send() + } catch (e: PendingIntent.CanceledException) { + Log.e(TAG, "Answer pending intent canceled", e) + } + }, + onRejectCall = { + try { + declinePendingIntent.send() + } catch (e: PendingIntent.CanceledException) { + Log.e(TAG, "Decline pending intent canceled", e) + } + } + ) + val soundUri = getCallRingtoneUri(applicationContext, appPreferences) val notificationChannelId = NotificationUtils.NotificationChannels.NOTIFICATION_CHANNEL_CALLS_V5.name val uri = user.baseUrl!!.toUri() diff --git a/app/src/main/java/com/nextcloud/talk/receivers/DeclineCallReceiver.kt b/app/src/main/java/com/nextcloud/talk/receivers/DeclineCallReceiver.kt index e3e278611c4..72ab2931876 100644 --- a/app/src/main/java/com/nextcloud/talk/receivers/DeclineCallReceiver.kt +++ b/app/src/main/java/com/nextcloud/talk/receivers/DeclineCallReceiver.kt @@ -10,6 +10,7 @@ import android.content.BroadcastReceiver import android.content.Context import android.content.Intent import androidx.core.app.NotificationManagerCompat +import com.nextcloud.talk.application.NextcloudTalkApplication import com.nextcloud.talk.utils.bundle.BundleKeys.KEY_NOTIFICATION_TIMESTAMP class DeclineCallReceiver : BroadcastReceiver() { @@ -17,5 +18,6 @@ class DeclineCallReceiver : BroadcastReceiver() { override fun onReceive(context: Context, intent: Intent) { val notificationId = intent.getIntExtra(KEY_NOTIFICATION_TIMESTAMP, 0) NotificationManagerCompat.from(context).cancel(notificationId) + NextcloudTalkApplication.sharedApplication?.telecomManager?.endCurrentCall() } } diff --git a/app/src/test/java/com/nextcloud/talk/call/TelecomManagerTest.kt b/app/src/test/java/com/nextcloud/talk/call/TelecomManagerTest.kt index 488c9903088..358aebdecdd 100644 --- a/app/src/test/java/com/nextcloud/talk/call/TelecomManagerTest.kt +++ b/app/src/test/java/com/nextcloud/talk/call/TelecomManagerTest.kt @@ -206,4 +206,118 @@ class TelecomManagerTest { telecomManager.setCallActive("roomA") assertTrue(telecomManager.hasActiveCall("roomA")) } + + @Test + fun testSetCallActiveUpdatesDisconnectHandler() { + var initialCallbackFired = false + + telecomManager.addIncomingCall( + displayName = "User 1", + roomToken = "roomA", + isVideo = false, + onAnswerCall = {}, + onRejectCall = { initialCallbackFired = true } + ) + + // CallActivity adopts the incoming call and passes its own disconnect handler + telecomManager.setCallActive("roomA", onDisconnectCall = {}) + + assertTrue(telecomManager.hasActiveCall("roomA")) + + // End current call + telecomManager.endCurrentCall("roomA") + + // Active call registration is now cleared + assertFalse(telecomManager.hasActiveCall("roomA")) + assertFalse(initialCallbackFired) + } + + @Test + fun testEndCurrentCallClearsRegistrationAndAllowsSubsequentCall() { + telecomManager.addIncomingCall( + displayName = "User 1", + roomToken = "roomA", + isVideo = false, + onAnswerCall = {}, + onRejectCall = {} + ) + + assertTrue(telecomManager.hasActiveCall("roomA")) + + // When call is declined/ended, currentRoomToken is cleared + telecomManager.endCurrentCall("roomA") + assertFalse(telecomManager.hasActiveCall("roomA")) + + // A new call for roomB can now be registered successfully + telecomManager.addIncomingCall( + displayName = "User 2", + roomToken = "roomB", + isVideo = false, + onAnswerCall = {}, + onRejectCall = {} + ) + + assertTrue(telecomManager.hasActiveCall("roomB")) + } + + @Test + fun testEndCurrentCallIgnoresNullOrBlankRoomTokenWhenCallIsActive() { + telecomManager.addOutgoingCall( + displayName = "User 1", + roomToken = "roomA", + isVideo = false + ) + + assertTrue(telecomManager.hasActiveCall("roomA")) + + // Null or blank room tokens must NOT disconnect an active answered/outgoing call + telecomManager.endCurrentCall(null) + assertTrue(telecomManager.hasActiveCall("roomA")) + + telecomManager.endCurrentCall("") + assertTrue(telecomManager.hasActiveCall("roomA")) + + // Explicit matching room token successfully ends the call + telecomManager.endCurrentCall("roomA") + assertFalse(telecomManager.hasActiveCall("roomA")) + } + + @Test + fun testEndCurrentCallDisconnectsUnansweredRingingCallWithNullOrBlankToken() { + telecomManager.addIncomingCall( + displayName = "User 1", + roomToken = "roomA", + isVideo = false, + onAnswerCall = {}, + onRejectCall = {} + ) + + assertTrue(telecomManager.hasActiveCall("roomA")) + + // When a caller cancels before answer (null roomToken in push delete message), + // unanswered ringing call is cleanly terminated + telecomManager.endCurrentCall(null) + assertFalse(telecomManager.hasActiveCall("roomA")) + } + + @Test + fun testOnAnswerClearsIncomingFlagToPreventDuplicateAnswerCalls() { + telecomManager.addIncomingCall( + displayName = "User 1", + roomToken = "roomA", + isVideo = false, + onAnswerCall = {}, + onRejectCall = {} + ) + + assertTrue(telecomManager.hasActiveCall("roomA")) + + // Set call active after answering + telecomManager.setCallActive("roomA") + assertTrue(telecomManager.hasActiveCall("roomA")) + + // Ensure active call remains valid + telecomManager.endCurrentCall("roomA") + assertFalse(telecomManager.hasActiveCall("roomA")) + } }