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..4d29f757ea 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 2c974382d0..ab0b8f49b9 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 { private val isPipSupported = pipSupportProvider.isPipSupported() - private var isInPictureInPicture = mutableStateOf(false) - private var hostActivity: WeakReference? = 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(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 0000000000..998d36da5c --- /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 70ab2e30c2..5c3d02b8ac 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 3af6c7cf95..2ae53d571f 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): List { 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 0000000000..01e23ab727 --- /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 0000000000..2a90965c8c --- /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) + } +} 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 0000000000..086feecd39 --- /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 0000000000..d07eb52c90 --- /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 895505c278..e433a09378 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 { 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 { } + 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 { true } + val enterPipResult = lambdaRecorder { } + val exitPipResult = lambdaRecorder { } + 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 6d15e5001c..ec75b9a1fa 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() - val pipEventsRecorder = EventsRecorder(expectEvents = false) + val pipEventsRecorder = EventsRecorder() 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 } } }