diff --git a/app/build.gradle.kts b/app/build.gradle.kts
index 561b0fa0404..e8677b52c5d 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.1")
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 4c27ccc2c81..e63958f10f5 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 4b741b67544..66f42cfdd4c 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
@@ -1602,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()
@@ -2218,6 +2223,7 @@ class CallActivity : CallBaseActivity() {
private fun hangup(shutDownView: Boolean, endCallForAll: Boolean) {
Log.d(TAG, "hangup! shutDownView=$shutDownView, endCallForAll=$endCallForAll")
+
joinRoomInitiated = false
if (shutDownView) {
setCallState(CallStatus.LEAVING)
@@ -2228,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)
@@ -2259,6 +2269,9 @@ class CallActivity : CallBaseActivity() {
}
hangupNetworkCalls(shutDownView, endCallForAll)
+ if (shutDownView) {
+ telecomManager.endCurrentCall(roomToken)
+ }
}
private fun terminateAudioVideo() {
@@ -2298,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)
}
}
@@ -2348,7 +2363,7 @@ class CallActivity : CallBaseActivity() {
startActivity(intent)
finish()
} else if (shutDownView) {
- finish()
+ finishAndRemoveTask()
} else if (currentCallStatus === CallStatus.RECONNECTING ||
currentCallStatus === CallStatus.PUBLISHER_FAILED
) {
@@ -2357,52 +2372,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) {
@@ -2947,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
@@ -2964,6 +3006,7 @@ class CallActivity : CallBaseActivity() {
}
private fun handleCallStateJoined() {
+ activateTelecomCall()
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 2b592961139..31da435fe51 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
@@ -124,6 +125,9 @@ class NextcloudTalkApplication :
lateinit var logger: LoggerImpl
//endregion
+ @Inject
+ lateinit var telecomManager: TelecomManager
+
//region private methods
private fun installExceptionHandler() {
val defaultHandler = Thread.getDefaultUncaughtExceptionHandler()
@@ -215,11 +219,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..5271c5df4da
--- /dev/null
+++ b/app/src/main/java/com/nextcloud/talk/call/TelecomManager.kt
@@ -0,0 +1,278 @@
+/*
+ * 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.annotation.VisibleForTesting
+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 java.util.UUID
+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)
+
+ @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) {
+ 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(roomToken: String? = null): Boolean {
+ if (roomToken.isNullOrBlank()) {
+ return currentRoomToken != null || currentCallControlScope != null
+ }
+ return currentRoomToken == roomToken
+ }
+
+ fun addIncomingCall(
+ displayName: String,
+ roomToken: String,
+ isVideo: Boolean,
+ onAnswerCall: () -> Unit,
+ onRejectCall: () -> Unit
+ ) {
+ val registrationId = prepareCallRegistration(roomToken, "Incoming") ?: return
+ currentDisconnectHandler = onRejectCall
+ isIncomingCall = true
+ isCallAnsweredOrActive = false
+ currentCallType = getCallType(isVideo)
+
+ 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 = { _ ->
+ isIncomingCall = false
+ isCallAnsweredOrActive = true
+ onAnswerCall()
+ },
+ onDisconnect = { _ ->
+ val handlerToInvoke = currentDisconnectHandler
+ handleCallDisconnected(registrationId)
+ handlerToInvoke?.invoke()
+ },
+ onSetActive = { },
+ onSetInactive = { }
+ ) {
+ onScopeAssigned(registrationId, roomToken)
+ }
+ } catch (e: Exception) {
+ Log.e(TAG, "Error adding incoming call to Telecom for room $roomToken", e)
+ } finally {
+ handleCallDisconnected(registrationId)
+ }
+ }
+ }
+
+ fun addOutgoingCall(
+ displayName: String,
+ roomToken: String,
+ isVideo: Boolean,
+ onDisconnectCall: (() -> Unit)? = null
+ ) {
+ val registrationId = prepareCallRegistration(roomToken, "Outgoing") ?: return
+ currentDisconnectHandler = onDisconnectCall
+ isIncomingCall = false
+ isCallAnsweredOrActive = true
+ currentCallType = getCallType(isVideo)
+
+ 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 = { },
+ onDisconnect = { _ ->
+ val handlerToInvoke = currentDisconnectHandler
+ handleCallDisconnected(registrationId)
+ handlerToInvoke?.invoke()
+ },
+ onSetActive = { },
+ onSetInactive = { }
+ ) {
+ onScopeAssigned(registrationId, roomToken, autoActivate = true)
+ }
+ } catch (e: Exception) {
+ Log.e(TAG, "Error adding outgoing call to Telecom for room $roomToken", e)
+ } finally {
+ handleCallDisconnected(registrationId)
+ }
+ }
+ }
+
+ fun endCurrentCall(roomToken: String? = null) {
+ 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
+ handleCallDisconnected(registrationIdToClear ?: "")
+
+ telecomScope.launch {
+ scopeToDisconnect?.disconnect(DisconnectCause(DisconnectCause.LOCAL))
+ }
+ }
+
+ 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 {
+ activateScope(scope)
+ }
+ } else if (currentRoomToken != null) {
+ isPendingSetActive = true
+ Log.d(TAG, "setCallActive queued: registration pending for room $currentRoomToken")
+ }
+ }
+
+ 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")
+ return null
+ }
+ if (currentRoomToken == roomToken || currentCallControlScope != null) {
+ Log.d(TAG, "$callTypeLabel call ignored: registration or scope already active for room $currentRoomToken")
+ return null
+ }
+ if (currentRoomToken != null) {
+ Log.d(TAG, "$callTypeLabel call ignored: active call already exists for room $currentRoomToken")
+ return null
+ }
+ val registrationId = UUID.randomUUID().toString()
+ currentRegistrationId = registrationId
+ currentRoomToken = roomToken
+ isPendingSetActive = false
+ return registrationId
+ }
+
+ private fun handleCallDisconnected(registrationId: String) {
+ if (registrationId.isNotEmpty() && currentRegistrationId == registrationId) {
+ currentCallControlScope = null
+ currentRoomToken = null
+ currentRegistrationId = null
+ currentDisconnectHandler = null
+ isPendingSetActive = false
+ isIncomingCall = false
+ isCallAnsweredOrActive = false
+ }
+ }
+
+ 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 (id=$registrationId)")
+ if (autoActivate || isPendingSetActive) {
+ launch { activateScope(this@onScopeAssigned) }
+ isPendingSetActive = false
+ Log.d(TAG, "Call set active for room $roomToken (id=$registrationId)")
+ }
+ } else {
+ Log.w(
+ TAG,
+ "CallControlScope obtained for stale registration (id=$registrationId, room=$roomToken), disconnecting"
+ )
+ launch { disconnect(DisconnectCause(DisconnectCause.REJECTED)) }
+ }
+ }
+
+ @VisibleForTesting
+ internal 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..7e520f44f8d 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
@@ -37,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
@@ -52,6 +54,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
@@ -160,6 +164,9 @@ class CallNotificationActivity : CallBaseActivity() {
if (NotificationUtils.isNotificationVisible(context, notificationTimestamp!!.toInt())) {
notificationHandler.postDelayed(this, ONE_SECOND)
} else {
+ if (!leavingScreen) {
+ telecomManager.endCurrentCall(roomToken)
+ }
finish()
}
}
@@ -194,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/jobs/NotificationWorker.kt b/app/src/main/java/com/nextcloud/talk/jobs/NotificationWorker.kt
index 56fba0998e4..e914b8f87e0 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/main/java/com/nextcloud/talk/services/CallForegroundService.kt b/app/src/main/java/com/nextcloud/talk/services/CallForegroundService.kt
index 1d804cc7742..f9c4f72eecd 100644
--- a/app/src/main/java/com/nextcloud/talk/services/CallForegroundService.kt
+++ b/app/src/main/java/com/nextcloud/talk/services/CallForegroundService.kt
@@ -183,7 +183,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 ->
@@ -302,6 +302,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,
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..358aebdecdd
--- /dev/null
+++ b/app/src/test/java/com/nextcloud/talk/call/TelecomManagerTest.kt
@@ -0,0 +1,323 @@
+/*
+ * 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.Assert.assertTrue
+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)
+ `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)
+ `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("Unsupported"))
+
+ // 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("Unsupported"))
+
+ // Should catch any exception thrown by CallsManager without crashing
+ telecomManager.addOutgoingCall(
+ displayName = "Test User",
+ roomToken = "token123",
+ 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"))
+ }
+
+ @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"))
+ }
+}