From 508e9106e2dffbc08ca8613d37d895e8e361994c Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Fri, 23 Aug 2024 16:17:57 +0200 Subject: [PATCH] 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. --- .../call/impl/pip/PictureInPictureEvents.kt | 4 + .../impl/pip/PictureInPicturePresenter.kt | 94 +++++++++--------- .../features/call/impl/pip/PipActivity.kt | 23 +++++ .../features/call/impl/ui/CallScreenView.kt | 3 + .../call/impl/ui/ElementCallActivity.kt | 51 +++++++++- .../features/call/impl/utils/WebPipApi.kt | 23 +++++ .../call/impl/utils/WebViewWebPipApi.kt | 41 ++++++++ .../features/call/impl/pip/FakePipActivity.kt | 29 ++++++ .../features/call/impl/pip/FakeWebPipApi.kt | 32 +++++++ .../impl/pip/PictureInPicturePresenterTest.kt | 96 ++++++++++++++----- 10 files changed, 317 insertions(+), 79 deletions(-) create mode 100644 features/call/impl/src/main/kotlin/io/element/android/features/call/impl/pip/PipActivity.kt create mode 100644 features/call/impl/src/main/kotlin/io/element/android/features/call/impl/utils/WebPipApi.kt create mode 100644 features/call/impl/src/main/kotlin/io/element/android/features/call/impl/utils/WebViewWebPipApi.kt create mode 100644 features/call/impl/src/test/kotlin/io/element/android/features/call/impl/pip/FakePipActivity.kt create mode 100644 features/call/impl/src/test/kotlin/io/element/android/features/call/impl/pip/FakeWebPipApi.kt 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 da3c08da32..be376aa079 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.WebPipApi + sealed interface PictureInPictureEvents { + data class SetupWebPipApi(val webPipApi: WebPipApi) : 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 2c974382d0..78d347d715 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.WebPipApi 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 { private val isPipSupported = pipSupportProvider.isPipSupported() - private var isInPictureInPicture = mutableStateOf(false) - private var hostActivity: WeakReference? = null + private var pipActivity: PipActivity? = null @Composable override fun present(): PictureInPictureState { + val coroutineScope = rememberCoroutineScope() + var isInPictureInPicture by remember { mutableStateOf(false) } + var webPipApi by remember { mutableStateOf(null) } + fun handleEvent(event: PictureInPictureEvents) { when (event) { - PictureInPictureEvents.EnterPictureInPicture -> switchToPip() + is PictureInPictureEvents.SetupWebPipApi -> { + webPipApi = event.webPipApi + } + PictureInPictureEvents.EnterPictureInPicture -> { + coroutineScope.launch { + switchToPip(webPipApi) + } + } + is PictureInPictureEvents.OnPictureInPictureModeChanged -> { + Timber.tag(loggerTag.value).d("onPictureInPictureModeChanged: ${event.isInPip}") + isInPictureInPicture = event.isInPip + if (event.isInPip) { + webPipApi?.enterPip() + } else { + webPipApi?.exitPip() + } + } } } return PictureInPictureState( supportPip = isPipSupported, - isInPictureInPicture = isInPictureInPicture.value, + isInPictureInPicture = isInPictureInPicture, eventSink = ::handleEvent, ) } - fun onCreate(activity: Activity) { + fun setPipActivity(pipActivity: PipActivity?) { 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.pipActivity = pipActivity + pipActivity?.setPipParams() } else { Timber.tag(loggerTag.value).d("onCreate: 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(webPipApi: WebPipApi?) { 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 (webPipApi == null) { + Timber.tag(loggerTag.value).w("webPipApi is not available") + } + if (webPipApi == null || webPipApi.canEnterPip()) { + Timber.tag(loggerTag.value).d("Switch to PiP mode") + pipActivity?.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") + pipActivity?.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/PipActivity.kt b/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/pip/PipActivity.kt new file mode 100644 index 0000000000..6b86988ae2 --- /dev/null +++ b/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/pip/PipActivity.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 PipActivity { + 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 70ab2e30c2..e81d386dc5 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.WebViewWebPipApi import io.element.android.features.call.impl.utils.WebViewWidgetMessageInterceptor import io.element.android.libraries.architecture.AsyncData import io.element.android.libraries.designsystem.components.ProgressDialog @@ -108,6 +109,8 @@ internal fun CallScreenView( onWebViewCreate = { webView -> val interceptor = WebViewWidgetMessageInterceptor(webView) state.eventSink(CallScreenEvents.SetupMessageChannels(interceptor)) + val webPipApi = WebViewWebPipApi(webView) + pipState.eventSink(PictureInPictureEvents.SetupWebPipApi(webPipApi)) } ) 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 3af6c7cf95..aace025bb4 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,11 +25,13 @@ 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.mutableStateOf import androidx.core.content.IntentCompat @@ -36,7 +39,9 @@ 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.PipActivity 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 +50,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, + PipActivity { @Inject lateinit var callIntentDataParser: CallIntentDataParser @Inject lateinit var presenterFactory: CallScreenPresenter.Factory @Inject lateinit var appPreferencesStore: AppPreferencesStore @@ -66,6 +74,7 @@ class ElementCallActivity : AppCompatActivity(), CallScreenNavigator { private val webViewTarget = mutableStateOf(null) private var eventSink: ((CallScreenEvents) -> Unit)? = null + private var pipEventSink: ((PictureInPictureEvents) -> Unit)? = null override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) @@ -86,13 +95,14 @@ class ElementCallActivity : AppCompatActivity(), CallScreenNavigator { updateUiMode(resources.configuration) } - pictureInPicturePresenter.onCreate(this) + pictureInPicturePresenter.setPipActivity(this) audioManager = getSystemService(AUDIO_SERVICE) as AudioManager requestAudioFocus() setContent { val pipState = pictureInPicturePresenter.present() + pipEventSink = pipState.eventSink ElementThemeApp(appPreferencesStore) { val state = presenter.present() eventSink = state.eventSink @@ -115,7 +125,7 @@ class ElementCallActivity : AppCompatActivity(), CallScreenNavigator { override fun onPictureInPictureModeChanged(isInPictureInPictureMode: Boolean, newConfig: Configuration) { super.onPictureInPictureModeChanged(isInPictureInPictureMode, newConfig) - pictureInPicturePresenter.onPictureInPictureModeChanged(isInPictureInPictureMode) + pipEventSink?.invoke(PictureInPictureEvents.OnPictureInPictureModeChanged(isInPictureInPictureMode)) if (!isInPictureInPictureMode && !lifecycle.currentState.isAtLeast(Lifecycle.State.STARTED)) { Timber.d("Exiting PiP mode: Hangup the call") @@ -142,14 +152,14 @@ class ElementCallActivity : AppCompatActivity(), CallScreenNavigator { override fun onUserLeaveHint() { super.onUserLeaveHint() - pictureInPicturePresenter.onUserLeaveHint() + pipEventSink?.invoke(PictureInPictureEvents.EnterPictureInPicture) } override fun onDestroy() { super.onDestroy() releaseAudioFocus() CallForegroundService.stop(this) - pictureInPicturePresenter.onDestroy() + pictureInPicturePresenter.setPipActivity(null) } override fun finish() { @@ -249,6 +259,37 @@ class ElementCallActivity : AppCompatActivity(), CallScreenNavigator { } } } + + override fun setPipParams() { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + setPictureInPictureParams(getPictureInPictureParams()) + } + } + + override fun enterPipMode(): Boolean { + return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + enterPictureInPictureMode(getPictureInPictureParams()) + } else { + false + } + } + + @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): List { diff --git a/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/utils/WebPipApi.kt b/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/utils/WebPipApi.kt new file mode 100644 index 0000000000..af1cc6b3f9 --- /dev/null +++ b/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/utils/WebPipApi.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 WebPipApi { + suspend fun canEnterPip(): Boolean + fun enterPip() + fun exitPip() +} diff --git a/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/utils/WebViewWebPipApi.kt b/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/utils/WebViewWebPipApi.kt new file mode 100644 index 0000000000..43b7dfadde --- /dev/null +++ b/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/utils/WebViewWebPipApi.kt @@ -0,0 +1,41 @@ +/* + * 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 WebViewWebPipApi( + private val webView: WebView, +) : WebPipApi { + override suspend fun canEnterPip(): Boolean { + return suspendCoroutine { continuation -> + webView.evaluateJavascript("controls.canEnterPip()") { result -> + continuation.resume(result == "true") + } + } + } + + override fun enterPip() { + webView.evaluateJavascript("controls.enablePip()", null) + } + + override fun exitPip() { + webView.evaluateJavascript("controls.disablePip()", null) + } +} diff --git a/features/call/impl/src/test/kotlin/io/element/android/features/call/impl/pip/FakePipActivity.kt b/features/call/impl/src/test/kotlin/io/element/android/features/call/impl/pip/FakePipActivity.kt new file mode 100644 index 0000000000..8a3089453e --- /dev/null +++ b/features/call/impl/src/test/kotlin/io/element/android/features/call/impl/pip/FakePipActivity.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 FakePipActivity( + private val setPipParamsResult: () -> Unit = { lambdaError() }, + private val enterPipModeResult: () -> Boolean = { lambdaError() }, + private val handUpResult: () -> Unit = { lambdaError() } +) : PipActivity { + 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/FakeWebPipApi.kt b/features/call/impl/src/test/kotlin/io/element/android/features/call/impl/pip/FakeWebPipApi.kt new file mode 100644 index 0000000000..ca752cd8ce --- /dev/null +++ b/features/call/impl/src/test/kotlin/io/element/android/features/call/impl/pip/FakeWebPipApi.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.WebPipApi +import io.element.android.tests.testutils.lambda.lambdaError + +class FakeWebPipApi( + private val canEnterPipResult: () -> Boolean = { lambdaError() }, + private val enterPipResult: () -> Unit = { lambdaError() }, + private val exitPipResult: () -> Unit = { lambdaError() }, +) : WebPipApi { + 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/PictureInPicturePresenterTest.kt b/features/call/impl/src/test/kotlin/io/element/android/features/call/impl/pip/PictureInPicturePresenterTest.kt index 895505c278..2343f2cb70 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.setPipActivity(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, + pipActivity = FakePipActivity(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 { true } + val presenter = createPictureInPicturePresenter( + supportPip = true, + pipActivity = FakePipActivity( + 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 { } + val presenter = createPictureInPicturePresenter( + supportPip = true, + pipActivity = FakePipActivity( + setPipParamsResult = { }, + handUpResult = handUpResult + ), + ) moleculeFlow(RecompositionMode.Immediate) { presenter.present() }.test { val initialState = awaitItem() - assertThat(initialState.isInPictureInPicture).isFalse() - presenter.onUserLeaveHint() - presenter.onPictureInPictureModeChanged(true) + initialState.eventSink(PictureInPictureEvents.SetupWebPipApi(FakeWebPipApi(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 { true } + val enterPipResult = lambdaRecorder { } + val exitPipResult = lambdaRecorder { } + val presenter = createPictureInPicturePresenter( + supportPip = true, + pipActivity = FakePipActivity( + setPipParamsResult = { }, + enterPipModeResult = enterPipModeResult + ), + ) + moleculeFlow(RecompositionMode.Immediate) { + presenter.present() + }.test { + val initialState = awaitItem() + initialState.eventSink( + PictureInPictureEvents.SetupWebPipApi( + FakeWebPipApi( + 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, + pipActivity: PipActivity? = FakePipActivity() ): PictureInPicturePresenter { - val activity = Robolectric.buildActivity(ElementCallActivity::class.java) return PictureInPicturePresenter( pipSupportProvider = FakePipSupportProvider(supportPip), ).apply { - onCreate(activity.get()) + setPipActivity(pipActivity) } } }