-
Notifications
You must be signed in to change notification settings - Fork 657
PR 3/3: Experimental Native Element Call behind lab flag #7747
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
065bbc4
b111ede
d7a89e2
83b1890
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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 { | ||
|
Check warning on line 23 in features/callnative/api/src/main/kotlin/io/element/android/features/callnative/api/NativeCallHost.kt
|
||
| @Composable | ||
| fun Render(modifier: Modifier, content: @Composable (Modifier) -> Unit) | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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 { | ||
|
Check warning on line 23 in features/callnative/api/src/main/kotlin/io/element/android/features/callnative/api/NativeCallPip.kt
|
||
| /** Call from the host Activity's `onUserLeaveHint()` override. */ | ||
| fun onUserLeaveHint(activity: Activity) | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,68 @@ | ||
| /* | ||
| * 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.activity.compose.LocalActivity | ||
| 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 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 the overlay draws | ||
| * the content alone. | ||
| */ | ||
| @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 | ||
|
|
||
| // 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 = LocalActivity.current as? ComponentActivity | ||
| DisposableEffect(activity, current) { | ||
| if (activity != null && current != null) { | ||
| runCatchingExceptions { ElementCallPictureInPicture.attach(activity, current) } | ||
| .onFailure { Timber.w(it, "NativeCall: cannot attach picture-in-picture") } | ||
| } | ||
| onDispose { } | ||
| } | ||
|
|
||
| // Composed even with no controller yet, so the content never moves in the tree and keeps its state. | ||
| ElementCallOverlay( | ||
| controller = current, | ||
| modifier = modifier, | ||
| style = rememberElementXCallStyle(), | ||
| content = content, | ||
| ) | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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) | ||
| } | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Double checking, but what we're doing is, instead of opening a new activity for EC, we're using
MainActivityand overlaying EC on top of everything, right?I'm a bit concerned this could cause issues with other UI elements, like pop ups or snackbars that could be displayed in a window on top of the current activity, but I can't think of anything at the moment that could cause this scenario.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Yes for the component ships no activity.
It is done like that to have a better and fast transition from minimize (PIP or banner) to maximized, no video/sound gap, etc..
But yes we have to then think about interactions with other components (like the media preview maybe?), but this is the kind of integration that we need according to the design; like when banner is integrated in the header?
That make me think that maybe the back action is not working to minimize the call, I will test and create an issue