mirror of
https://github.com/Novaloop-AG/element-x-android.git
synced 2026-11-03 06:55:41 +00:00
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:
+24
-10
@@ -18,10 +18,8 @@ import androidx.core.app.PendingIntentCompat
|
|||||||
import androidx.core.app.Person
|
import androidx.core.app.Person
|
||||||
import dev.zacsweers.metro.Inject
|
import dev.zacsweers.metro.Inject
|
||||||
import io.element.android.appconfig.ElementCallConfig
|
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.receivers.DeclineCallBroadcastReceiver
|
||||||
import io.element.android.features.call.impl.ui.IncomingCallActivity
|
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.AvatarData
|
||||||
import io.element.android.libraries.designsystem.components.avatar.AvatarSize
|
import io.element.android.libraries.designsystem.components.avatar.AvatarSize
|
||||||
import io.element.android.libraries.designsystem.utils.CommonDrawables
|
import io.element.android.libraries.designsystem.utils.CommonDrawables
|
||||||
@@ -55,6 +53,15 @@ class RingingCallNotificationCreator(
|
|||||||
* Request code for the full screen intent.
|
* Request code for the full screen intent.
|
||||||
*/
|
*/
|
||||||
const val FULL_SCREEN_INTENT_REQUEST_CODE = 2
|
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(
|
suspend fun createNotification(
|
||||||
@@ -89,14 +96,6 @@ class RingingCallNotificationCreator(
|
|||||||
.setImportant(true)
|
.setImportant(true)
|
||||||
.build()
|
.build()
|
||||||
|
|
||||||
val answerIntent = IntentProvider.getPendingIntent(
|
|
||||||
context,
|
|
||||||
CallData(
|
|
||||||
sessionId = sessionId,
|
|
||||||
roomId = roomId,
|
|
||||||
isAudioCall = audioOnly,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
val notificationData = CallNotificationData(
|
val notificationData = CallNotificationData(
|
||||||
sessionId = sessionId,
|
sessionId = sessionId,
|
||||||
roomId = roomId,
|
roomId = roomId,
|
||||||
@@ -112,6 +111,21 @@ class RingingCallNotificationCreator(
|
|||||||
audioOnly = audioOnly,
|
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(
|
val declineIntent = PendingIntentCompat.getBroadcast(
|
||||||
context,
|
context,
|
||||||
DECLINE_REQUEST_CODE,
|
DECLINE_REQUEST_CODE,
|
||||||
|
|||||||
+42
@@ -8,6 +8,7 @@
|
|||||||
|
|
||||||
package io.element.android.features.call.impl.ui
|
package io.element.android.features.call.impl.ui
|
||||||
|
|
||||||
|
import android.content.Intent
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
import android.view.WindowManager
|
import android.view.WindowManager
|
||||||
import androidx.activity.compose.setContent
|
import androidx.activity.compose.setContent
|
||||||
@@ -47,6 +48,15 @@ class IncomingCallActivity : AppCompatActivity() {
|
|||||||
* Extra key for the notification data.
|
* Extra key for the notification data.
|
||||||
*/
|
*/
|
||||||
const val EXTRA_NOTIFICATION_DATA = "EXTRA_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
|
@Inject
|
||||||
@@ -85,6 +95,7 @@ class IncomingCallActivity : AppCompatActivity() {
|
|||||||
)
|
)
|
||||||
|
|
||||||
val notificationData = intent?.let { IntentCompat.getParcelableExtra(it, EXTRA_NOTIFICATION_DATA, CallNotificationData::class.java) }
|
val notificationData = intent?.let { IntentCompat.getParcelableExtra(it, EXTRA_NOTIFICATION_DATA, CallNotificationData::class.java) }
|
||||||
|
if (answerImmediatelyIfAsked(intent)) return
|
||||||
if (notificationData != null) {
|
if (notificationData != null) {
|
||||||
setContent {
|
setContent {
|
||||||
val colors by remember {
|
val colors by remember {
|
||||||
@@ -116,6 +127,37 @@ class IncomingCallActivity : AppCompatActivity() {
|
|||||||
.launchIn(lifecycleScope)
|
.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) {
|
private fun onAnswer(notificationData: CallNotificationData) {
|
||||||
elementCallEntryPoint.startCall(
|
elementCallEntryPoint.startCall(
|
||||||
CallData(
|
CallData(
|
||||||
|
|||||||
+31
@@ -13,6 +13,7 @@ import androidx.test.platform.app.InstrumentationRegistry
|
|||||||
import coil3.ImageLoader
|
import coil3.ImageLoader
|
||||||
import com.google.common.truth.Truth.assertThat
|
import com.google.common.truth.Truth.assertThat
|
||||||
import io.element.android.features.call.impl.notifications.RingingCallNotificationCreator
|
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.designsystem.components.avatar.AvatarData
|
||||||
import io.element.android.libraries.matrix.test.AN_EVENT_ID
|
import io.element.android.libraries.matrix.test.AN_EVENT_ID
|
||||||
import io.element.android.libraries.matrix.test.A_ROOM_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 io.element.android.tests.testutils.robolectric.RobolectricTest
|
||||||
import kotlinx.coroutines.test.runTest
|
import kotlinx.coroutines.test.runTest
|
||||||
import org.junit.Test
|
import org.junit.Test
|
||||||
|
import org.robolectric.Shadows.shadowOf
|
||||||
|
|
||||||
class RingingCallNotificationCreatorTest : RobolectricTest() {
|
class RingingCallNotificationCreatorTest : RobolectricTest() {
|
||||||
@Test
|
@Test
|
||||||
@@ -89,6 +91,35 @@ class RingingCallNotificationCreatorTest : RobolectricTest() {
|
|||||||
assertThat(acceptAction?.title?.toString()).isEqualTo("Answer")
|
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(
|
private suspend fun RingingCallNotificationCreator.createTestNotification(audioOnly: Boolean = false) = createNotification(
|
||||||
sessionId = A_SESSION_ID,
|
sessionId = A_SESSION_ID,
|
||||||
roomId = A_ROOM_ID,
|
roomId = A_ROOM_ID,
|
||||||
|
|||||||
Reference in New Issue
Block a user