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 { plugins {
id("io.element.android-library") // NativeCallHost renders the call, so this module carries a composable.
id("io.element.android-compose-library")
} }
android { 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. * Please see LICENSE files in the repository root for full details.
*/ */
plugins { plugins {
id("io.element.android-library") id("io.element.android-compose-library")
} }
android { android {
@@ -29,8 +29,10 @@ dependencies {
implementation(platform("io.element.android:element-call-bom:$elementCallVersion")) implementation(platform("io.element.android:element-call-bom:$elementCallVersion"))
implementation(libs.element.call.api) implementation(libs.element.call.api)
implementation(libs.element.call.impl) implementation(libs.element.call.impl)
implementation(libs.element.call.ui)
implementation(projects.features.call.api) implementation(projects.features.call.api)
implementation(projects.libraries.designsystem)
implementation(projects.libraries.architecture) implementation(projects.libraries.architecture)
implementation(projects.libraries.di) implementation(projects.libraries.di)
implementation(projects.libraries.featureflag.api) 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 io.element.android.libraries.di.annotations.ApplicationContext
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import timber.log.Timber
/** /**
* Starts a call on the native MatrixRTC stack. * Starts a call on the native MatrixRTC stack.
@@ -37,15 +36,6 @@ class DefaultNativeCallEntryPoint(
@AppCoroutineScope private val appCoroutineScope: CoroutineScope, @AppCoroutineScope private val appCoroutineScope: CoroutineScope,
) : NativeCallEntryPoint { ) : NativeCallEntryPoint {
override fun startCall(callData: CallData) { 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 { appCoroutineScope.launch {
val controller = controllers.getOrBuild(callData.sessionId) ?: return@launch val controller = controllers.getOrBuild(callData.sessionId) ?: return@launch
controller.startCall( controller.startCall(
@@ -54,13 +44,14 @@ class DefaultNativeCallEntryPoint(
isAudioCall = callData.isAudioCall, isAudioCall = callData.isAudioCall,
) )
) )
controller.setMicrophonePermissionGranted(true) // The call screen asks for the microphone itself, and is where the answer normally comes
// A call with no UI is never the screen the user is looking at, and the snapshot assumes // from. This covers the case it cannot reach: a call answered from a notification while
// it is. Saying so matters for more than tidiness: the component holds a proximity wake // the app is in the background has no call screen composed yet, and a call waiting on a
// lock while a maximized audio call is on screen, so leaving the default in place blanks // permission nobody is there to grant would sit unanswered. Reporting a permission we
// the display whenever a hand goes near the top of the phone - including when reaching // already hold is idempotent, so the screen asking again later costs nothing.
// for the notification shade, which is the only way to hang this call up. if (hasMicrophonePermission()) {
controller.setMaximized(false) 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.ForegroundServiceType
import io.element.android.libraries.push.api.notifications.NotificationIdProvider import io.element.android.libraries.push.api.notifications.NotificationIdProvider
import io.element.android.services.appnavstate.api.AppForegroundStateService 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.first
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.job import kotlinx.coroutines.job
import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.sync.withLock
import timber.log.Timber import timber.log.Timber
import java.util.concurrent.ConcurrentHashMap
/** /**
* The native call controller of a session, built on demand. * The native call controller of a session, built on demand.
@@ -51,6 +55,14 @@ interface ElementCallControllers {
*/ */
suspend fun getOrBuild(sessionId: SessionId): ElementCallController? 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. * 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 appPreferencesStore: AppPreferencesStore,
private val featureFlagService: FeatureFlagService, private val featureFlagService: FeatureFlagService,
) : ElementCallControllers { ) : 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 // 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. // the same session, because only one of them would ever be closed.
private val mutex = Mutex() private val mutex = Mutex()
override suspend fun getOrBuild(sessionId: SessionId): ElementCallController? = mutex.withLock { 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() val client = matrixClientProvider.getOrRestore(sessionId).getOrNull()
if (client == null) { if (client == null) {
Timber.w("NativeCall: no client for $sessionId, cannot start a call") Timber.w("NativeCall: no client for $sessionId, cannot start a call")
return@withLock null return@withLock null
} }
build(client).also { stack -> 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 // 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 // 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. // component's own reference, so a dead stack is not left behind it.
client.sessionCoroutineScope.coroutineContext.job.invokeOnCompletion { client.sessionCoroutineScope.coroutineContext.job.invokeOnCompletion {
stacks.remove(sessionId, stack) stacks.update { if (it[sessionId] === stack) it - sessionId else it }
stack.close() stack.close()
} }
}.controller }.controller
} }
override fun controller(sessionId: SessionId): Flow<ElementCallController?> =
stacks.map { it[sessionId]?.controller }.distinctUntilChanged()
override fun withRunningCall(): ElementCallController? = override fun withRunningCall(): ElementCallController? =
stacks.values.firstNotNullOfOrNull { stack -> stacks.value.values.firstNotNullOfOrNull { stack ->
stack.controller.takeIf { it.state.value != null } 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() runCurrent()
assertThat(controller.startedCalls.map { it.roomId.value }).containsExactly(A_ROOM_ID.value) 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 // The call screen normally answers this, but it is not composed when a call is answered from
// "running" every later attempt is refused with nothing on screen to hang it up. // 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) assertThat(controller.microphonePermissionAnswers).containsExactly(true)
} }
@Test @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() grantMicrophonePermission()
val controller = FakeElementCallController() val controller = FakeElementCallController()
val entryPoint = createEntryPoint(controller) val entryPoint = createEntryPoint(controller)
@@ -50,14 +51,14 @@ class DefaultNativeCallEntryPointTest : RobolectricTest() {
entryPoint.startCall(A_CALL_DATA) entryPoint.startCall(A_CALL_DATA)
runCurrent() runCurrent()
// The snapshot assumes a call is on screen. While there is no call UI it never is, and the // A snapshot starts maximized, which is what the user asked for by pressing call. Saying
// component holds a proximity wake lock for a maximized audio call - which blanks the display // otherwise here is what a UI-less build had to do, and it would now dock a freshly placed
// whenever a hand goes near the top of the phone, including reaching for the shade. // call into the bar.
assertThat(controller.maximizedCalls).containsExactly(false) assertThat(controller.maximizedCalls).isEmpty()
} }
@Test @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) shadowOf(RuntimeEnvironment.getApplication()).denyPermissions(Manifest.permission.RECORD_AUDIO)
val controller = FakeElementCallController() val controller = FakeElementCallController()
val entryPoint = createEntryPoint(controller) val entryPoint = createEntryPoint(controller)
@@ -65,9 +66,10 @@ class DefaultNativeCallEntryPointTest : RobolectricTest() {
entryPoint.startCall(A_CALL_DATA) entryPoint.startCall(A_CALL_DATA)
runCurrent() runCurrent()
// Nothing to ask with, so nothing is started: a call left unanswered would wedge the assertThat(controller.startedCalls).hasSize(1)
// controller for the rest of the session. // Nothing to report, and nothing to report it from: only an Activity can ask, and the call
assertThat(controller.startedCalls).isEmpty() // screen does exactly that once it appears.
assertThat(controller.microphonePermissionAnswers).isEmpty()
} }
@Test @Test
@@ -9,6 +9,8 @@ package io.element.android.features.callnative.impl
import io.element.android.call.api.ElementCallController import io.element.android.call.api.ElementCallController
import io.element.android.libraries.matrix.api.core.SessionId 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 * @param controller what [getOrBuild] answers with, or null to stand for a session that cannot be
@@ -24,6 +26,8 @@ class FakeElementCallControllers(
return controller return controller
} }
override fun controller(sessionId: SessionId): Flow<ElementCallController?> = flowOf(controller)
override fun withRunningCall(): ElementCallController? = override fun withRunningCall(): ElementCallController? =
controller?.takeIf { it.state.value != null } controller?.takeIf { it.state.value != null }
} }