diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index d5f08e07957..9193c6de717 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -53,9 +53,10 @@ --> diff --git a/app/src/main/kotlin/io/element/android/x/MainActivity.kt b/app/src/main/kotlin/io/element/android/x/MainActivity.kt index c5551f11aab..cc058d0f8f2 100644 --- a/app/src/main/kotlin/io/element/android/x/MainActivity.kt +++ b/app/src/main/kotlin/io/element/android/x/MainActivity.kt @@ -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() diff --git a/app/src/main/kotlin/io/element/android/x/di/AppBindings.kt b/app/src/main/kotlin/io/element/android/x/di/AppBindings.kt index 638ba0e38ce..abdb6b13944 100644 --- a/app/src/main/kotlin/io/element/android/x/di/AppBindings.kt +++ b/app/src/main/kotlin/io/element/android/x/di/AppBindings.kt @@ -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 @@ -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) diff --git a/appnav/build.gradle.kts b/appnav/build.gradle.kts index 12b95d9a096..f65692feb4e 100644 --- a/appnav/build.gradle.kts +++ b/appnav/build.gradle.kts @@ -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) diff --git a/appnav/src/main/kotlin/io/element/android/appnav/LoggedInFlowNode.kt b/appnav/src/main/kotlin/io/element/android/appnav/LoggedInFlowNode.kt index a0bcf3b4890..2f0988281e8 100644 --- a/appnav/src/main/kotlin/io/element/android/appnav/LoggedInFlowNode.kt +++ b/appnav/src/main/kotlin/io/element/android/appnav/LoggedInFlowNode.kt @@ -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 @@ -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, @@ -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) + } } } } diff --git a/features/callnative/api/build.gradle.kts b/features/callnative/api/build.gradle.kts index 81e5976627c..f5f3c25f4bd 100644 --- a/features/callnative/api/build.gradle.kts +++ b/features/callnative/api/build.gradle.kts @@ -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 { diff --git a/features/callnative/api/src/main/kotlin/io/element/android/features/callnative/api/NativeCallHost.kt b/features/callnative/api/src/main/kotlin/io/element/android/features/callnative/api/NativeCallHost.kt new file mode 100644 index 00000000000..c90ffe1e29c --- /dev/null +++ b/features/callnative/api/src/main/kotlin/io/element/android/features/callnative/api/NativeCallHost.kt @@ -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) +} diff --git a/features/callnative/api/src/main/kotlin/io/element/android/features/callnative/api/NativeCallPip.kt b/features/callnative/api/src/main/kotlin/io/element/android/features/callnative/api/NativeCallPip.kt new file mode 100644 index 00000000000..0cd8ea9110f --- /dev/null +++ b/features/callnative/api/src/main/kotlin/io/element/android/features/callnative/api/NativeCallPip.kt @@ -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) +} diff --git a/features/callnative/impl/build.gradle.kts b/features/callnative/impl/build.gradle.kts index 8cfee12e0be..a135f46ad35 100644 --- a/features/callnative/impl/build.gradle.kts +++ b/features/callnative/impl/build.gradle.kts @@ -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) diff --git a/features/callnative/impl/src/main/kotlin/io/element/android/features/callnative/impl/DefaultNativeCallEntryPoint.kt b/features/callnative/impl/src/main/kotlin/io/element/android/features/callnative/impl/DefaultNativeCallEntryPoint.kt index 4eb5b694185..2210663376b 100644 --- a/features/callnative/impl/src/main/kotlin/io/element/android/features/callnative/impl/DefaultNativeCallEntryPoint.kt +++ b/features/callnative/impl/src/main/kotlin/io/element/android/features/callnative/impl/DefaultNativeCallEntryPoint.kt @@ -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) + } } } diff --git a/features/callnative/impl/src/main/kotlin/io/element/android/features/callnative/impl/DefaultNativeCallHost.kt b/features/callnative/impl/src/main/kotlin/io/element/android/features/callnative/impl/DefaultNativeCallHost.kt new file mode 100644 index 00000000000..332f28f7d86 --- /dev/null +++ b/features/callnative/impl/src/main/kotlin/io/element/android/features/callnative/impl/DefaultNativeCallHost.kt @@ -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, + ) + } +} diff --git a/features/callnative/impl/src/main/kotlin/io/element/android/features/callnative/impl/DefaultNativeCallPip.kt b/features/callnative/impl/src/main/kotlin/io/element/android/features/callnative/impl/DefaultNativeCallPip.kt new file mode 100644 index 00000000000..b94785a931b --- /dev/null +++ b/features/callnative/impl/src/main/kotlin/io/element/android/features/callnative/impl/DefaultNativeCallPip.kt @@ -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) + } +} diff --git a/features/callnative/impl/src/main/kotlin/io/element/android/features/callnative/impl/ElementCallControllers.kt b/features/callnative/impl/src/main/kotlin/io/element/android/features/callnative/impl/ElementCallControllers.kt index 0cb50d2038d..5792da9a930 100644 --- a/features/callnative/impl/src/main/kotlin/io/element/android/features/callnative/impl/ElementCallControllers.kt +++ b/features/callnative/impl/src/main/kotlin/io/element/android/features/callnative/impl/ElementCallControllers.kt @@ -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 + /** * 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() + // A flow rather than a plain map because what draws the call has to see the stack appear. + private val stacks = MutableStateFlow>(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 = + 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 } } diff --git a/features/callnative/impl/src/main/kotlin/io/element/android/features/callnative/impl/ElementXCallStyle.kt b/features/callnative/impl/src/main/kotlin/io/element/android/features/callnative/impl/ElementXCallStyle.kt new file mode 100644 index 00000000000..751679b298f --- /dev/null +++ b/features/callnative/impl/src/main/kotlin/io/element/android/features/callnative/impl/ElementXCallStyle.kt @@ -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, + ) + }, + ) + } +} diff --git a/features/callnative/impl/src/test/kotlin/io/element/android/features/callnative/impl/DefaultNativeCallEntryPointTest.kt b/features/callnative/impl/src/test/kotlin/io/element/android/features/callnative/impl/DefaultNativeCallEntryPointTest.kt index 5afbda0e5b9..cd209069e72 100644 --- a/features/callnative/impl/src/test/kotlin/io/element/android/features/callnative/impl/DefaultNativeCallEntryPointTest.kt +++ b/features/callnative/impl/src/test/kotlin/io/element/android/features/callnative/impl/DefaultNativeCallEntryPointTest.kt @@ -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 diff --git a/features/callnative/impl/src/test/kotlin/io/element/android/features/callnative/impl/FakeElementCallControllers.kt b/features/callnative/impl/src/test/kotlin/io/element/android/features/callnative/impl/FakeElementCallControllers.kt index 64cf425af6e..fb87f494d58 100644 --- a/features/callnative/impl/src/test/kotlin/io/element/android/features/callnative/impl/FakeElementCallControllers.kt +++ b/features/callnative/impl/src/test/kotlin/io/element/android/features/callnative/impl/FakeElementCallControllers.kt @@ -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 = flowOf(controller) + override fun withRunningCall(): ElementCallController? = controller?.takeIf { it.state.value != null } }