diff --git a/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/pip/PictureInPictureEvent.kt b/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/pip/PictureInPictureEvent.kt index 1af13e148e..2053ff5102 100644 --- a/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/pip/PictureInPictureEvent.kt +++ b/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/pip/PictureInPictureEvent.kt @@ -11,7 +11,9 @@ package io.element.android.features.call.impl.pip import io.element.android.features.call.impl.utils.PipController sealed interface PictureInPictureEvent { + data object OnCallStarted : PictureInPictureEvent data class SetPipController(val pipController: PipController) : PictureInPictureEvent data object EnterPictureInPicture : PictureInPictureEvent data class OnPictureInPictureModeChanged(val isInPip: Boolean) : PictureInPictureEvent + data class SetPipOrientation(val orientation: Int?) : PictureInPictureEvent } diff --git a/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/pip/PictureInPicturePresenter.kt b/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/pip/PictureInPicturePresenter.kt index 2dc16d7f26..aeb507f6ec 100644 --- a/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/pip/PictureInPicturePresenter.kt +++ b/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/pip/PictureInPicturePresenter.kt @@ -38,6 +38,9 @@ class PictureInPicturePresenter( fun handleEvent(event: PictureInPictureEvent) { when (event) { + is PictureInPictureEvent.OnCallStarted -> { + pipController?.setupPipCallbacks() + } is PictureInPictureEvent.SetPipController -> { pipController = event.pipController } @@ -55,6 +58,10 @@ class PictureInPicturePresenter( pipController?.exitPip() } } + is PictureInPictureEvent.SetPipOrientation -> { + Timber.tag(loggerTag.value).d("onOrientationChange: ${event.orientation}") + pipView?.setPipOrientation(event.orientation) + } } } diff --git a/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/pip/PipView.kt b/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/pip/PipView.kt index 90f74a37fd..aa1ab79dd0 100644 --- a/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/pip/PipView.kt +++ b/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/pip/PipView.kt @@ -9,6 +9,7 @@ package io.element.android.features.call.impl.pip interface PipView { + fun setPipOrientation(orientation: Int?) fun setPipParams() fun enterPipMode(): Boolean fun hangUp() diff --git a/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/ui/CallScreenView.kt b/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/ui/CallScreenView.kt index 2537eb739c..ee63e273e4 100644 --- a/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/ui/CallScreenView.kt +++ b/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/ui/CallScreenView.kt @@ -118,6 +118,7 @@ internal fun CallScreenView( if (webViewAudioManager?.isInCallMode?.get() == false) { Timber.d("URL $url is loaded, starting in-call audio mode") webViewAudioManager?.onCallStarted() + pipState.eventSink(PictureInPictureEvent.OnCallStarted) } else { Timber.d("Can't start in-call audio mode since the app is already in it.") } @@ -130,7 +131,12 @@ internal fun CallScreenView( onInvalidAudioDeviceAdded = { invalidAudioDeviceReason = it }, ) state.eventSink(CallScreenEvent.SetupMessageChannels(interceptor)) - val pipController = WebViewPipController(webView) + val pipController = WebViewPipController( + webView = webView, + updatePipOrientation = { orientation -> + pipState.eventSink(PictureInPictureEvent.SetPipOrientation(orientation)) + } + ) pipState.eventSink(PictureInPictureEvent.SetPipController(pipController)) }, onDestroyWebView = { diff --git a/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/ui/ElementCallActivity.kt b/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/ui/ElementCallActivity.kt index 9a093ff5a6..5ed9e29fe2 100644 --- a/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/ui/ElementCallActivity.kt +++ b/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/ui/ElementCallActivity.kt @@ -11,9 +11,9 @@ package io.element.android.features.call.impl.ui import android.Manifest import android.app.PictureInPictureParams import android.content.Intent +import android.content.res.Configuration import android.os.Build import android.os.Bundle -import android.util.Rational import android.view.WindowManager import android.webkit.PermissionRequest import androidx.activity.compose.setContent @@ -49,6 +49,7 @@ import io.element.android.features.call.impl.pip.PipView import io.element.android.features.call.impl.services.CallForegroundService import io.element.android.features.enterprise.api.EnterpriseService import io.element.android.libraries.androidutils.browser.ConsoleMessageLogger +import io.element.android.libraries.androidutils.media.setAspectRatioFromOrientation import io.element.android.libraries.architecture.Presenter import io.element.android.libraries.architecture.bindings import io.element.android.libraries.audio.api.AudioFocus @@ -86,6 +87,8 @@ class ElementCallActivity : private var eventSink: ((CallScreenEvent) -> Unit)? = null + private var currentPipOrientation: Int? = null + override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) @@ -199,6 +202,7 @@ class ElementCallActivity : } DisposableEffect(Unit) { val onPictureInPictureModeChangedListener = Consumer { _: PictureInPictureModeChangedInfo -> + setPipParams() pipEventSink(PictureInPictureEvent.OnPictureInPictureModeChanged(isInPictureInPictureMode)) if (!isInPictureInPictureMode && !lifecycle.currentState.isAtLeast(Lifecycle.State.STARTED)) { Timber.tag(loggerTag.value).d("Exiting PiP mode: Hangup the call") @@ -296,11 +300,17 @@ class ElementCallActivity : } } + override fun setPipOrientation(orientation: Int?) { + currentPipOrientation = orientation + setPictureInPictureParams(getPictureInPictureParams()) + } + @RequiresApi(Build.VERSION_CODES.O) private fun getPictureInPictureParams(): PictureInPictureParams { + // Portrait for calls seems more appropriate as a fallback value + val orientation = currentPipOrientation ?: Configuration.ORIENTATION_PORTRAIT return PictureInPictureParams.Builder() - // Portrait for calls seems more appropriate - .setAspectRatio(Rational(3, 5)) + .setAspectRatioFromOrientation(orientation) .apply { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { setAutoEnterEnabled(true) diff --git a/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/utils/PipController.kt b/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/utils/PipController.kt index b259816f9d..f7788ad520 100644 --- a/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/utils/PipController.kt +++ b/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/utils/PipController.kt @@ -9,6 +9,7 @@ package io.element.android.features.call.impl.utils interface PipController { + fun setupPipCallbacks() suspend fun canEnterPip(): Boolean fun enterPip() fun exitPip() diff --git a/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/utils/WebViewAudioManager.kt b/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/utils/WebViewAudioManager.kt index e68e57118b..3290269971 100644 --- a/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/utils/WebViewAudioManager.kt +++ b/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/utils/WebViewAudioManager.kt @@ -250,7 +250,7 @@ class WebViewAudioManager( hasRegisteredCallbacks = true } - } + }, ) Timber.d("Setting androidNativeBridge javascript interface in webview") webView.addJavascriptInterface(webViewAudioDeviceSelectedCallback, "androidNativeBridge") diff --git a/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/utils/WebViewPipController.kt b/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/utils/WebViewPipController.kt index 12f8dfd00f..88648e84fa 100644 --- a/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/utils/WebViewPipController.kt +++ b/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/utils/WebViewPipController.kt @@ -8,7 +8,10 @@ package io.element.android.features.call.impl.utils +import android.content.res.Configuration +import android.webkit.JavascriptInterface import android.webkit.WebView +import timber.log.Timber import kotlin.coroutines.resume import kotlin.coroutines.suspendCoroutine @@ -18,7 +21,18 @@ import kotlin.coroutines.suspendCoroutine */ class WebViewPipController( private val webView: WebView, + updatePipOrientation: (orientation: Int?) -> Unit, ) : PipController { + init { + // Register the JavaScript interface ahead of time to ensure it's available when the WebView content is loaded and tries to call it + webView.addJavascriptInterface(PipBridge(updatePipOrientation), "androidPipBridge") + } + + override fun setupPipCallbacks() { + Timber.d("Adding callback in controls.onPipMediaOrientationUpdate") + webView.evaluateJavascript("controls.onPipMediaOrientationUpdate = (orientation) => { androidPipBridge.setPipOrientation(orientation); };", null) + } + override suspend fun canEnterPip(): Boolean { return suspendCoroutine { continuation -> webView.evaluateJavascript("controls.canEnterPip()") { result -> @@ -36,3 +50,19 @@ class WebViewPipController( webView.evaluateJavascript("controls.disablePip()", null) } } + +private class PipBridge( + private val onOrientationChange: (orientation: Int?) -> Unit, +) { + @JavascriptInterface + fun setPipOrientation(orientation: String?) { + Timber.d("Picture-in-picture orientation changed in webview, orientation: $orientation") + // This callback is used to update the picture-in-picture orientation in the WebView when it changes + val orientation = when (orientation) { + "portrait" -> Configuration.ORIENTATION_PORTRAIT + "landscape" -> Configuration.ORIENTATION_LANDSCAPE + else -> return + } + onOrientationChange(orientation) + } +} diff --git a/features/call/impl/src/test/kotlin/io/element/android/features/call/impl/pip/FakePipController.kt b/features/call/impl/src/test/kotlin/io/element/android/features/call/impl/pip/FakePipController.kt index 5153f79c31..f2eca681e9 100644 --- a/features/call/impl/src/test/kotlin/io/element/android/features/call/impl/pip/FakePipController.kt +++ b/features/call/impl/src/test/kotlin/io/element/android/features/call/impl/pip/FakePipController.kt @@ -12,10 +12,13 @@ import io.element.android.features.call.impl.utils.PipController import io.element.android.tests.testutils.lambda.lambdaError class FakePipController( + private val setupPipCallbacksResult: () -> Unit = { lambdaError() }, private val canEnterPipResult: () -> Boolean = { lambdaError() }, private val enterPipResult: () -> Unit = { lambdaError() }, private val exitPipResult: () -> Unit = { lambdaError() }, ) : PipController { + override fun setupPipCallbacks() = setupPipCallbacksResult() + override suspend fun canEnterPip(): Boolean = canEnterPipResult() override fun enterPip() = enterPipResult() diff --git a/features/call/impl/src/test/kotlin/io/element/android/features/call/impl/pip/FakePipView.kt b/features/call/impl/src/test/kotlin/io/element/android/features/call/impl/pip/FakePipView.kt index 55e94312bf..adfcde2c38 100644 --- a/features/call/impl/src/test/kotlin/io/element/android/features/call/impl/pip/FakePipView.kt +++ b/features/call/impl/src/test/kotlin/io/element/android/features/call/impl/pip/FakePipView.kt @@ -13,9 +13,11 @@ import io.element.android.tests.testutils.lambda.lambdaError class FakePipView( private val setPipParamsResult: () -> Unit = { lambdaError() }, private val enterPipModeResult: () -> Boolean = { lambdaError() }, - private val handUpResult: () -> Unit = { lambdaError() } + private val handUpResult: () -> Unit = { lambdaError() }, + private val onOrientationChangeResult: (orientation: Int?) -> Unit = { lambdaError() }, ) : PipView { override fun setPipParams() = setPipParamsResult() override fun enterPipMode(): Boolean = enterPipModeResult() override fun hangUp() = handUpResult() + override fun setPipOrientation(orientation: Int?) = onOrientationChangeResult(orientation) } diff --git a/libraries/androidutils/src/main/kotlin/io/element/android/libraries/androidutils/media/PictureInPictureParamsExt.kt b/libraries/androidutils/src/main/kotlin/io/element/android/libraries/androidutils/media/PictureInPictureParamsExt.kt new file mode 100644 index 0000000000..b17ec0260b --- /dev/null +++ b/libraries/androidutils/src/main/kotlin/io/element/android/libraries/androidutils/media/PictureInPictureParamsExt.kt @@ -0,0 +1,29 @@ +/* + * 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.libraries.androidutils.media + +import android.app.PictureInPictureParams +import android.content.res.Configuration +import android.os.Build +import android.util.Rational +import androidx.annotation.RequiresApi + +/** + * Set the aspect ratio of the Picture-in-Picture mode based on the current orientation of the other user's device. + */ +@RequiresApi(Build.VERSION_CODES.O) +fun PictureInPictureParams.Builder.setAspectRatioFromOrientation(orientation: Int): PictureInPictureParams.Builder { + val aspectRatio = if (orientation == Configuration.ORIENTATION_LANDSCAPE) { + // If landscape orientation, invert the aspect ratio + Rational(5, 3) + } else { + // In any other case, assume portrait orientation + Rational(3, 5) + } + return setAspectRatio(aspectRatio) +}