Merge pull request #3334 from element-hq/feature/bma/pipCallApi
Use new functions exposed by Element Call about PiP
This commit is contained in:
commit
0c99c71de5
11 changed files with 355 additions and 98 deletions
|
|
@ -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.PipController
|
||||||
|
|
||||||
sealed interface PictureInPictureEvents {
|
sealed interface PictureInPictureEvents {
|
||||||
|
data class SetPipController(val pipController: PipController) : PictureInPictureEvents
|
||||||
data object EnterPictureInPicture : PictureInPictureEvents
|
data object EnterPictureInPicture : PictureInPictureEvents
|
||||||
|
data class OnPictureInPictureModeChanged(val isInPip: Boolean) : PictureInPictureEvents
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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.PipController
|
||||||
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 pipView: PipView? = 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 pipController by remember { mutableStateOf<PipController?>(null) }
|
||||||
|
|
||||||
fun handleEvent(event: PictureInPictureEvents) {
|
fun handleEvent(event: PictureInPictureEvents) {
|
||||||
when (event) {
|
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(
|
return PictureInPictureState(
|
||||||
supportPip = isPipSupported,
|
supportPip = isPipSupported,
|
||||||
isInPictureInPicture = isInPictureInPicture.value,
|
isInPictureInPicture = isInPictureInPicture,
|
||||||
eventSink = ::handleEvent,
|
eventSink = ::handleEvent,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun onCreate(activity: Activity) {
|
fun setPipView(pipView: PipView?) {
|
||||||
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.pipView = pipView
|
||||||
hostActivity?.get()?.setPictureInPictureParams(getPictureInPictureParams())
|
pipView?.setPipParams()
|
||||||
} else {
|
} 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) {
|
if (isPipSupported) {
|
||||||
Timber.tag(loggerTag.value).d("Switch to PiP mode")
|
if (pipController == 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 (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()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
|
}
|
||||||
|
|
@ -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.WebViewPipController
|
||||||
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
|
||||||
|
|
@ -95,9 +96,9 @@ internal fun CallScreenView(
|
||||||
}
|
}
|
||||||
CallWebView(
|
CallWebView(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.padding(padding)
|
.padding(padding)
|
||||||
.consumeWindowInsets(padding)
|
.consumeWindowInsets(padding)
|
||||||
.fillMaxSize(),
|
.fillMaxSize(),
|
||||||
url = state.urlState,
|
url = state.urlState,
|
||||||
userAgent = state.userAgent,
|
userAgent = state.userAgent,
|
||||||
onPermissionsRequest = { request ->
|
onPermissionsRequest = { request ->
|
||||||
|
|
@ -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 pipController = WebViewPipController(webView)
|
||||||
|
pipState.eventSink(PictureInPictureEvents.SetPipController(pipController))
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
when (state.urlState) {
|
when (state.urlState) {
|
||||||
|
|
|
||||||
|
|
@ -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,19 +25,30 @@ 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.Composable
|
||||||
|
import androidx.compose.runtime.DisposableEffect
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.rememberUpdatedState
|
||||||
|
import androidx.core.app.PictureInPictureModeChangedInfo
|
||||||
import androidx.core.content.IntentCompat
|
import androidx.core.content.IntentCompat
|
||||||
|
import androidx.core.util.Consumer
|
||||||
import androidx.lifecycle.Lifecycle
|
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.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.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 +57,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,
|
||||||
|
PipView {
|
||||||
@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
|
||||||
|
|
@ -86,13 +101,14 @@ class ElementCallActivity : AppCompatActivity(), CallScreenNavigator {
|
||||||
updateUiMode(resources.configuration)
|
updateUiMode(resources.configuration)
|
||||||
}
|
}
|
||||||
|
|
||||||
pictureInPicturePresenter.onCreate(this)
|
pictureInPicturePresenter.setPipView(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()
|
||||||
|
ListenToAndroidEvents(pipState)
|
||||||
ElementThemeApp(appPreferencesStore) {
|
ElementThemeApp(appPreferencesStore) {
|
||||||
val state = presenter.present()
|
val state = presenter.present()
|
||||||
eventSink = state.eventSink
|
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) {
|
override fun onConfigurationChanged(newConfig: Configuration) {
|
||||||
super.onConfigurationChanged(newConfig)
|
super.onConfigurationChanged(newConfig)
|
||||||
updateUiMode(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) {
|
override fun onNewIntent(intent: Intent) {
|
||||||
super.onNewIntent(intent)
|
super.onNewIntent(intent)
|
||||||
setCallType(intent)
|
setCallType(intent)
|
||||||
|
|
@ -140,16 +173,11 @@ class ElementCallActivity : AppCompatActivity(), CallScreenNavigator {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onUserLeaveHint() {
|
|
||||||
super.onUserLeaveHint()
|
|
||||||
pictureInPicturePresenter.onUserLeaveHint()
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onDestroy() {
|
override fun onDestroy() {
|
||||||
super.onDestroy()
|
super.onDestroy()
|
||||||
releaseAudioFocus()
|
releaseAudioFocus()
|
||||||
CallForegroundService.stop(this)
|
CallForegroundService.stop(this)
|
||||||
pictureInPicturePresenter.onDestroy()
|
pictureInPicturePresenter.setPipView(null)
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun finish() {
|
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> {
|
internal fun mapWebkitPermissions(permissions: Array<String>): List<String> {
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
|
}
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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()
|
||||||
|
}
|
||||||
|
|
@ -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()
|
||||||
|
}
|
||||||
|
|
@ -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.setPipView(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,
|
||||||
|
pipView = FakePipView(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,
|
||||||
|
pipView = FakePipView(
|
||||||
|
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,
|
||||||
|
pipView = FakePipView(
|
||||||
|
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.SetPipController(FakePipController(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,
|
||||||
|
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()
|
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,
|
||||||
|
pipView: PipView? = FakePipView()
|
||||||
): PictureInPicturePresenter {
|
): PictureInPicturePresenter {
|
||||||
val activity = Robolectric.buildActivity(ElementCallActivity::class.java)
|
|
||||||
return PictureInPicturePresenter(
|
return PictureInPicturePresenter(
|
||||||
pipSupportProvider = FakePipSupportProvider(supportPip),
|
pipSupportProvider = FakePipSupportProvider(supportPip),
|
||||||
).apply {
|
).apply {
|
||||||
onCreate(activity.get())
|
setPipView(pipView)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -37,7 +37,7 @@ class CallScreenViewTest {
|
||||||
@Test
|
@Test
|
||||||
fun `clicking on back when pip is not supported hangs up`() {
|
fun `clicking on back when pip is not supported hangs up`() {
|
||||||
val eventsRecorder = EventsRecorder<CallScreenEvents>()
|
val eventsRecorder = EventsRecorder<CallScreenEvents>()
|
||||||
val pipEventsRecorder = EventsRecorder<PictureInPictureEvents>(expectEvents = false)
|
val pipEventsRecorder = EventsRecorder<PictureInPictureEvents>()
|
||||||
rule.setCallScreenView(
|
rule.setCallScreenView(
|
||||||
aCallScreenState(
|
aCallScreenState(
|
||||||
eventSink = eventsRecorder
|
eventSink = eventsRecorder
|
||||||
|
|
@ -51,6 +51,8 @@ class CallScreenViewTest {
|
||||||
eventsRecorder.assertSize(2)
|
eventsRecorder.assertSize(2)
|
||||||
eventsRecorder.assertTrue(0) { it is CallScreenEvents.SetupMessageChannels }
|
eventsRecorder.assertTrue(0) { it is CallScreenEvents.SetupMessageChannels }
|
||||||
eventsRecorder.assertTrue(1) { it == CallScreenEvents.Hangup }
|
eventsRecorder.assertTrue(1) { it == CallScreenEvents.Hangup }
|
||||||
|
pipEventsRecorder.assertSize(1)
|
||||||
|
pipEventsRecorder.assertTrue(0) { it is PictureInPictureEvents.SetPipController }
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
|
|
@ -69,7 +71,9 @@ class CallScreenViewTest {
|
||||||
rule.pressBack()
|
rule.pressBack()
|
||||||
eventsRecorder.assertSize(1)
|
eventsRecorder.assertSize(1)
|
||||||
eventsRecorder.assertTrue(0) { it is CallScreenEvents.SetupMessageChannels }
|
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 }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue