Draw the native call in the app

A wrapper around the logged-in content rather than an overlay, because a minimized call takes a strip of height above it while a maximized one covers it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Valere
2026-09-22 21:31:02 +02:00
committed by Valere Fedronic
parent 2ca33296b5
commit e786e2d22f
11 changed files with 263 additions and 36 deletions
+2 -1
View File
@@ -6,7 +6,8 @@
*/
plugins {
id("io.element.android-library")
// NativeCallHost renders the call, so this module carries a composable.
id("io.element.android-compose-library")
}
android {
@@ -0,0 +1,26 @@
/*
* 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.api
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
/**
* Where a native call is drawn, wrapped around the logged-in content.
*
* A wrapper rather than an overlay, because the two states of a call need different things from the
* layout: minimized, the call takes a strip of height above the content; maximized, it covers it. The
* [Modifier] handed back to [content] has consumed whatever window insets the call has already taken,
* so the screens underneath do not pad for the status bar a second time.
*
* With no call running this renders [content] and nothing else.
*/
interface NativeCallHost {
@Composable
fun Render(modifier: Modifier, content: @Composable (Modifier) -> Unit)
}
@@ -0,0 +1,26 @@
/*
* 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.api
import android.app.Activity
/**
* The one part of picture-in-picture that an Activity has to do itself.
*
* Everything else is installed where the call is drawn, which is inside the logged-in content and so
* knows which session's call it is. This does not: `onUserLeaveHint` is an Activity override, there is
* no session in scope at that point, and it applies to whichever call happens to be running.
*
* Only Android 11 and below reach this. From Android 12 the system is told up front that the Activity
* would like to shrink, which covers every way out of the app including the home gesture - the one
* people actually use, and the one this hook never sees.
*/
interface NativeCallPip {
/** Call from the host Activity's `onUserLeaveHint()` override. */
fun onUserLeaveHint(activity: Activity)
}
+3 -1
View File
@@ -8,7 +8,7 @@ import extension.testCommonDependencies
* Please see LICENSE files in the repository root for full details.
*/
plugins {
id("io.element.android-library")
id("io.element.android-compose-library")
}
android {
@@ -29,8 +29,10 @@ dependencies {
implementation(platform("io.element.android:element-call-bom:$elementCallVersion"))
implementation(libs.element.call.api)
implementation(libs.element.call.impl)
implementation(libs.element.call.ui)
implementation(projects.features.call.api)
implementation(projects.libraries.designsystem)
implementation(projects.libraries.architecture)
implementation(projects.libraries.di)
implementation(projects.libraries.featureflag.api)
@@ -21,7 +21,6 @@ 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
/**
* Starts a call on the native MatrixRTC stack.
@@ -37,15 +36,6 @@ class DefaultNativeCallEntryPoint(
@AppCoroutineScope private val appCoroutineScope: CoroutineScope,
) : NativeCallEntryPoint {
override fun startCall(callData: CallData) {
// 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(
@@ -54,13 +44,14 @@ class DefaultNativeCallEntryPoint(
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)
// The call screen asks for the microphone itself, and is where the answer normally comes
// from. This covers the case it cannot reach: a call answered from a notification while
// the app is in the background has no call screen composed yet, and a call waiting on a
// permission nobody is there to grant would sit unanswered. Reporting a permission we
// already hold is idempotent, so the screen asking again later costs nothing.
if (hasMicrophonePermission()) {
controller.setMicrophonePermissionGranted(true)
}
}
}
@@ -0,0 +1,71 @@
/*
* 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 androidx.activity.ComponentActivity
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import dev.zacsweers.metro.ContributesBinding
import io.element.android.call.impl.ElementCallPictureInPicture
import io.element.android.call.ui.ElementCallOverlay
import io.element.android.features.callnative.api.NativeCallHost
import io.element.android.libraries.core.extensions.runCatchingExceptions
import io.element.android.libraries.di.SessionScope
import io.element.android.libraries.matrix.api.MatrixClient
import timber.log.Timber
/**
* Draws the session's native call around the logged-in content.
*
* Session-scoped, unlike the entry point: this renders inside the logged-in navigation, so the
* session is already in scope and there is no need to go looking for one. The stack it draws is not,
* so this asks the app-scoped registry for its own session's controller.
*
* That controller only exists once a call has been placed or answered. Until then this is a
* pass-through and nothing about the call is on screen.
*/
@ContributesBinding(SessionScope::class)
class DefaultNativeCallHost(
private val client: MatrixClient,
private val controllers: ElementCallControllers,
) : NativeCallHost {
@Composable
override fun Render(modifier: Modifier, content: @Composable (Modifier) -> Unit) {
val controller by remember(client.sessionId) { controllers.controller(client.sessionId) }
.collectAsState(initial = null)
val current = controller
if (current == null) {
content(modifier)
return
}
// Installed here rather than in the Activity because this is the only place that has both the
// Activity and the session whose call it is. The Activity keeps `onUserLeaveHint`, which it
// cannot delegate: see NativeCallPip.
val activity = LocalContext.current as? ComponentActivity
DisposableEffect(activity, current) {
if (activity != null) {
runCatchingExceptions { ElementCallPictureInPicture.attach(activity, current) }
.onFailure { Timber.w(it, "NativeCall: cannot attach picture-in-picture") }
}
onDispose { }
}
ElementCallOverlay(
controller = current,
modifier = modifier,
style = rememberElementXCallStyle(),
content = content,
)
}
}
@@ -0,0 +1,31 @@
/*
* 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.app.Activity
import dev.zacsweers.metro.AppScope
import dev.zacsweers.metro.ContributesBinding
import io.element.android.call.impl.ElementCallPictureInPicture
import io.element.android.features.callnative.api.NativeCallPip
/**
* Forwards the Activity's leave hint to whichever session has a call.
*
* App-scoped because `onUserLeaveHint` is, and it asks for the running call rather than a particular
* session's: only one call runs at a time, and the Activity has no session in scope to name. Sessions
* whose stack was never built are skipped, so asking cannot start one.
*/
@ContributesBinding(AppScope::class)
class DefaultNativeCallPip(
private val controllers: ElementCallControllers,
) : NativeCallPip {
override fun onUserLeaveHint(activity: Activity) {
val controller = controllers.withRunningCall() ?: return
ElementCallPictureInPicture.onUserLeaveHint(activity, controller)
}
}
@@ -31,12 +31,16 @@ import io.element.android.libraries.preferences.api.store.AppPreferencesStore
import io.element.android.libraries.push.api.notifications.ForegroundServiceType
import io.element.android.libraries.push.api.notifications.NotificationIdProvider
import io.element.android.services.appnavstate.api.AppForegroundStateService
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.job
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import timber.log.Timber
import java.util.concurrent.ConcurrentHashMap
/**
* The native call controller of a session, built on demand.
@@ -51,6 +55,14 @@ interface ElementCallControllers {
*/
suspend fun getOrBuild(sessionId: SessionId): ElementCallController?
/**
* The session's controller once its stack exists, and null until then. Never builds one.
*
* A flow rather than a getter because the stack appears part-way through the session's life, when
* a call is first placed or answered, and whatever draws the call has to notice when it does.
*/
fun controller(sessionId: SessionId): Flow<ElementCallController?>
/**
* The controller of whichever session currently has a call, or null when none does.
*
@@ -84,33 +96,37 @@ class DefaultElementCallControllers(
private val appPreferencesStore: AppPreferencesStore,
private val featureFlagService: FeatureFlagService,
) : ElementCallControllers {
private val stacks = ConcurrentHashMap<SessionId, ElementCallStack>()
// A flow rather than a plain map because what draws the call has to see the stack appear.
private val stacks = MutableStateFlow<Map<SessionId, ElementCallStack>>(emptyMap())
// One at a time across every session: two calls placed at once must not race into two stacks for
// the same session, because only one of them would ever be closed.
private val mutex = Mutex()
override suspend fun getOrBuild(sessionId: SessionId): ElementCallController? = mutex.withLock {
stacks[sessionId]?.let { return@withLock it.controller }
stacks.value[sessionId]?.let { return@withLock it.controller }
val client = matrixClientProvider.getOrRestore(sessionId).getOrNull()
if (client == null) {
Timber.w("NativeCall: no client for $sessionId, cannot start a call")
return@withLock null
}
build(client).also { stack ->
stacks[sessionId] = stack
stacks.update { it + (sessionId to stack) }
// The client's scope is the stack's lifetime: the component has no shutdown of its own, so
// cancelling this at logout is what stops the RTC core. Closing here only releases the
// component's own reference, so a dead stack is not left behind it.
client.sessionCoroutineScope.coroutineContext.job.invokeOnCompletion {
stacks.remove(sessionId, stack)
stacks.update { if (it[sessionId] === stack) it - sessionId else it }
stack.close()
}
}.controller
}
override fun controller(sessionId: SessionId): Flow<ElementCallController?> =
stacks.map { it[sessionId]?.controller }.distinctUntilChanged()
override fun withRunningCall(): ElementCallController? =
stacks.values.firstNotNullOfOrNull { stack ->
stacks.value.values.firstNotNullOfOrNull { stack ->
stack.controller.takeIf { it.state.value != null }
}
@@ -0,0 +1,57 @@
/*
* 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 androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import io.element.android.call.ui.theme.ElementCallColors
import io.element.android.call.ui.theme.ElementCallIcons
import io.element.android.call.ui.theme.ElementCallStyle
import io.element.android.call.ui.theme.ElementCallTypography
import io.element.android.libraries.designsystem.components.avatar.Avatar
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.AvatarType
/**
* How Element X dresses the native call.
*
* Deliberately almost nothing. The component's own defaults are already Compound - its colours are
* Compound's dark palette and its glyphs are Compound vectors - so re-deriving them from
* `ElementTheme` would buy no visual change and a lot of mapping to keep in step. The call also stays
* dark in a light-themed app on purpose: it is a video surface, as it is in Element Call on the web.
*
* What the component genuinely cannot do is turn an `mxc://` URL into a picture, because that needs a
* Matrix media loader. So the avatar is the one thing overridden here, and it is what makes faces
* appear on the tiles rather than initials.
*/
@Composable
fun rememberElementXCallStyle(): ElementCallStyle {
val icons = ElementCallIcons.default()
return remember(icons) {
ElementCallStyle(
colors = ElementCallColors.Dark,
typography = ElementCallTypography.Default,
icons = icons,
avatar = { data, size ->
Avatar(
avatarData = AvatarData(
id = data.id,
name = data.name,
url = data.url,
// Unused: forcedAvatarSize below is what decides the size, and the component
// asks in dp because it lays the tiles out itself.
size = AvatarSize.ActiveCallItem,
),
avatarType = AvatarType.User,
forcedAvatarSize = size,
)
},
)
}
}
@@ -36,13 +36,14 @@ class DefaultNativeCallEntryPointTest : RobolectricTest() {
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.
// The call screen normally answers this, but it is not composed when a call is answered from
// a notification with the app in the background - and a call waiting on a permission nobody
// is there to grant sits unanswered and refuses every later attempt.
assertThat(controller.microphonePermissionAnswers).containsExactly(true)
}
@Test
fun `a call with no UI is not maximized`() = runTest {
fun `a call the user placed opens on the call screen, not in the minimized bar`() = runTest {
grantMicrophonePermission()
val controller = FakeElementCallController()
val entryPoint = createEntryPoint(controller)
@@ -50,14 +51,14 @@ class DefaultNativeCallEntryPointTest : RobolectricTest() {
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)
// A snapshot starts maximized, which is what the user asked for by pressing call. Saying
// otherwise here is what a UI-less build had to do, and it would now dock a freshly placed
// call into the bar.
assertThat(controller.maximizedCalls).isEmpty()
}
@Test
fun `no call is started when the microphone permission is missing`() = runTest {
fun `the call still starts without the microphone permission, so the call screen can ask`() = runTest {
shadowOf(RuntimeEnvironment.getApplication()).denyPermissions(Manifest.permission.RECORD_AUDIO)
val controller = FakeElementCallController()
val entryPoint = createEntryPoint(controller)
@@ -65,9 +66,10 @@ class DefaultNativeCallEntryPointTest : RobolectricTest() {
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()
assertThat(controller.startedCalls).hasSize(1)
// Nothing to report, and nothing to report it from: only an Activity can ask, and the call
// screen does exactly that once it appears.
assertThat(controller.microphonePermissionAnswers).isEmpty()
}
@Test
@@ -9,6 +9,8 @@ package io.element.android.features.callnative.impl
import io.element.android.call.api.ElementCallController
import io.element.android.libraries.matrix.api.core.SessionId
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flowOf
/**
* @param controller what [getOrBuild] answers with, or null to stand for a session that cannot be
@@ -24,6 +26,8 @@ class FakeElementCallControllers(
return controller
}
override fun controller(sessionId: SessionId): Flow<ElementCallController?> = flowOf(controller)
override fun withRunningCall(): ElementCallController? =
controller?.takeIf { it.state.value != null }
}