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.
This commit is contained in:
parent
1b7c0dbe88
commit
18dcdc0e64
10 changed files with 317 additions and 79 deletions
|
|
@ -16,6 +16,10 @@
|
|||
|
||||
package io.element.android.features.call.impl.pip
|
||||
|
||||
import io.element.android.features.call.impl.utils.WebPipApi
|
||||
|
||||
sealed interface PictureInPictureEvents {
|
||||
data class SetupWebPipApi(val webPipApi: WebPipApi) : PictureInPictureEvents
|
||||
data object EnterPictureInPicture : PictureInPictureEvents
|
||||
data class OnPictureInPictureModeChanged(val isInPip: Boolean) : PictureInPictureEvents
|
||||
}
|
||||
|
|
|
|||
|
|
@ -16,17 +16,17 @@
|
|||
|
||||
package io.element.android.features.call.impl.pip
|
||||
|
||||
import android.app.Activity
|
||||
import android.app.PictureInPictureParams
|
||||
import android.os.Build
|
||||
import android.util.Rational
|
||||
import androidx.annotation.RequiresApi
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import io.element.android.features.call.impl.utils.WebPipApi
|
||||
import io.element.android.libraries.architecture.Presenter
|
||||
import io.element.android.libraries.core.log.logger.LoggerTag
|
||||
import kotlinx.coroutines.launch
|
||||
import timber.log.Timber
|
||||
import java.lang.ref.WeakReference
|
||||
import javax.inject.Inject
|
||||
|
||||
private val loggerTag = LoggerTag("PiP")
|
||||
|
|
@ -35,71 +35,69 @@ class PictureInPicturePresenter @Inject constructor(
|
|||
pipSupportProvider: PipSupportProvider,
|
||||
) : Presenter<PictureInPictureState> {
|
||||
private val isPipSupported = pipSupportProvider.isPipSupported()
|
||||
private var isInPictureInPicture = mutableStateOf(false)
|
||||
private var hostActivity: WeakReference<Activity>? = null
|
||||
private var pipActivity: PipActivity? = null
|
||||
|
||||
@Composable
|
||||
override fun present(): PictureInPictureState {
|
||||
val coroutineScope = rememberCoroutineScope()
|
||||
var isInPictureInPicture by remember { mutableStateOf(false) }
|
||||
var webPipApi by remember { mutableStateOf<WebPipApi?>(null) }
|
||||
|
||||
fun handleEvent(event: PictureInPictureEvents) {
|
||||
when (event) {
|
||||
PictureInPictureEvents.EnterPictureInPicture -> switchToPip()
|
||||
is PictureInPictureEvents.SetupWebPipApi -> {
|
||||
webPipApi = event.webPipApi
|
||||
}
|
||||
PictureInPictureEvents.EnterPictureInPicture -> {
|
||||
coroutineScope.launch {
|
||||
switchToPip(webPipApi)
|
||||
}
|
||||
}
|
||||
is PictureInPictureEvents.OnPictureInPictureModeChanged -> {
|
||||
Timber.tag(loggerTag.value).d("onPictureInPictureModeChanged: ${event.isInPip}")
|
||||
isInPictureInPicture = event.isInPip
|
||||
if (event.isInPip) {
|
||||
webPipApi?.enterPip()
|
||||
} else {
|
||||
webPipApi?.exitPip()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return PictureInPictureState(
|
||||
supportPip = isPipSupported,
|
||||
isInPictureInPicture = isInPictureInPicture.value,
|
||||
isInPictureInPicture = isInPictureInPicture,
|
||||
eventSink = ::handleEvent,
|
||||
)
|
||||
}
|
||||
|
||||
fun onCreate(activity: Activity) {
|
||||
fun setPipActivity(pipActivity: PipActivity?) {
|
||||
if (isPipSupported) {
|
||||
Timber.tag(loggerTag.value).d("onCreate: Setting PiP params")
|
||||
hostActivity = WeakReference(activity)
|
||||
hostActivity?.get()?.setPictureInPictureParams(getPictureInPictureParams())
|
||||
Timber.tag(loggerTag.value).d("Setting PiP params")
|
||||
this.pipActivity = pipActivity
|
||||
pipActivity?.setPipParams()
|
||||
} else {
|
||||
Timber.tag(loggerTag.value).d("onCreate: PiP is not supported")
|
||||
}
|
||||
}
|
||||
|
||||
fun onDestroy() {
|
||||
Timber.tag(loggerTag.value).d("onDestroy")
|
||||
hostActivity?.clear()
|
||||
hostActivity = null
|
||||
}
|
||||
|
||||
@RequiresApi(Build.VERSION_CODES.O)
|
||||
private fun getPictureInPictureParams(): PictureInPictureParams {
|
||||
return PictureInPictureParams.Builder()
|
||||
// Portrait for calls seems more appropriate
|
||||
.setAspectRatio(Rational(3, 5))
|
||||
.apply {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
|
||||
setAutoEnterEnabled(true)
|
||||
}
|
||||
}
|
||||
.build()
|
||||
}
|
||||
|
||||
/**
|
||||
* Enters Picture-in-Picture mode.
|
||||
* Enters Picture-in-Picture mode, if allowed by Element Call.
|
||||
*/
|
||||
private fun switchToPip() {
|
||||
private suspend fun switchToPip(webPipApi: WebPipApi?) {
|
||||
if (isPipSupported) {
|
||||
Timber.tag(loggerTag.value).d("Switch to PiP mode")
|
||||
hostActivity?.get()?.enterPictureInPictureMode(getPictureInPictureParams())
|
||||
?.also { Timber.tag(loggerTag.value).d("Switch to PiP mode result: $it") }
|
||||
if (webPipApi == null) {
|
||||
Timber.tag(loggerTag.value).w("webPipApi is not available")
|
||||
}
|
||||
if (webPipApi == null || webPipApi.canEnterPip()) {
|
||||
Timber.tag(loggerTag.value).d("Switch to PiP mode")
|
||||
pipActivity?.enterPipMode()
|
||||
?.also { Timber.tag(loggerTag.value).d("Switch to PiP mode result: $it") }
|
||||
} else {
|
||||
Timber.tag(loggerTag.value).w("Cannot enter PiP mode, hangup the call")
|
||||
pipActivity?.hangUp()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun onPictureInPictureModeChanged(isInPictureInPictureMode: Boolean) {
|
||||
Timber.tag(loggerTag.value).d("onPictureInPictureModeChanged: $isInPictureInPictureMode")
|
||||
isInPictureInPicture.value = isInPictureInPictureMode
|
||||
}
|
||||
|
||||
fun onUserLeaveHint() {
|
||||
Timber.tag(loggerTag.value).d("onUserLeaveHint")
|
||||
switchToPip()
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
|
|
@ -40,6 +40,7 @@ import io.element.android.features.call.impl.pip.PictureInPictureEvents
|
|||
import io.element.android.features.call.impl.pip.PictureInPictureState
|
||||
import io.element.android.features.call.impl.pip.PictureInPictureStateProvider
|
||||
import io.element.android.features.call.impl.pip.aPictureInPictureState
|
||||
import io.element.android.features.call.impl.utils.WebViewWebPipApi
|
||||
import io.element.android.features.call.impl.utils.WebViewWidgetMessageInterceptor
|
||||
import io.element.android.libraries.architecture.AsyncData
|
||||
import io.element.android.libraries.designsystem.components.ProgressDialog
|
||||
|
|
@ -108,6 +109,8 @@ internal fun CallScreenView(
|
|||
onWebViewCreate = { webView ->
|
||||
val interceptor = WebViewWidgetMessageInterceptor(webView)
|
||||
state.eventSink(CallScreenEvents.SetupMessageChannels(interceptor))
|
||||
val webPipApi = WebViewWebPipApi(webView)
|
||||
pipState.eventSink(PictureInPictureEvents.SetupWebPipApi(webPipApi))
|
||||
}
|
||||
)
|
||||
when (state.urlState) {
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@
|
|||
package io.element.android.features.call.impl.ui
|
||||
|
||||
import android.Manifest
|
||||
import android.app.PictureInPictureParams
|
||||
import android.content.Intent
|
||||
import android.content.res.Configuration
|
||||
import android.media.AudioAttributes
|
||||
|
|
@ -24,11 +25,13 @@ import android.media.AudioFocusRequest
|
|||
import android.media.AudioManager
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
import android.util.Rational
|
||||
import android.view.WindowManager
|
||||
import android.webkit.PermissionRequest
|
||||
import androidx.activity.compose.setContent
|
||||
import androidx.activity.result.ActivityResultLauncher
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.annotation.RequiresApi
|
||||
import androidx.appcompat.app.AppCompatActivity
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.core.content.IntentCompat
|
||||
|
|
@ -36,7 +39,9 @@ import androidx.lifecycle.Lifecycle
|
|||
import io.element.android.features.call.api.CallType
|
||||
import io.element.android.features.call.impl.DefaultElementCallEntryPoint
|
||||
import io.element.android.features.call.impl.di.CallBindings
|
||||
import io.element.android.features.call.impl.pip.PictureInPictureEvents
|
||||
import io.element.android.features.call.impl.pip.PictureInPicturePresenter
|
||||
import io.element.android.features.call.impl.pip.PipActivity
|
||||
import io.element.android.features.call.impl.services.CallForegroundService
|
||||
import io.element.android.features.call.impl.utils.CallIntentDataParser
|
||||
import io.element.android.libraries.architecture.bindings
|
||||
|
|
@ -45,7 +50,10 @@ import io.element.android.libraries.preferences.api.store.AppPreferencesStore
|
|||
import timber.log.Timber
|
||||
import javax.inject.Inject
|
||||
|
||||
class ElementCallActivity : AppCompatActivity(), CallScreenNavigator {
|
||||
class ElementCallActivity :
|
||||
AppCompatActivity(),
|
||||
CallScreenNavigator,
|
||||
PipActivity {
|
||||
@Inject lateinit var callIntentDataParser: CallIntentDataParser
|
||||
@Inject lateinit var presenterFactory: CallScreenPresenter.Factory
|
||||
@Inject lateinit var appPreferencesStore: AppPreferencesStore
|
||||
|
|
@ -66,6 +74,7 @@ class ElementCallActivity : AppCompatActivity(), CallScreenNavigator {
|
|||
private val webViewTarget = mutableStateOf<CallType?>(null)
|
||||
|
||||
private var eventSink: ((CallScreenEvents) -> Unit)? = null
|
||||
private var pipEventSink: ((PictureInPictureEvents) -> Unit)? = null
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
|
|
@ -86,13 +95,14 @@ class ElementCallActivity : AppCompatActivity(), CallScreenNavigator {
|
|||
updateUiMode(resources.configuration)
|
||||
}
|
||||
|
||||
pictureInPicturePresenter.onCreate(this)
|
||||
pictureInPicturePresenter.setPipActivity(this)
|
||||
|
||||
audioManager = getSystemService(AUDIO_SERVICE) as AudioManager
|
||||
requestAudioFocus()
|
||||
|
||||
setContent {
|
||||
val pipState = pictureInPicturePresenter.present()
|
||||
pipEventSink = pipState.eventSink
|
||||
ElementThemeApp(appPreferencesStore) {
|
||||
val state = presenter.present()
|
||||
eventSink = state.eventSink
|
||||
|
|
@ -115,7 +125,7 @@ class ElementCallActivity : AppCompatActivity(), CallScreenNavigator {
|
|||
|
||||
override fun onPictureInPictureModeChanged(isInPictureInPictureMode: Boolean, newConfig: Configuration) {
|
||||
super.onPictureInPictureModeChanged(isInPictureInPictureMode, newConfig)
|
||||
pictureInPicturePresenter.onPictureInPictureModeChanged(isInPictureInPictureMode)
|
||||
pipEventSink?.invoke(PictureInPictureEvents.OnPictureInPictureModeChanged(isInPictureInPictureMode))
|
||||
|
||||
if (!isInPictureInPictureMode && !lifecycle.currentState.isAtLeast(Lifecycle.State.STARTED)) {
|
||||
Timber.d("Exiting PiP mode: Hangup the call")
|
||||
|
|
@ -142,14 +152,14 @@ class ElementCallActivity : AppCompatActivity(), CallScreenNavigator {
|
|||
|
||||
override fun onUserLeaveHint() {
|
||||
super.onUserLeaveHint()
|
||||
pictureInPicturePresenter.onUserLeaveHint()
|
||||
pipEventSink?.invoke(PictureInPictureEvents.EnterPictureInPicture)
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
super.onDestroy()
|
||||
releaseAudioFocus()
|
||||
CallForegroundService.stop(this)
|
||||
pictureInPicturePresenter.onDestroy()
|
||||
pictureInPicturePresenter.setPipActivity(null)
|
||||
}
|
||||
|
||||
override fun finish() {
|
||||
|
|
@ -249,6 +259,37 @@ class ElementCallActivity : AppCompatActivity(), CallScreenNavigator {
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun setPipParams() {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
setPictureInPictureParams(getPictureInPictureParams())
|
||||
}
|
||||
}
|
||||
|
||||
override fun enterPipMode(): Boolean {
|
||||
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
enterPictureInPictureMode(getPictureInPictureParams())
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
@RequiresApi(Build.VERSION_CODES.O)
|
||||
private fun getPictureInPictureParams(): PictureInPictureParams {
|
||||
return PictureInPictureParams.Builder()
|
||||
// Portrait for calls seems more appropriate
|
||||
.setAspectRatio(Rational(3, 5))
|
||||
.apply {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
|
||||
setAutoEnterEnabled(true)
|
||||
}
|
||||
}
|
||||
.build()
|
||||
}
|
||||
|
||||
override fun hangUp() {
|
||||
eventSink?.invoke(CallScreenEvents.Hangup)
|
||||
}
|
||||
}
|
||||
|
||||
internal fun mapWebkitPermissions(permissions: Array<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 WebPipApi {
|
||||
suspend fun canEnterPip(): Boolean
|
||||
fun enterPip()
|
||||
fun exitPip()
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue