mirror of
https://github.com/Novaloop-AG/element-x-android.git
synced 2026-10-31 16:22:49 +00:00
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:
@@ -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 {
|
||||
|
||||
+26
@@ -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)
|
||||
}
|
||||
+26
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
+8
-17
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+71
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
+31
@@ -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)
|
||||
}
|
||||
}
|
||||
+22
-6
@@ -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 }
|
||||
}
|
||||
|
||||
|
||||
+57
@@ -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,
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
+13
-11
@@ -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
|
||||
|
||||
+4
@@ -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 }
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user