Browse Source

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.
pull/3334/head
Benoit Marty 4 weeks ago committed by Benoit Marty
parent
commit
18dcdc0e64
  1. 4
      features/call/impl/src/main/kotlin/io/element/android/features/call/impl/pip/PictureInPictureEvents.kt
  2. 94
      features/call/impl/src/main/kotlin/io/element/android/features/call/impl/pip/PictureInPicturePresenter.kt
  3. 23
      features/call/impl/src/main/kotlin/io/element/android/features/call/impl/pip/PipActivity.kt
  4. 3
      features/call/impl/src/main/kotlin/io/element/android/features/call/impl/ui/CallScreenView.kt
  5. 51
      features/call/impl/src/main/kotlin/io/element/android/features/call/impl/ui/ElementCallActivity.kt
  6. 23
      features/call/impl/src/main/kotlin/io/element/android/features/call/impl/utils/WebPipApi.kt
  7. 41
      features/call/impl/src/main/kotlin/io/element/android/features/call/impl/utils/WebViewWebPipApi.kt
  8. 29
      features/call/impl/src/test/kotlin/io/element/android/features/call/impl/pip/FakePipActivity.kt
  9. 32
      features/call/impl/src/test/kotlin/io/element/android/features/call/impl/pip/FakeWebPipApi.kt
  10. 96
      features/call/impl/src/test/kotlin/io/element/android/features/call/impl/pip/PictureInPicturePresenterTest.kt

4
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 package io.element.android.features.call.impl.pip
import io.element.android.features.call.impl.utils.WebPipApi
sealed interface PictureInPictureEvents { sealed interface PictureInPictureEvents {
data class SetupWebPipApi(val webPipApi: WebPipApi) : PictureInPictureEvents
data object EnterPictureInPicture : PictureInPictureEvents data object EnterPictureInPicture : PictureInPictureEvents
data class OnPictureInPictureModeChanged(val isInPip: Boolean) : PictureInPictureEvents
} }

94
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 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.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf 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.architecture.Presenter
import io.element.android.libraries.core.log.logger.LoggerTag import io.element.android.libraries.core.log.logger.LoggerTag
import kotlinx.coroutines.launch
import timber.log.Timber import timber.log.Timber
import java.lang.ref.WeakReference
import javax.inject.Inject import javax.inject.Inject
private val loggerTag = LoggerTag("PiP") private val loggerTag = LoggerTag("PiP")
@ -35,71 +35,69 @@ class PictureInPicturePresenter @Inject constructor(
pipSupportProvider: PipSupportProvider, pipSupportProvider: PipSupportProvider,
) : Presenter<PictureInPictureState> { ) : Presenter<PictureInPictureState> {
private val isPipSupported = pipSupportProvider.isPipSupported() private val isPipSupported = pipSupportProvider.isPipSupported()
private var isInPictureInPicture = mutableStateOf(false) private var pipActivity: PipActivity? = null
private var hostActivity: WeakReference<Activity>? = null
@Composable @Composable
override fun present(): PictureInPictureState { override fun present(): PictureInPictureState {
val coroutineScope = rememberCoroutineScope()
var isInPictureInPicture by remember { mutableStateOf(false) }
var webPipApi by remember { mutableStateOf<WebPipApi?>(null) }
fun handleEvent(event: PictureInPictureEvents) { fun handleEvent(event: PictureInPictureEvents) {
when (event) { 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( return PictureInPictureState(
supportPip = isPipSupported, supportPip = isPipSupported,
isInPictureInPicture = isInPictureInPicture.value, isInPictureInPicture = isInPictureInPicture,
eventSink = ::handleEvent, eventSink = ::handleEvent,
) )
} }
fun onCreate(activity: Activity) { fun setPipActivity(pipActivity: PipActivity?) {
if (isPipSupported) { if (isPipSupported) {
Timber.tag(loggerTag.value).d("onCreate: Setting PiP params") Timber.tag(loggerTag.value).d("Setting PiP params")
hostActivity = WeakReference(activity) this.pipActivity = pipActivity
hostActivity?.get()?.setPictureInPictureParams(getPictureInPictureParams()) pipActivity?.setPipParams()
} else { } else {
Timber.tag(loggerTag.value).d("onCreate: PiP is not supported") 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) { if (isPipSupported) {
Timber.tag(loggerTag.value).d("Switch to PiP mode") if (webPipApi == null) {
hostActivity?.get()?.enterPictureInPictureMode(getPictureInPictureParams()) Timber.tag(loggerTag.value).w("webPipApi is not available")
?.also { Timber.tag(loggerTag.value).d("Switch to PiP mode result: $it") } }
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()
}
} }

23
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()
}

3
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.PictureInPictureState
import io.element.android.features.call.impl.pip.PictureInPictureStateProvider 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.pip.aPictureInPictureState
import io.element.android.features.call.impl.utils.WebViewWebPipApi
import io.element.android.features.call.impl.utils.WebViewWidgetMessageInterceptor import io.element.android.features.call.impl.utils.WebViewWidgetMessageInterceptor
import io.element.android.libraries.architecture.AsyncData import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.designsystem.components.ProgressDialog import io.element.android.libraries.designsystem.components.ProgressDialog
@ -108,6 +109,8 @@ internal fun CallScreenView(
onWebViewCreate = { webView -> onWebViewCreate = { webView ->
val interceptor = WebViewWidgetMessageInterceptor(webView) val interceptor = WebViewWidgetMessageInterceptor(webView)
state.eventSink(CallScreenEvents.SetupMessageChannels(interceptor)) state.eventSink(CallScreenEvents.SetupMessageChannels(interceptor))
val webPipApi = WebViewWebPipApi(webView)
pipState.eventSink(PictureInPictureEvents.SetupWebPipApi(webPipApi))
} }
) )
when (state.urlState) { when (state.urlState) {

51
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 package io.element.android.features.call.impl.ui
import android.Manifest import android.Manifest
import android.app.PictureInPictureParams
import android.content.Intent import android.content.Intent
import android.content.res.Configuration import android.content.res.Configuration
import android.media.AudioAttributes import android.media.AudioAttributes
@ -24,11 +25,13 @@ import android.media.AudioFocusRequest
import android.media.AudioManager import android.media.AudioManager
import android.os.Build import android.os.Build
import android.os.Bundle import android.os.Bundle
import android.util.Rational
import android.view.WindowManager import android.view.WindowManager
import android.webkit.PermissionRequest import android.webkit.PermissionRequest
import androidx.activity.compose.setContent import androidx.activity.compose.setContent
import androidx.activity.result.ActivityResultLauncher import androidx.activity.result.ActivityResultLauncher
import androidx.activity.result.contract.ActivityResultContracts import androidx.activity.result.contract.ActivityResultContracts
import androidx.annotation.RequiresApi
import androidx.appcompat.app.AppCompatActivity import androidx.appcompat.app.AppCompatActivity
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.core.content.IntentCompat 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.api.CallType
import io.element.android.features.call.impl.DefaultElementCallEntryPoint import io.element.android.features.call.impl.DefaultElementCallEntryPoint
import io.element.android.features.call.impl.di.CallBindings 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.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.services.CallForegroundService
import io.element.android.features.call.impl.utils.CallIntentDataParser import io.element.android.features.call.impl.utils.CallIntentDataParser
import io.element.android.libraries.architecture.bindings 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 timber.log.Timber
import javax.inject.Inject import javax.inject.Inject
class ElementCallActivity : AppCompatActivity(), CallScreenNavigator { class ElementCallActivity :
AppCompatActivity(),
CallScreenNavigator,
PipActivity {
@Inject lateinit var callIntentDataParser: CallIntentDataParser @Inject lateinit var callIntentDataParser: CallIntentDataParser
@Inject lateinit var presenterFactory: CallScreenPresenter.Factory @Inject lateinit var presenterFactory: CallScreenPresenter.Factory
@Inject lateinit var appPreferencesStore: AppPreferencesStore @Inject lateinit var appPreferencesStore: AppPreferencesStore
@ -66,6 +74,7 @@ class ElementCallActivity : AppCompatActivity(), CallScreenNavigator {
private val webViewTarget = mutableStateOf<CallType?>(null) private val webViewTarget = mutableStateOf<CallType?>(null)
private var eventSink: ((CallScreenEvents) -> Unit)? = null private var eventSink: ((CallScreenEvents) -> Unit)? = null
private var pipEventSink: ((PictureInPictureEvents) -> Unit)? = null
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
@ -86,13 +95,14 @@ class ElementCallActivity : AppCompatActivity(), CallScreenNavigator {
updateUiMode(resources.configuration) updateUiMode(resources.configuration)
} }
pictureInPicturePresenter.onCreate(this) pictureInPicturePresenter.setPipActivity(this)
audioManager = getSystemService(AUDIO_SERVICE) as AudioManager audioManager = getSystemService(AUDIO_SERVICE) as AudioManager
requestAudioFocus() requestAudioFocus()
setContent { setContent {
val pipState = pictureInPicturePresenter.present() val pipState = pictureInPicturePresenter.present()
pipEventSink = pipState.eventSink
ElementThemeApp(appPreferencesStore) { ElementThemeApp(appPreferencesStore) {
val state = presenter.present() val state = presenter.present()
eventSink = state.eventSink eventSink = state.eventSink
@ -115,7 +125,7 @@ class ElementCallActivity : AppCompatActivity(), CallScreenNavigator {
override fun onPictureInPictureModeChanged(isInPictureInPictureMode: Boolean, newConfig: Configuration) { override fun onPictureInPictureModeChanged(isInPictureInPictureMode: Boolean, newConfig: Configuration) {
super.onPictureInPictureModeChanged(isInPictureInPictureMode, newConfig) super.onPictureInPictureModeChanged(isInPictureInPictureMode, newConfig)
pictureInPicturePresenter.onPictureInPictureModeChanged(isInPictureInPictureMode) pipEventSink?.invoke(PictureInPictureEvents.OnPictureInPictureModeChanged(isInPictureInPictureMode))
if (!isInPictureInPictureMode && !lifecycle.currentState.isAtLeast(Lifecycle.State.STARTED)) { if (!isInPictureInPictureMode && !lifecycle.currentState.isAtLeast(Lifecycle.State.STARTED)) {
Timber.d("Exiting PiP mode: Hangup the call") Timber.d("Exiting PiP mode: Hangup the call")
@ -142,14 +152,14 @@ class ElementCallActivity : AppCompatActivity(), CallScreenNavigator {
override fun onUserLeaveHint() { override fun onUserLeaveHint() {
super.onUserLeaveHint() super.onUserLeaveHint()
pictureInPicturePresenter.onUserLeaveHint() pipEventSink?.invoke(PictureInPictureEvents.EnterPictureInPicture)
} }
override fun onDestroy() { override fun onDestroy() {
super.onDestroy() super.onDestroy()
releaseAudioFocus() releaseAudioFocus()
CallForegroundService.stop(this) CallForegroundService.stop(this)
pictureInPicturePresenter.onDestroy() pictureInPicturePresenter.setPipActivity(null)
} }
override fun finish() { 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<String>): List<String> { internal fun mapWebkitPermissions(permissions: Array<String>): List<String> {

23
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()
}

41
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)
}
}

29
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()
}

32
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()
}

96
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 package io.element.android.features.call.impl.pip
import android.os.Build.VERSION_CODES
import app.cash.molecule.RecompositionMode import app.cash.molecule.RecompositionMode
import app.cash.molecule.moleculeFlow import app.cash.molecule.moleculeFlow
import app.cash.turbine.test import app.cash.turbine.test
import com.google.common.truth.Truth.assertThat 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 kotlinx.coroutines.test.runTest
import org.junit.Test 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 { class PictureInPicturePresenterTest {
@Test @Test
@Config(sdk = [VERSION_CODES.O, VERSION_CODES.S])
fun `when pip is not supported, the state value supportPip is false`() = runTest { fun `when pip is not supported, the state value supportPip is false`() = runTest {
val presenter = createPictureInPicturePresenter(supportPip = false) val presenter = createPictureInPicturePresenter(supportPip = false)
moleculeFlow(RecompositionMode.Immediate) { moleculeFlow(RecompositionMode.Immediate) {
@ -41,68 +34,119 @@ class PictureInPicturePresenterTest {
val initialState = awaitItem() val initialState = awaitItem()
assertThat(initialState.supportPip).isFalse() assertThat(initialState.supportPip).isFalse()
} }
presenter.onDestroy() presenter.setPipActivity(null)
} }
@Test @Test
@Config(sdk = [VERSION_CODES.O, VERSION_CODES.S])
fun `when pip is supported, the state value supportPip is true`() = runTest { 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) { moleculeFlow(RecompositionMode.Immediate) {
presenter.present() presenter.present()
}.test { }.test {
val initialState = awaitItem() val initialState = awaitItem()
assertThat(initialState.supportPip).isTrue() assertThat(initialState.supportPip).isTrue()
} }
presenter.onDestroy()
} }
@Test @Test
@Config(sdk = [VERSION_CODES.S])
fun `when entering pip is supported, the state value isInPictureInPicture is true`() = runTest { 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,
pipActivity = FakePipActivity(
setPipParamsResult = { },
enterPipModeResult = enterPipModeResult,
),
)
moleculeFlow(RecompositionMode.Immediate) { moleculeFlow(RecompositionMode.Immediate) {
presenter.present() presenter.present()
}.test { }.test {
val initialState = awaitItem() val initialState = awaitItem()
assertThat(initialState.isInPictureInPicture).isFalse() assertThat(initialState.isInPictureInPicture).isFalse()
initialState.eventSink(PictureInPictureEvents.EnterPictureInPicture) initialState.eventSink(PictureInPictureEvents.EnterPictureInPicture)
presenter.onPictureInPictureModeChanged(true) enterPipModeResult.assertions().isCalledOnce()
initialState.eventSink(PictureInPictureEvents.OnPictureInPictureModeChanged(true))
val pipState = awaitItem() val pipState = awaitItem()
assertThat(pipState.isInPictureInPicture).isTrue() assertThat(pipState.isInPictureInPicture).isTrue()
// User stops pip // User stops pip
presenter.onPictureInPictureModeChanged(false) initialState.eventSink(PictureInPictureEvents.OnPictureInPictureModeChanged(false))
val finalState = awaitItem() val finalState = awaitItem()
assertThat(finalState.isInPictureInPicture).isFalse() assertThat(finalState.isInPictureInPicture).isFalse()
} }
presenter.onDestroy()
} }
@Test @Test
@Config(sdk = [VERSION_CODES.S]) fun `with webPipApi, when entering pip is supported, but web deny it, the call is finished`() = runTest {
fun `when onUserLeaveHint is called, the state value isInPictureInPicture becomes true`() = runTest { val handUpResult = lambdaRecorder<Unit> { }
val presenter = createPictureInPicturePresenter(supportPip = true) val presenter = createPictureInPicturePresenter(
supportPip = true,
pipActivity = FakePipActivity(
setPipParamsResult = { },
handUpResult = handUpResult
),
)
moleculeFlow(RecompositionMode.Immediate) { moleculeFlow(RecompositionMode.Immediate) {
presenter.present() presenter.present()
}.test { }.test {
val initialState = awaitItem() val initialState = awaitItem()
assertThat(initialState.isInPictureInPicture).isFalse() initialState.eventSink(PictureInPictureEvents.SetupWebPipApi(FakeWebPipApi(canEnterPipResult = { false })))
presenter.onUserLeaveHint() initialState.eventSink(PictureInPictureEvents.EnterPictureInPicture)
presenter.onPictureInPictureModeChanged(true) 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,
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() val pipState = awaitItem()
assertThat(pipState.isInPictureInPicture).isTrue() 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( private fun createPictureInPicturePresenter(
supportPip: Boolean = true, supportPip: Boolean = true,
pipActivity: PipActivity? = FakePipActivity()
): PictureInPicturePresenter { ): PictureInPicturePresenter {
val activity = Robolectric.buildActivity(ElementCallActivity::class.java)
return PictureInPicturePresenter( return PictureInPicturePresenter(
pipSupportProvider = FakePipSupportProvider(supportPip), pipSupportProvider = FakePipSupportProvider(supportPip),
).apply { ).apply {
onCreate(activity.get()) setPipActivity(pipActivity)
} }
} }
} }

Loading…
Cancel
Save