Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion app/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -53,9 +53,10 @@
-->
<activity
android:name=".MainActivity"
android:configChanges="orientation|screenSize|screenLayout|keyboardHidden|uiMode"
android:configChanges="orientation|screenSize|smallestScreenSize|screenLayout|keyboardHidden|uiMode"
android:exported="true"
android:launchMode="singleTask"
android:supportsPictureInPicture="true"
android:theme="@style/Theme.ElementX.Splash"
android:windowSoftInputMode="adjustResize">
<intent-filter>
Expand Down
13 changes: 13 additions & 0 deletions app/src/main/kotlin/io/element/android/x/MainActivity.kt
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,19 @@ class MainActivity : NodeActivity() {
}
}

/**
* The pre-Android 12 way into picture-in-picture for a native call.
*
* Everything else about it is installed where the call is drawn, which knows whose call it is.
* This cannot be: an Activity override has no session in scope. From Android 12 the system is told
* up front that this Activity would like to shrink, and the call handles it without ever coming
* through here.
*/
override fun onUserLeaveHint() {
super.onUserLeaveHint()
appBindings.nativeCallPip().onUserLeaveHint(this)
}

@Composable
private fun MainContent(appBindings: AppBindings) {
val migrationState = appBindings.migrationEntryPoint().present()
Expand Down
8 changes: 8 additions & 0 deletions app/src/main/kotlin/io/element/android/x/di/AppBindings.kt
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import dev.zacsweers.metro.AppScope
import dev.zacsweers.metro.ContributesTo
import dev.zacsweers.metro.Multibinds
import io.element.android.features.api.MigrationEntryPoint
import io.element.android.features.callnative.api.NativeCallPip
import io.element.android.features.enterprise.api.AppStartupHook
import io.element.android.features.enterprise.api.EnterpriseService
import io.element.android.features.lockscreen.api.LockScreenEntryPoint
Expand Down Expand Up @@ -52,6 +53,13 @@ interface AppBindings {

fun buildMeta(): BuildMeta

/**
* For picture-in-picture. Only an Activity is told it is being left, and only the call knows
* whether that is worth shrinking for, so `MainActivity` needs this much of the native call and
* no more.
*/
fun nativeCallPip(): NativeCallPip

fun sentrySdkDsn(): SentrySdkDsn?

@Multibinds(allowEmpty = true)
Expand Down
1 change: 1 addition & 0 deletions appnav/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@ dependencies {
implementation(libs.coil)

implementation(projects.features.announcement.api)
implementation(projects.features.callnative.api)
implementation(projects.features.ftue.api)
implementation(projects.features.linknewdevice.api)
implementation(projects.features.share.api)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ import io.element.android.appnav.room.RoomFlowNode
import io.element.android.appnav.room.RoomNavigationTarget
import io.element.android.appnav.room.joined.JoinedRoomLoadedFlowNode
import io.element.android.compound.colors.SemanticColorsLightDark
import io.element.android.features.callnative.api.NativeCallHost
import io.element.android.features.createroom.api.CreateRoomEntryPoint
import io.element.android.features.enterprise.api.EnterpriseService
import io.element.android.features.enterprise.api.SessionEnterpriseService
Expand Down Expand Up @@ -144,6 +145,7 @@ class LoggedInFlowNode(
private val shareEntryPoint: ShareEntryPoint,
private val matrixClient: MatrixClient,
private val sendingQueue: SendQueues,
private val nativeCallHost: NativeCallHost,
private val incomingVerificationEntryPoint: IncomingVerificationEntryPoint,
private val mediaPreviewConfigMigration: MediaPreviewConfigMigration,
private val sessionEnterpriseService: SessionEnterpriseService,
Expand Down Expand Up @@ -701,15 +703,19 @@ class LoggedInFlowNode(
) {
CompositionLocalProvider(LocalMapTilerConfig provides updatedMapTilerConfig) {
val isOnline by syncService.isOnline.collectAsState()
ConnectivityIndicatorContainer(
isOnline = isOnline,
modifier = modifier,
) { contentModifier ->
Box(modifier = contentModifier) {
val ftueState by ftueService.state.collectAsState()
BackstackView(transitionHandler = rememberLoggedInFlowTransitionHandler(backstack))
if (ftueState is FtueState.Complete) {
PermanentChild(permanentNavModel = permanentNavModel, navTarget = NavTarget.LoggedInPermanent)
// Outside the connectivity indicator so the two strips stack rather than fight: an
// offline banner during a call belongs under the call bar, not over it.
nativeCallHost.Render(modifier = modifier) { callContentModifier ->
ConnectivityIndicatorContainer(
isOnline = isOnline,
modifier = callContentModifier,
) { contentModifier ->
Box(modifier = contentModifier) {
val ftueState by ftueService.state.collectAsState()
BackstackView(transitionHandler = rememberLoggedInFlowTransitionHandler(backstack))
if (ftueState is FtueState.Complete) {
PermanentChild(permanentNavModel = permanentNavModel, navTarget = NavTarget.LoggedInPermanent)
}
}
}
}
Expand Down
3 changes: 2 additions & 1 deletion features/callnative/api/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
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

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Make this interface functional or replace it with a function type.

See more on https://sonarcloud.io/project/issues?id=element-x-android&issues=AaDFC8snbrBzbhSBZcJS&open=AaDFC8snbrBzbhSBZcJS&pullRequest=7747
@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

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Make this interface functional or replace it with a function type.

See more on https://sonarcloud.io/project/issues?id=element-x-android&issues=AaDFC8svbrBzbhSBZcJT&open=AaDFC8svbrBzbhSBZcJT&pullRequest=7747
/** Call from the host Activity's `onUserLeaveHint()` override. */
fun onUserLeaveHint(activity: Activity)
}
4 changes: 3 additions & 1 deletion features/callnative/impl/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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(
Expand All @@ -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)
}
}
}

Expand Down
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(

Copy link
Copy Markdown
Member

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 MainActivity and 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.

Copy link
Copy Markdown
Member Author

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

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)
}
}
Loading
Loading