mirror of
https://github.com/Novaloop-AG/element-x-android.git
synced 2026-11-02 12:07:28 +00:00
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:
+47
-7
@@ -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
|
||||
}
|
||||
|
||||
+101
@@ -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)
|
||||
}
|
||||
}
|
||||
+29
@@ -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 }
|
||||
}
|
||||
Reference in New Issue
Block a user