Repository: element-hq/element-x-android (language: kotlin)

Issue:
[PiP] Use new functions expose by Element Call about PiP
Element Call is exposing 3 new functions about PiP:

- `canEnterPip`: will return a boolean to let the application if it is possible to enter the PiP mode. If the call is in error state for instance, the application should not enter PiP but rather hang up the call. So if this function return `true`: enter PiP, else close the call.
- `enablePip` to be called when the Activity is entering PiP, and will let Element Call render the PiP mode correctly
- `disablePip` to be called when the Activity is exiting PiP, so render in fullscreen, and will let Element Call render the fullscreen mode correctly

Ref: https://github.com/element-hq/element-call/pull/2573


Discussion hints:
For your reference, this is where we're using the API on iOS:

https://github.com/element-hq/element-x-ios/blob/dad9618f59c7494dbc69c150f2d71856c1d97c00/ElementX/Sources/Screens/CallScreen/View/CallScreen.swift#L211-L259

And if that `requestPictureInPicture` method fails we then decide to [navigate back](https://github.com/element-hq/element-x-ios/blob/dad9618f59c7494dbc69c150f2d71856c1d97c00/ElementX/Sources/Screens/CallScreen/CallScreenViewModel.swift#L198-L203) to the previous screen.

Pull request: Use new functions exposed by Element Call about PiP


<!-- Please read [CONTRIBUTING.md](https://github.com/element-hq/element-x-android/blob/develop/CONTRIBUTING.md) before submitting your pull request -->
 
## Content

<!-- Describe shortly what has been changed -->
- Communicate with Element Call about PiP status.
- Also only use eventSink to communicate with the Presenter, instead of having public methods.
- Change WeakReference to an Activity to a listener and update tests.

## Motivation and context

<!-- Provide link to the corresponding issue if applicable or explain the context -->
Closes #3326

## Screenshots / GIFs

<!--
We have screenshot tests in the project, so attaching screenshots to a PR is not mandatory, as far as there
is a Composable Preview covering the changes. In this case, the change will appear in the file diff.
Note that all the UI composables should be covered by a Composable Preview.

Providing a video of the change is still very useful for the reviewer and for the history of the project.

You can use a table like this to show screenshots comparison.
Uncomment this markdown table below and edit the last line `|||`:
|copy screenshot of before here|copy screenshot of after here|

|Before|After|
|-|-|
|||
 -->

## Tests

<!-- Explain how you tested your development -->

- Change Element Call url to https://pr2573--element-call.netlify.app/
- Start a call and play with the Pip mode
- Note that if the call is in error, the pip mode will not start, but the call will be terminated. This can be tested when entering pip mode just after starting the call (and this is maybe a thing to fix on Element Call side. The call is not in error, but not fully started yet CC @robintown )

Note: using regular (so not deployed) Element Call should not be impacted by this change.

## Tested devices

- [ ] Physical
- [x] Emulator
- OS version(s):

## Checklist

<!-- Depending on the Pull Request content, it can be acceptable if some of the following checkboxes stay unchecked. -->

- [ ] Changes have been tested on an Android device or Android emulator with API 23
- [ ] UI change has been tested on both light and dark themes
- [ ] Accessibility has been taken into account. See https://github.com/element-hq/element-x-android/blob/develop/CONTRIBUTING.md#accessibility
- [ ] Pull request is based on the develop branch
- [ ] Pull request title will be used in the release note, it clearly define what will change for the user
- [ ] Pull request includes screenshots or videos if containing UI changes
- [ ] Pull request includes a [sign off](https://matrix-org.github.io/synapse/latest/development/contributing_guide.html#sign-off)
- [ ] You've made a self review of your PR


Test command: ./gradlew :features:call:impl:testDebugUnitTest --tests "io.element.android.features.call.impl.ui.CallScreenViewTest" --tests "io.element.android.features.call.impl.pip.PictureInPicturePresenterTest"

Fix patch:
diff --git a/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/pip/PictureInPictureEvents.kt b/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/pip/PictureInPictureEvents.kt
index da3c08da32a..4d29f757ea5 100644
--- a/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/pip/PictureInPictureEvents.kt
+++ b/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/pip/PictureInPictureEvents.kt
@@ -16,6 +16,10 @@
 
 package io.element.android.features.call.impl.pip
 
+import io.element.android.features.call.impl.utils.PipController
+
 sealed interface PictureInPictureEvents {
+    data class SetPipController(val pipController: PipController) : PictureInPictureEvents
     data object EnterPictureInPicture : PictureInPictureEvents
+    data class OnPictureInPictureModeChanged(val isInPip: Boolean) : PictureInPictureEvents
 }
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 2c974382d06..ab0b8f49b9c 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
@@ -16,17 +16,17 @@
 
 package io.element.android.features.call.impl.pip
 
-import android.app.Activity
-import android.app.PictureInPictureParams
-import android.os.Build
-import android.util.Rational
-import androidx.annotation.RequiresApi
 import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
 import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.rememberCoroutineScope
+import androidx.compose.runtime.setValue
+import io.element.android.features.call.impl.utils.PipController
 import io.element.android.libraries.architecture.Presenter
 import io.element.android.libraries.core.log.logger.LoggerTag
+import kotlinx.coroutines.launch
 import timber.log.Timber
-import java.lang.ref.WeakReference
 import javax.inject.Inject
 
 private val loggerTag = LoggerTag("PiP")
@@ -35,71 +35,69 @@ class PictureInPicturePresenter @Inject constructor(
     pipSupportProvider: PipSupportProvider,
 ) : Presenter<PictureInPictureState> {
     private val isPipSupported = pipSupportProvider.isPipSupported()
-    private var isInPictureInPicture = mutableStateOf(false)
-    private var hostActivity: WeakReference<Activity>? = null
+    private var pipView: PipView? = null
 
     @Composable
     override fun present(): PictureInPictureState {
+        val coroutineScope = rememberCoroutineScope()
+        var isInPictureInPicture by remember { mutableStateOf(false) }
+        var pipController by remember { mutableStateOf<PipController?>(null) }
+
         fun handleEvent(event: PictureInPictureEvents) {
             when (event) {
-                PictureInPictureEvents.EnterPictureInPicture -> switchToPip()
+                is PictureInPictureEvents.SetPipController -> {
+                    pipController = event.pipController
+                }
+                PictureInPictureEvents.EnterPictureInPicture -> {
+                    coroutineScope.launch {
+                        switchToPip(pipController)
+                    }
+                }
+                is PictureInPictureEvents.OnPictureInPictureModeChanged -> {
+                    Timber.tag(loggerTag.value).d("onPictureInPictureModeChanged: ${event.isInPip}")
+                    isInPictureInPicture = event.isInPip
+                    if (event.isInPip) {
+                        pipController?.enterPip()
+                    } else {
+                        pipController?.exitPip()
+                    }
+                }
             }
         }
 
         return PictureInPictureState(
             supportPip = isPipSupported,
-            isInPictureInPicture = isInPictureInPicture.value,
+            isInPictureInPicture = isInPictureInPicture,
             eventSink = ::handleEvent,
         )
     }
 
-    fun onCreate(activity: Activity) {
+    fun setPipView(pipView: PipView?) {
         if (isPipSupported) {
-            Timber.tag(loggerTag.value).d("onCreate: Setting PiP params")
-            hostActivity = WeakReference(activity)
-            hostActivity?.get()?.setPictureInPictureParams(getPictureInPictureParams())
+            Timber.tag(loggerTag.value).d("Setting PiP params")
+            this.pipView = pipView
+            pipView?.setPipParams()
         } else {
-            Timber.tag(loggerTag.value).d("onCreate: PiP is not supported")
+            Timber.tag(loggerTag.value).d("setPipView: PiP is not supported")
         }
     }
 
-    fun onDestroy() {
-        Timber.tag(loggerTag.value).d("onDestroy")
-        hostActivity?.clear()
-        hostActivity = null
-    }
-
-    @RequiresApi(Build.VERSION_CODES.O)
-    private fun getPictureInPictureParams(): PictureInPictureParams {
-        return PictureInPictureParams.Builder()
-            // Portrait for calls seems more appropriate
-            .setAspectRatio(Rational(3, 5))
-            .apply {
-                if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
-                    setAutoEnterEnabled(true)
-                }
-            }
-            .build()
-    }
-
     /**
-     * Enters Picture-in-Picture mode.
+     * Enters Picture-in-Picture mode, if allowed by Element Call.
      */
-    private fun switchToPip() {
+    private suspend fun switchToPip(pipController: PipController?) {
         if (isPipSupported) {
-            Timber.tag(loggerTag.value).d("Switch to PiP mode")
-            hostActivity?.get()?.enterPictureInPictureMode(getPictureInPictureParams())
-                ?.also { Timber.tag(loggerTag.value).d("Switch to PiP mode result: $it") }
+            if (pipController == null) {
+                Timber.tag(loggerTag.value).w("webPipApi is not available")
+            }
+            if (pipController == null || pipController.canEnterPip()) {
+                Timber.tag(loggerTag.value).d("Switch to PiP mode")
+                pipView?.enterPipMode()
+                    ?.also { Timber.tag(loggerTag.value).d("Switch to PiP mode result: $it") }
+            } else {
+                Timber.tag(loggerTag.value).w("Cannot enter PiP mode, hangup the call")
+                pipView?.hangUp()
+            }
         }
     }
-
-    fun onPictureInPictureModeChanged(isInPictureInPictureMode: Boolean) {
-        Timber.tag(loggerTag.value).d("onPictureInPictureModeChanged: $isInPictureInPictureMode")
-        isInPictureInPicture.value = isInPictureInPictureMode
-    }
-
-    fun onUserLeaveHint() {
-        Timber.tag(loggerTag.value).d("onUserLeaveHint")
-        switchToPip()
-    }
 }
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
new file mode 100644
index 00000000000..998d36da5c9
--- /dev/null
+++ b/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/pip/PipView.kt
@@ -0,0 +1,23 @@
+/*
+ * Copyright (c) 2024 New Vector Ltd
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package io.element.android.features.call.impl.pip
+
+interface PipView {
+    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 70ab2e30c2d..5c3d02b8ac8 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
@@ -40,6 +40,7 @@ import io.element.android.features.call.impl.pip.PictureInPictureEvents
 import io.element.android.features.call.impl.pip.PictureInPictureState
 import io.element.android.features.call.impl.pip.PictureInPictureStateProvider
 import io.element.android.features.call.impl.pip.aPictureInPictureState
+import io.element.android.features.call.impl.utils.WebViewPipController
 import io.element.android.features.call.impl.utils.WebViewWidgetMessageInterceptor
 import io.element.android.libraries.architecture.AsyncData
 import io.element.android.libraries.designsystem.components.ProgressDialog
@@ -95,9 +96,9 @@ internal fun CallScreenView(
         }
         CallWebView(
             modifier = Modifier
-                .padding(padding)
-                .consumeWindowInsets(padding)
-                .fillMaxSize(),
+                    .padding(padding)
+                    .consumeWindowInsets(padding)
+                    .fillMaxSize(),
             url = state.urlState,
             userAgent = state.userAgent,
             onPermissionsRequest = { request ->
@@ -108,6 +109,8 @@ internal fun CallScreenView(
             onWebViewCreate = { webView ->
                 val interceptor = WebViewWidgetMessageInterceptor(webView)
                 state.eventSink(CallScreenEvents.SetupMessageChannels(interceptor))
+                val pipController = WebViewPipController(webView)
+                pipState.eventSink(PictureInPictureEvents.SetPipController(pipController))
             }
         )
         when (state.urlState) {
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 3af6c7cf95f..2ae53d571f8 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
@@ -17,6 +17,7 @@
 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.media.AudioAttributes
@@ -24,19 +25,30 @@ import android.media.AudioFocusRequest
 import android.media.AudioManager
 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
 import androidx.activity.result.ActivityResultLauncher
 import androidx.activity.result.contract.ActivityResultContracts
+import androidx.annotation.RequiresApi
 import androidx.appcompat.app.AppCompatActivity
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.DisposableEffect
+import androidx.compose.runtime.getValue
 import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.rememberUpdatedState
+import androidx.core.app.PictureInPictureModeChangedInfo
 import androidx.core.content.IntentCompat
+import androidx.core.util.Consumer
 import androidx.lifecycle.Lifecycle
 import io.element.android.features.call.api.CallType
 import io.element.android.features.call.impl.DefaultElementCallEntryPoint
 import io.element.android.features.call.impl.di.CallBindings
+import io.element.android.features.call.impl.pip.PictureInPictureEvents
 import io.element.android.features.call.impl.pip.PictureInPicturePresenter
+import io.element.android.features.call.impl.pip.PictureInPictureState
+import io.element.android.features.call.impl.pip.PipView
 import io.element.android.features.call.impl.services.CallForegroundService
 import io.element.android.features.call.impl.utils.CallIntentDataParser
 import io.element.android.libraries.architecture.bindings
@@ -45,7 +57,10 @@ import io.element.android.libraries.preferences.api.store.AppPreferencesStore
 import timber.log.Timber
 import javax.inject.Inject
 
-class ElementCallActivity : AppCompatActivity(), CallScreenNavigator {
+class ElementCallActivity :
+    AppCompatActivity(),
+    CallScreenNavigator,
+    PipView {
     @Inject lateinit var callIntentDataParser: CallIntentDataParser
     @Inject lateinit var presenterFactory: CallScreenPresenter.Factory
     @Inject lateinit var appPreferencesStore: AppPreferencesStore
@@ -86,13 +101,14 @@ class ElementCallActivity : AppCompatActivity(), CallScreenNavigator {
             updateUiMode(resources.configuration)
         }
 
-        pictureInPicturePresenter.onCreate(this)
+        pictureInPicturePresenter.setPipView(this)
 
         audioManager = getSystemService(AUDIO_SERVICE) as AudioManager
         requestAudioFocus()
 
         setContent {
             val pipState = pictureInPicturePresenter.present()
+            ListenToAndroidEvents(pipState)
             ElementThemeApp(appPreferencesStore) {
                 val state = presenter.present()
                 eventSink = state.eventSink
@@ -108,21 +124,38 @@ class ElementCallActivity : AppCompatActivity(), CallScreenNavigator {
         }
     }
 
+    @Composable
+    private fun ListenToAndroidEvents(pipState: PictureInPictureState) {
+        val pipEventSink by rememberUpdatedState(pipState.eventSink)
+        DisposableEffect(Unit) {
+            val onUserLeaveHintListener = Runnable {
+                pipEventSink(PictureInPictureEvents.EnterPictureInPicture)
+            }
+            addOnUserLeaveHintListener(onUserLeaveHintListener)
+            onDispose {
+                removeOnUserLeaveHintListener(onUserLeaveHintListener)
+            }
+        }
+        DisposableEffect(Unit) {
+            val onPictureInPictureModeChangedListener = Consumer { _: PictureInPictureModeChangedInfo ->
+                pipEventSink(PictureInPictureEvents.OnPictureInPictureModeChanged(isInPictureInPictureMode))
+                if (!isInPictureInPictureMode && !lifecycle.currentState.isAtLeast(Lifecycle.State.STARTED)) {
+                    Timber.d("Exiting PiP mode: Hangup the call")
+                    eventSink?.invoke(CallScreenEvents.Hangup)
+                }
+            }
+            addOnPictureInPictureModeChangedListener(onPictureInPictureModeChangedListener)
+            onDispose {
+                removeOnPictureInPictureModeChangedListener(onPictureInPictureModeChangedListener)
+            }
+        }
+    }
+
     override fun onConfigurationChanged(newConfig: Configuration) {
         super.onConfigurationChanged(newConfig)
         updateUiMode(newConfig)
     }
 
-    override fun onPictureInPictureModeChanged(isInPictureInPictureMode: Boolean, newConfig: Configuration) {
-        super.onPictureInPictureModeChanged(isInPictureInPictureMode, newConfig)
-        pictureInPicturePresenter.onPictureInPictureModeChanged(isInPictureInPictureMode)
-
-        if (!isInPictureInPictureMode && !lifecycle.currentState.isAtLeast(Lifecycle.State.STARTED)) {
-            Timber.d("Exiting PiP mode: Hangup the call")
-            eventSink?.invoke(CallScreenEvents.Hangup)
-        }
-    }
-
     override fun onNewIntent(intent: Intent) {
         super.onNewIntent(intent)
         setCallType(intent)
@@ -140,16 +173,11 @@ class ElementCallActivity : AppCompatActivity(), CallScreenNavigator {
         }
     }
 
-    override fun onUserLeaveHint() {
-        super.onUserLeaveHint()
-        pictureInPicturePresenter.onUserLeaveHint()
-    }
-
     override fun onDestroy() {
         super.onDestroy()
         releaseAudioFocus()
         CallForegroundService.stop(this)
-        pictureInPicturePresenter.onDestroy()
+        pictureInPicturePresenter.setPipView(null)
     }
 
     override fun finish() {
@@ -249,6 +277,33 @@ class ElementCallActivity : AppCompatActivity(), CallScreenNavigator {
             }
         }
     }
+
+    @RequiresApi(Build.VERSION_CODES.O)
+    override fun setPipParams() {
+        setPictureInPictureParams(getPictureInPictureParams())
+    }
+
+    @RequiresApi(Build.VERSION_CODES.O)
+    override fun enterPipMode(): Boolean {
+        return enterPictureInPictureMode(getPictureInPictureParams())
+    }
+
+    @RequiresApi(Build.VERSION_CODES.O)
+    private fun getPictureInPictureParams(): PictureInPictureParams {
+        return PictureInPictureParams.Builder()
+            // Portrait for calls seems more appropriate
+            .setAspectRatio(Rational(3, 5))
+            .apply {
+                if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
+                    setAutoEnterEnabled(true)
+                }
+            }
+            .build()
+    }
+
+    override fun hangUp() {
+        eventSink?.invoke(CallScreenEvents.Hangup)
+    }
 }
 
 internal fun mapWebkitPermissions(permissions: Array<String>): List<String> {
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
new file mode 100644
index 00000000000..01e23ab727b
--- /dev/null
+++ b/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/utils/PipController.kt
@@ -0,0 +1,23 @@
+/*
+ * Copyright (c) 2024 New Vector Ltd
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package io.element.android.features.call.impl.utils
+
+interface PipController {
+    suspend fun canEnterPip(): Boolean
+    fun enterPip()
+    fun exitPip()
+}
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
new file mode 100644
index 00000000000..2a90965c8cf
--- /dev/null
+++ b/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/utils/WebViewPipController.kt
@@ -0,0 +1,42 @@
+/*
+ * Copyright (c) 2024 New Vector Ltd
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package io.element.android.features.call.impl.utils
+
+import android.webkit.WebView
+import kotlin.coroutines.resume
+import kotlin.coroutines.suspendCoroutine
+
+class WebViewPipController(
+    private val webView: WebView,
+) : PipController {
+    override suspend fun canEnterPip(): Boolean {
+        return suspendCoroutine { continuation ->
+            webView.evaluateJavascript("controls.canEnterPip()") { result ->
+                // Note if the method is not available, it will return "null"
+                continuation.resume(result == "true" || result == "null")
+            }
+        }
+    }
+
+    override fun enterPip() {
+        webView.evaluateJavascript("controls.enablePip()", null)
+    }
+
+    override fun exitPip() {
+        webView.evaluateJavascript("controls.disablePip()", null)
+    }
+}


Test patch:
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
new file mode 100644
index 00000000000..086feecd39f
--- /dev/null
+++ b/features/call/impl/src/test/kotlin/io/element/android/features/call/impl/pip/FakePipController.kt
@@ -0,0 +1,32 @@
+/*
+ * Copyright (c) 2024 New Vector Ltd
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package io.element.android.features.call.impl.pip
+
+import io.element.android.features.call.impl.utils.PipController
+import io.element.android.tests.testutils.lambda.lambdaError
+
+class FakePipController(
+    private val canEnterPipResult: () -> Boolean = { lambdaError() },
+    private val enterPipResult: () -> Unit = { lambdaError() },
+    private val exitPipResult: () -> Unit = { lambdaError() },
+) : PipController {
+    override suspend fun canEnterPip(): Boolean = canEnterPipResult()
+
+    override fun enterPip() = enterPipResult()
+
+    override fun exitPip() = exitPipResult()
+}
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
new file mode 100644
index 00000000000..d07eb52c904
--- /dev/null
+++ b/features/call/impl/src/test/kotlin/io/element/android/features/call/impl/pip/FakePipView.kt
@@ -0,0 +1,29 @@
+/*
+ * Copyright (c) 2024 New Vector Ltd
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package io.element.android.features.call.impl.pip
+
+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() }
+) : PipView {
+    override fun setPipParams() = setPipParamsResult()
+    override fun enterPipMode(): Boolean = enterPipModeResult()
+    override fun hangUp() = handUpResult()
+}
diff --git a/features/call/impl/src/test/kotlin/io/element/android/features/call/impl/pip/PictureInPicturePresenterTest.kt b/features/call/impl/src/test/kotlin/io/element/android/features/call/impl/pip/PictureInPicturePresenterTest.kt
index 895505c2788..e433a09378e 100644
--- a/features/call/impl/src/test/kotlin/io/element/android/features/call/impl/pip/PictureInPicturePresenterTest.kt
+++ b/features/call/impl/src/test/kotlin/io/element/android/features/call/impl/pip/PictureInPicturePresenterTest.kt
@@ -16,23 +16,16 @@
 
 package io.element.android.features.call.impl.pip
 
-import android.os.Build.VERSION_CODES
 import app.cash.molecule.RecompositionMode
 import app.cash.molecule.moleculeFlow
 import app.cash.turbine.test
 import com.google.common.truth.Truth.assertThat
-import io.element.android.features.call.impl.ui.ElementCallActivity
+import io.element.android.tests.testutils.lambda.lambdaRecorder
 import kotlinx.coroutines.test.runTest
 import org.junit.Test
-import org.junit.runner.RunWith
-import org.robolectric.Robolectric
-import org.robolectric.RobolectricTestRunner
-import org.robolectric.annotation.Config
 
-@RunWith(RobolectricTestRunner::class)
 class PictureInPicturePresenterTest {
     @Test
-    @Config(sdk = [VERSION_CODES.O, VERSION_CODES.S])
     fun `when pip is not supported, the state value supportPip is false`() = runTest {
         val presenter = createPictureInPicturePresenter(supportPip = false)
         moleculeFlow(RecompositionMode.Immediate) {
@@ -41,68 +34,119 @@ class PictureInPicturePresenterTest {
             val initialState = awaitItem()
             assertThat(initialState.supportPip).isFalse()
         }
-        presenter.onDestroy()
+        presenter.setPipView(null)
     }
 
     @Test
-    @Config(sdk = [VERSION_CODES.O, VERSION_CODES.S])
     fun `when pip is supported, the state value supportPip is true`() = runTest {
-        val presenter = createPictureInPicturePresenter(supportPip = true)
+        val presenter = createPictureInPicturePresenter(
+            supportPip = true,
+            pipView = FakePipView(setPipParamsResult = { }),
+        )
         moleculeFlow(RecompositionMode.Immediate) {
             presenter.present()
         }.test {
             val initialState = awaitItem()
             assertThat(initialState.supportPip).isTrue()
         }
-        presenter.onDestroy()
     }
 
     @Test
-    @Config(sdk = [VERSION_CODES.S])
     fun `when entering pip is supported, the state value isInPictureInPicture is true`() = runTest {
-        val presenter = createPictureInPicturePresenter(supportPip = true)
+        val enterPipModeResult = lambdaRecorder<Boolean> { true }
+        val presenter = createPictureInPicturePresenter(
+            supportPip = true,
+            pipView = FakePipView(
+                setPipParamsResult = { },
+                enterPipModeResult = enterPipModeResult,
+            ),
+        )
         moleculeFlow(RecompositionMode.Immediate) {
             presenter.present()
         }.test {
             val initialState = awaitItem()
             assertThat(initialState.isInPictureInPicture).isFalse()
             initialState.eventSink(PictureInPictureEvents.EnterPictureInPicture)
-            presenter.onPictureInPictureModeChanged(true)
+            enterPipModeResult.assertions().isCalledOnce()
+            initialState.eventSink(PictureInPictureEvents.OnPictureInPictureModeChanged(true))
             val pipState = awaitItem()
             assertThat(pipState.isInPictureInPicture).isTrue()
             // User stops pip
-            presenter.onPictureInPictureModeChanged(false)
+            initialState.eventSink(PictureInPictureEvents.OnPictureInPictureModeChanged(false))
             val finalState = awaitItem()
             assertThat(finalState.isInPictureInPicture).isFalse()
         }
-        presenter.onDestroy()
     }
 
     @Test
-    @Config(sdk = [VERSION_CODES.S])
-    fun `when onUserLeaveHint is called, the state value isInPictureInPicture becomes true`() = runTest {
-        val presenter = createPictureInPicturePresenter(supportPip = true)
+    fun `with webPipApi, when entering pip is supported, but web deny it, the call is finished`() = runTest {
+        val handUpResult = lambdaRecorder<Unit> { }
+        val presenter = createPictureInPicturePresenter(
+            supportPip = true,
+            pipView = FakePipView(
+                setPipParamsResult = { },
+                handUpResult = handUpResult
+            ),
+        )
         moleculeFlow(RecompositionMode.Immediate) {
             presenter.present()
         }.test {
             val initialState = awaitItem()
-            assertThat(initialState.isInPictureInPicture).isFalse()
-            presenter.onUserLeaveHint()
-            presenter.onPictureInPictureModeChanged(true)
+            initialState.eventSink(PictureInPictureEvents.SetPipController(FakePipController(canEnterPipResult = { false })))
+            initialState.eventSink(PictureInPictureEvents.EnterPictureInPicture)
+            handUpResult.assertions().isCalledOnce()
+        }
+    }
+
+    @Test
+    fun `with webPipApi, when entering pip is supported, and web allows it, the state value isInPictureInPicture is true`() = runTest {
+        val enterPipModeResult = lambdaRecorder<Boolean> { true }
+        val enterPipResult = lambdaRecorder<Unit> { }
+        val exitPipResult = lambdaRecorder<Unit> { }
+        val presenter = createPictureInPicturePresenter(
+            supportPip = true,
+            pipView = FakePipView(
+                setPipParamsResult = { },
+                enterPipModeResult = enterPipModeResult
+            ),
+        )
+        moleculeFlow(RecompositionMode.Immediate) {
+            presenter.present()
+        }.test {
+            val initialState = awaitItem()
+            initialState.eventSink(
+                PictureInPictureEvents.SetPipController(
+                    FakePipController(
+                        canEnterPipResult = { true },
+                        enterPipResult = enterPipResult,
+                        exitPipResult = exitPipResult,
+                    )
+                )
+            )
+            initialState.eventSink(PictureInPictureEvents.EnterPictureInPicture)
+            enterPipModeResult.assertions().isCalledOnce()
+            enterPipResult.assertions().isNeverCalled()
+            initialState.eventSink(PictureInPictureEvents.OnPictureInPictureModeChanged(true))
             val pipState = awaitItem()
             assertThat(pipState.isInPictureInPicture).isTrue()
+            enterPipResult.assertions().isCalledOnce()
+            // User stops pip
+            exitPipResult.assertions().isNeverCalled()
+            initialState.eventSink(PictureInPictureEvents.OnPictureInPictureModeChanged(false))
+            val finalState = awaitItem()
+            assertThat(finalState.isInPictureInPicture).isFalse()
+            exitPipResult.assertions().isCalledOnce()
         }
-        presenter.onDestroy()
     }
 
     private fun createPictureInPicturePresenter(
         supportPip: Boolean = true,
+        pipView: PipView? = FakePipView()
     ): PictureInPicturePresenter {
-        val activity = Robolectric.buildActivity(ElementCallActivity::class.java)
         return PictureInPicturePresenter(
             pipSupportProvider = FakePipSupportProvider(supportPip),
         ).apply {
-            onCreate(activity.get())
+            setPipView(pipView)
         }
     }
 }
diff --git a/features/call/impl/src/test/kotlin/io/element/android/features/call/impl/ui/CallScreenViewTest.kt b/features/call/impl/src/test/kotlin/io/element/android/features/call/impl/ui/CallScreenViewTest.kt
index 6d15e5001c9..ec75b9a1fa3 100644
--- a/features/call/impl/src/test/kotlin/io/element/android/features/call/impl/ui/CallScreenViewTest.kt
+++ b/features/call/impl/src/test/kotlin/io/element/android/features/call/impl/ui/CallScreenViewTest.kt
@@ -37,7 +37,7 @@ class CallScreenViewTest {
     @Test
     fun `clicking on back when pip is not supported hangs up`() {
         val eventsRecorder = EventsRecorder<CallScreenEvents>()
-        val pipEventsRecorder = EventsRecorder<PictureInPictureEvents>(expectEvents = false)
+        val pipEventsRecorder = EventsRecorder<PictureInPictureEvents>()
         rule.setCallScreenView(
             aCallScreenState(
                 eventSink = eventsRecorder
@@ -51,6 +51,8 @@ class CallScreenViewTest {
         eventsRecorder.assertSize(2)
         eventsRecorder.assertTrue(0) { it is CallScreenEvents.SetupMessageChannels }
         eventsRecorder.assertTrue(1) { it == CallScreenEvents.Hangup }
+        pipEventsRecorder.assertSize(1)
+        pipEventsRecorder.assertTrue(0) { it is PictureInPictureEvents.SetPipController }
     }
 
     @Test
@@ -69,7 +71,9 @@ class CallScreenViewTest {
         rule.pressBack()
         eventsRecorder.assertSize(1)
         eventsRecorder.assertTrue(0) { it is CallScreenEvents.SetupMessageChannels }
-        pipEventsRecorder.assertSingle(PictureInPictureEvents.EnterPictureInPicture)
+        pipEventsRecorder.assertSize(2)
+        pipEventsRecorder.assertTrue(0) { it is PictureInPictureEvents.SetPipController }
+        pipEventsRecorder.assertTrue(1) { it == PictureInPictureEvents.EnterPictureInPicture }
     }
 }