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) <noreply@anthropic.com>
This commit is contained in:
Valere
2026-09-22 21:04:06 +02:00
committed by Valere Fedronic
parent 40cb43ae87
commit 75605f1932
4 changed files with 178 additions and 8 deletions
@@ -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)
}
@@ -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
}
@@ -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)
}
}
@@ -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<SessionId>()
override suspend fun getOrBuild(sessionId: SessionId): ElementCallController? {
requestedSessions += sessionId
return controller
}
override fun withRunningCall(): ElementCallController? =
controller?.takeIf { it.state.value != null }
}