From 75605f19328532fd5c2ded37031f3118280d6fd5 Mon Sep 17 00:00:00 2001 From: Valere Date: Tue, 22 Sep 2026 21:04:06 +0200 Subject: [PATCH] Make a call with no UI actually run, and cover it with tests Found on a device: with no UI to answer the microphone permission or unset the maximized snapshot, the call parks forever and blanks the screen through the proximity wake lock. Co-Authored-By: Claude Opus 5 (1M context) --- .../callnative/api/NativeCallEntryPoint.kt | 2 +- .../impl/DefaultNativeCallEntryPoint.kt | 54 ++++++++-- .../impl/DefaultNativeCallEntryPointTest.kt | 101 ++++++++++++++++++ .../impl/FakeElementCallControllers.kt | 29 +++++ 4 files changed, 178 insertions(+), 8 deletions(-) create mode 100644 features/callnative/impl/src/test/kotlin/io/element/android/features/callnative/impl/DefaultNativeCallEntryPointTest.kt create mode 100644 features/callnative/impl/src/test/kotlin/io/element/android/features/callnative/impl/FakeElementCallControllers.kt diff --git a/features/callnative/api/src/main/kotlin/io/element/android/features/callnative/api/NativeCallEntryPoint.kt b/features/callnative/api/src/main/kotlin/io/element/android/features/callnative/api/NativeCallEntryPoint.kt index a964c1502a..486a0b14c1 100644 --- a/features/callnative/api/src/main/kotlin/io/element/android/features/callnative/api/NativeCallEntryPoint.kt +++ b/features/callnative/api/src/main/kotlin/io/element/android/features/callnative/api/NativeCallEntryPoint.kt @@ -19,6 +19,6 @@ import io.element.android.features.call.api.CallData * There is nothing behind this seam yet - the default implementation does nothing and says so. The * native stack that implements it is a separate library, added in a follow-up. */ -interface NativeCallEntryPoint { +fun interface NativeCallEntryPoint { fun startCall(callData: CallData) } diff --git a/features/callnative/impl/src/main/kotlin/io/element/android/features/callnative/impl/DefaultNativeCallEntryPoint.kt b/features/callnative/impl/src/main/kotlin/io/element/android/features/callnative/impl/DefaultNativeCallEntryPoint.kt index ecfdafd76f..4eb5b69418 100644 --- a/features/callnative/impl/src/main/kotlin/io/element/android/features/callnative/impl/DefaultNativeCallEntryPoint.kt +++ b/features/callnative/impl/src/main/kotlin/io/element/android/features/callnative/impl/DefaultNativeCallEntryPoint.kt @@ -7,23 +7,63 @@ package io.element.android.features.callnative.impl +import android.Manifest +import android.content.Context +import android.content.pm.PackageManager +import androidx.core.content.ContextCompat import dev.zacsweers.metro.AppScope import dev.zacsweers.metro.ContributesBinding +import io.element.android.call.api.ElementCallData +import io.element.android.call.api.rtc.id.RoomId import io.element.android.features.call.api.CallData import io.element.android.features.callnative.api.NativeCallEntryPoint +import io.element.android.libraries.di.annotations.AppCoroutineScope +import io.element.android.libraries.di.annotations.ApplicationContext +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.launch import timber.log.Timber /** - * What answers [NativeCallEntryPoint] until the native MatrixRTC stack is wired in: nothing. + * Starts a call on the native MatrixRTC stack. * - * The seam exists so that `DefaultElementCallEntryPoint` can fork on `FeatureFlags.NativeCall` today, - * and so that adding the stack later is a change to this module alone. A user who turns the flag on - * before then gets no call and a line in the log, which is the honest outcome - falling back to the - * WebView would make the flag look broken rather than unimplemented. + * App-scoped, while the stack it drives is per session: the session comes from the call rather than + * from whatever is on screen, so answering a notification for a second account reaches that account's + * stack. */ @ContributesBinding(AppScope::class) -class DefaultNativeCallEntryPoint : NativeCallEntryPoint { +class DefaultNativeCallEntryPoint( + @ApplicationContext private val context: Context, + private val controllers: ElementCallControllers, + @AppCoroutineScope private val appCoroutineScope: CoroutineScope, +) : NativeCallEntryPoint { override fun startCall(callData: CallData) { - Timber.w("Native call requested for ${callData.roomId} but no native call stack is present in this build") + // The call does nothing past "requested" until the microphone permission is answered, and + // only an Activity can ask for one. Until the call has a UI of its own, the only answer + // available is the permission the app already holds - so check it before starting rather + // than after. Starting a call nobody can answer leaves it running and refusing every later + // attempt for the rest of the session, with nothing on screen to hang it up. + if (!hasMicrophonePermission()) { + Timber.w("NativeCall: RECORD_AUDIO is not granted and there is no call UI to ask for it, not starting the call") + return + } + appCoroutineScope.launch { + val controller = controllers.getOrBuild(callData.sessionId) ?: return@launch + controller.startCall( + ElementCallData( + roomId = RoomId(callData.roomId.value), + isAudioCall = callData.isAudioCall, + ) + ) + controller.setMicrophonePermissionGranted(true) + // A call with no UI is never the screen the user is looking at, and the snapshot assumes + // it is. Saying so matters for more than tidiness: the component holds a proximity wake + // lock while a maximized audio call is on screen, so leaving the default in place blanks + // the display whenever a hand goes near the top of the phone - including when reaching + // for the notification shade, which is the only way to hang this call up. + controller.setMaximized(false) + } } + + private fun hasMicrophonePermission(): Boolean = + ContextCompat.checkSelfPermission(context, Manifest.permission.RECORD_AUDIO) == PackageManager.PERMISSION_GRANTED } diff --git a/features/callnative/impl/src/test/kotlin/io/element/android/features/callnative/impl/DefaultNativeCallEntryPointTest.kt b/features/callnative/impl/src/test/kotlin/io/element/android/features/callnative/impl/DefaultNativeCallEntryPointTest.kt new file mode 100644 index 0000000000..5afbda0e5b --- /dev/null +++ b/features/callnative/impl/src/test/kotlin/io/element/android/features/callnative/impl/DefaultNativeCallEntryPointTest.kt @@ -0,0 +1,101 @@ +/* + * Copyright (c) 2026 Element Creations Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.callnative.impl + +import android.Manifest +import android.app.Application +import androidx.test.platform.app.InstrumentationRegistry +import com.google.common.truth.Truth.assertThat +import io.element.android.call.test.FakeElementCallController +import io.element.android.features.call.api.CallData +import io.element.android.libraries.matrix.test.A_ROOM_ID +import io.element.android.libraries.matrix.test.A_SESSION_ID +import io.element.android.tests.testutils.robolectric.RobolectricTest +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.test.runTest +import org.junit.Test +import org.robolectric.RuntimeEnvironment +import org.robolectric.Shadows.shadowOf + +@OptIn(ExperimentalCoroutinesApi::class) +class DefaultNativeCallEntryPointTest : RobolectricTest() { + @Test + fun `starting a call answers the microphone permission the app already holds`() = runTest { + grantMicrophonePermission() + val controller = FakeElementCallController() + val entryPoint = createEntryPoint(controller) + + entryPoint.startCall(A_CALL_DATA) + runCurrent() + + assertThat(controller.startedCalls.map { it.roomId.value }).containsExactly(A_ROOM_ID.value) + // Without this the call parks at RequestingPermission forever, and because a call is then + // "running" every later attempt is refused with nothing on screen to hang it up. + assertThat(controller.microphonePermissionAnswers).containsExactly(true) + } + + @Test + fun `a call with no UI is not maximized`() = runTest { + grantMicrophonePermission() + val controller = FakeElementCallController() + val entryPoint = createEntryPoint(controller) + + entryPoint.startCall(A_CALL_DATA) + runCurrent() + + // The snapshot assumes a call is on screen. While there is no call UI it never is, and the + // component holds a proximity wake lock for a maximized audio call - which blanks the display + // whenever a hand goes near the top of the phone, including reaching for the shade. + assertThat(controller.maximizedCalls).containsExactly(false) + } + + @Test + fun `no call is started when the microphone permission is missing`() = runTest { + shadowOf(RuntimeEnvironment.getApplication()).denyPermissions(Manifest.permission.RECORD_AUDIO) + val controller = FakeElementCallController() + val entryPoint = createEntryPoint(controller) + + entryPoint.startCall(A_CALL_DATA) + runCurrent() + + // Nothing to ask with, so nothing is started: a call left unanswered would wedge the + // controller for the rest of the session. + assertThat(controller.startedCalls).isEmpty() + } + + @Test + fun `nothing happens for a session that cannot be restored`() = runTest { + grantMicrophonePermission() + val controller = FakeElementCallController() + val entryPoint = createEntryPoint(controller, sessionCanBeRestored = false) + + entryPoint.startCall(A_CALL_DATA) + runCurrent() + + assertThat(controller.startedCalls).isEmpty() + } + + private fun grantMicrophonePermission() { + shadowOf(RuntimeEnvironment.getApplication()).grantPermissions(Manifest.permission.RECORD_AUDIO) + } + + private fun TestScope.createEntryPoint( + controller: FakeElementCallController, + sessionCanBeRestored: Boolean = true, + ) = DefaultNativeCallEntryPoint( + context = InstrumentationRegistry.getInstrumentation().targetContext as Application, + controllers = FakeElementCallControllers(controller.takeIf { sessionCanBeRestored }), + appCoroutineScope = this, + ) + + private companion object { + val A_CALL_DATA = CallData(sessionId = A_SESSION_ID, roomId = A_ROOM_ID, isAudioCall = true) + } +} diff --git a/features/callnative/impl/src/test/kotlin/io/element/android/features/callnative/impl/FakeElementCallControllers.kt b/features/callnative/impl/src/test/kotlin/io/element/android/features/callnative/impl/FakeElementCallControllers.kt new file mode 100644 index 0000000000..64cf425af6 --- /dev/null +++ b/features/callnative/impl/src/test/kotlin/io/element/android/features/callnative/impl/FakeElementCallControllers.kt @@ -0,0 +1,29 @@ +/* + * Copyright (c) 2026 Element Creations Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.callnative.impl + +import io.element.android.call.api.ElementCallController +import io.element.android.libraries.matrix.api.core.SessionId + +/** + * @param controller what [getOrBuild] answers with, or null to stand for a session that cannot be + * restored - logged out, or an id from a stale notification. + */ +class FakeElementCallControllers( + private val controller: ElementCallController? = null, +) : ElementCallControllers { + val requestedSessions = mutableListOf() + + override suspend fun getOrBuild(sessionId: SessionId): ElementCallController? { + requestedSessions += sessionId + return controller + } + + override fun withRunningCall(): ElementCallController? = + controller?.takeIf { it.state.value != null } +}