Answer a ringing call through the call entry point

The notification's Answer action named ElementCallActivity outright, so answering always opened the WebView whatever FeatureFlags.NativeCall was set to.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Valere
2026-09-22 21:04:06 +02:00
committed by Valere Fedronic
parent 75605f1932
commit e35b522a9a
3 changed files with 97 additions and 10 deletions
@@ -18,10 +18,8 @@ import androidx.core.app.PendingIntentCompat
import androidx.core.app.Person
import dev.zacsweers.metro.Inject
import io.element.android.appconfig.ElementCallConfig
import io.element.android.features.call.api.CallData
import io.element.android.features.call.impl.receivers.DeclineCallBroadcastReceiver
import io.element.android.features.call.impl.ui.IncomingCallActivity
import io.element.android.features.call.impl.utils.IntentProvider
import io.element.android.libraries.designsystem.components.avatar.AvatarData
import io.element.android.libraries.designsystem.components.avatar.AvatarSize
import io.element.android.libraries.designsystem.utils.CommonDrawables
@@ -55,6 +53,15 @@ class RingingCallNotificationCreator(
* Request code for the full screen intent.
*/
const val FULL_SCREEN_INTENT_REQUEST_CODE = 2
/**
* Request code for the answer action.
*
* Distinct from [FULL_SCREEN_INTENT_REQUEST_CODE] even though both now target
* `IncomingCallActivity`: they carry different extras, and a shared request code would let
* `FLAG_CANCEL_CURRENT` on one replace the other.
*/
const val ANSWER_REQUEST_CODE = 3
}
suspend fun createNotification(
@@ -89,14 +96,6 @@ class RingingCallNotificationCreator(
.setImportant(true)
.build()
val answerIntent = IntentProvider.getPendingIntent(
context,
CallData(
sessionId = sessionId,
roomId = roomId,
isAudioCall = audioOnly,
),
)
val notificationData = CallNotificationData(
sessionId = sessionId,
roomId = roomId,
@@ -112,6 +111,21 @@ class RingingCallNotificationCreator(
audioOnly = audioOnly,
)
// Through IncomingCallActivity rather than straight to ElementCallActivity, so that
// answering goes through ElementCallEntryPoint and lands in whichever call stack the
// NativeCall flag selects. Naming the WebView activity here meant the notification's Answer
// button always opened the WebView, even with native calls turned on.
val answerIntent = PendingIntentCompat.getActivity(
context,
ANSWER_REQUEST_CODE,
Intent(context, IncomingCallActivity::class.java).apply {
putExtra(IncomingCallActivity.EXTRA_NOTIFICATION_DATA, notificationData)
putExtra(IncomingCallActivity.EXTRA_ANSWER_IMMEDIATELY, true)
},
PendingIntent.FLAG_CANCEL_CURRENT,
false,
)!!
val declineIntent = PendingIntentCompat.getBroadcast(
context,
DECLINE_REQUEST_CODE,
@@ -8,6 +8,7 @@
package io.element.android.features.call.impl.ui
import android.content.Intent
import android.os.Bundle
import android.view.WindowManager
import androidx.activity.compose.setContent
@@ -47,6 +48,15 @@ class IncomingCallActivity : AppCompatActivity() {
* Extra key for the notification data.
*/
const val EXTRA_NOTIFICATION_DATA = "EXTRA_NOTIFICATION_DATA"
/**
* Answer straight away rather than showing the incoming-call screen.
*
* Set by the notification's Answer action, which has already asked the user - showing them
* the same question again would be the bug. The screen is for the full-screen intent, where
* the user has not answered anything yet.
*/
const val EXTRA_ANSWER_IMMEDIATELY = "EXTRA_ANSWER_IMMEDIATELY"
}
@Inject
@@ -85,6 +95,7 @@ class IncomingCallActivity : AppCompatActivity() {
)
val notificationData = intent?.let { IntentCompat.getParcelableExtra(it, EXTRA_NOTIFICATION_DATA, CallNotificationData::class.java) }
if (answerImmediatelyIfAsked(intent)) return
if (notificationData != null) {
setContent {
val colors by remember {
@@ -116,6 +127,37 @@ class IncomingCallActivity : AppCompatActivity() {
.launchIn(lifecycleScope)
}
/**
* The Answer action can arrive while this activity is already showing the ringing screen, because
* the full screen intent put it there and it is `singleTask`. That delivers a new intent instead
* of recreating the activity, so the answer has to be handled here as well as in [onCreate] - and
* [setIntent] has to run first, or everything reading `intent` afterwards would still see the one
* the activity was created with.
*/
override fun onNewIntent(intent: Intent) {
super.onNewIntent(intent)
setIntent(intent)
answerImmediatelyIfAsked(intent)
}
/**
* Answers and finishes when the intent is the notification's Answer action, and reports whether
* it did.
*
* Answering goes through this activity rather than straight to a call screen so that it goes
* through `ElementCallEntryPoint`, which is what decides between the WebView and the native
* stack. The notification action used to name `ElementCallActivity` outright, so answering from
* the notification always opened the WebView however the flag was set.
*/
private fun answerImmediatelyIfAsked(intent: Intent?): Boolean {
if (intent == null || !intent.getBooleanExtra(EXTRA_ANSWER_IMMEDIATELY, false)) return false
val notificationData = IntentCompat.getParcelableExtra(intent, EXTRA_NOTIFICATION_DATA, CallNotificationData::class.java)
?: return false
onAnswer(notificationData)
finish()
return true
}
private fun onAnswer(notificationData: CallNotificationData) {
elementCallEntryPoint.startCall(
CallData(
@@ -13,6 +13,7 @@ import androidx.test.platform.app.InstrumentationRegistry
import coil3.ImageLoader
import com.google.common.truth.Truth.assertThat
import io.element.android.features.call.impl.notifications.RingingCallNotificationCreator
import io.element.android.features.call.impl.ui.IncomingCallActivity
import io.element.android.libraries.designsystem.components.avatar.AvatarData
import io.element.android.libraries.matrix.test.AN_EVENT_ID
import io.element.android.libraries.matrix.test.A_ROOM_ID
@@ -26,6 +27,7 @@ import io.element.android.tests.testutils.lambda.lambdaRecorder
import io.element.android.tests.testutils.robolectric.RobolectricTest
import kotlinx.coroutines.test.runTest
import org.junit.Test
import org.robolectric.Shadows.shadowOf
class RingingCallNotificationCreatorTest : RobolectricTest() {
@Test
@@ -89,6 +91,35 @@ class RingingCallNotificationCreatorTest : RobolectricTest() {
assertThat(acceptAction?.title?.toString()).isEqualTo("Answer")
}
@Test
fun `createNotification - the answer action goes through IncomingCallActivity, asking it to answer straight away`() = runTest {
val notificationCreator = createRingingCallNotificationCreator(
matrixClientProvider = FakeMatrixClientProvider(getClient = { Result.success(FakeMatrixClient()) }),
)
val notification = notificationCreator.createTestNotification()
// Not ElementCallActivity: naming the WebView activity here made the Answer button open the
// WebView call whatever FeatureFlags.NativeCall was set to.
val answerIntent = shadowOf(notification?.actions?.get(1)?.actionIntent).savedIntent
assertThat(answerIntent.component?.className).isEqualTo(IncomingCallActivity::class.java.name)
assertThat(answerIntent.getBooleanExtra(IncomingCallActivity.EXTRA_ANSWER_IMMEDIATELY, false)).isTrue()
assertThat(answerIntent.hasExtra(IncomingCallActivity.EXTRA_NOTIFICATION_DATA)).isTrue()
}
@Test
fun `createNotification - tapping the notification body answers too`() = runTest {
val notificationCreator = createRingingCallNotificationCreator(
matrixClientProvider = FakeMatrixClientProvider(getClient = { Result.success(FakeMatrixClient()) }),
)
val notification = notificationCreator.createTestNotification()
val contentIntent = shadowOf(notification?.contentIntent).savedIntent
assertThat(contentIntent.component?.className).isEqualTo(IncomingCallActivity::class.java.name)
assertThat(contentIntent.getBooleanExtra(IncomingCallActivity.EXTRA_ANSWER_IMMEDIATELY, false)).isTrue()
}
private suspend fun RingingCallNotificationCreator.createTestNotification(audioOnly: Boolean = false) = createNotification(
sessionId = A_SESSION_ID,
roomId = A_ROOM_ID,