diff --git a/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/notifications/RingingCallNotificationCreator.kt b/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/notifications/RingingCallNotificationCreator.kt index 647f5ebcfc..78b08e4468 100644 --- a/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/notifications/RingingCallNotificationCreator.kt +++ b/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/notifications/RingingCallNotificationCreator.kt @@ -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, diff --git a/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/ui/IncomingCallActivity.kt b/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/ui/IncomingCallActivity.kt index 1d6989fb3c..a4bb9eef31 100644 --- a/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/ui/IncomingCallActivity.kt +++ b/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/ui/IncomingCallActivity.kt @@ -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( diff --git a/features/call/impl/src/test/kotlin/io/element/android/features/call/notifications/RingingCallNotificationCreatorTest.kt b/features/call/impl/src/test/kotlin/io/element/android/features/call/notifications/RingingCallNotificationCreatorTest.kt index 3f5c1c4e50..3c072c5958 100644 --- a/features/call/impl/src/test/kotlin/io/element/android/features/call/notifications/RingingCallNotificationCreatorTest.kt +++ b/features/call/impl/src/test/kotlin/io/element/android/features/call/notifications/RingingCallNotificationCreatorTest.kt @@ -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,