Skip to content

Commit 18dcdc0

Browse files
committed
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.
1 parent 1b7c0db commit 18dcdc0

File tree

10 files changed

+317
-79
lines changed

10 files changed

+317
-79
lines changed

features/call/impl/src/main/kotlin/io/element/android/features/call/impl/pip/PictureInPictureEvents.kt

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,10 @@
1616

1717
package io.element.android.features.call.impl.pip
1818

19+
import io.element.android.features.call.impl.utils.WebPipApi
20+
1921
sealed interface PictureInPictureEvents {
22+
data class SetupWebPipApi(val webPipApi: WebPipApi) : PictureInPictureEvents
2023
data object EnterPictureInPicture : PictureInPictureEvents
24+
data class OnPictureInPictureModeChanged(val isInPip: Boolean) : PictureInPictureEvents
2125
}

features/call/impl/src/main/kotlin/io/element/android/features/call/impl/pip/PictureInPicturePresenter.kt

Lines changed: 46 additions & 48 deletions
Original file line numberDiff line numberDiff line change
@@ -16,17 +16,17 @@
1616

1717
package io.element.android.features.call.impl.pip
1818

19-
import android.app.Activity
20-
import android.app.PictureInPictureParams
21-
import android.os.Build
22-
import android.util.Rational
23-
import androidx.annotation.RequiresApi
2419
import androidx.compose.runtime.Composable
20+
import androidx.compose.runtime.getValue
2521
import androidx.compose.runtime.mutableStateOf
22+
import androidx.compose.runtime.remember
23+
import androidx.compose.runtime.rememberCoroutineScope
24+
import androidx.compose.runtime.setValue
25+
import io.element.android.features.call.impl.utils.WebPipApi
2626
import io.element.android.libraries.architecture.Presenter
2727
import io.element.android.libraries.core.log.logger.LoggerTag
28+
import kotlinx.coroutines.launch
2829
import timber.log.Timber
29-
import java.lang.ref.WeakReference
3030
import javax.inject.Inject
3131

3232
private val loggerTag = LoggerTag("PiP")
@@ -35,71 +35,69 @@ class PictureInPicturePresenter @Inject constructor(
3535
pipSupportProvider: PipSupportProvider,
3636
) : Presenter<PictureInPictureState> {
3737
private val isPipSupported = pipSupportProvider.isPipSupported()
38-
private var isInPictureInPicture = mutableStateOf(false)
39-
private var hostActivity: WeakReference<Activity>? = null
38+
private var pipActivity: PipActivity? = null
4039

4140
@Composable
4241
override fun present(): PictureInPictureState {
42+
val coroutineScope = rememberCoroutineScope()
43+
var isInPictureInPicture by remember { mutableStateOf(false) }
44+
var webPipApi by remember { mutableStateOf<WebPipApi?>(null) }
45+
4346
fun handleEvent(event: PictureInPictureEvents) {
4447
when (event) {
45-
PictureInPictureEvents.EnterPictureInPicture -> switchToPip()
48+
is PictureInPictureEvents.SetupWebPipApi -> {
49+
webPipApi = event.webPipApi
50+
}
51+
PictureInPictureEvents.EnterPictureInPicture -> {
52+
coroutineScope.launch {
53+
switchToPip(webPipApi)
54+
}
55+
}
56+
is PictureInPictureEvents.OnPictureInPictureModeChanged -> {
57+
Timber.tag(loggerTag.value).d("onPictureInPictureModeChanged: ${event.isInPip}")
58+
isInPictureInPicture = event.isInPip
59+
if (event.isInPip) {
60+
webPipApi?.enterPip()
61+
} else {
62+
webPipApi?.exitPip()
63+
}
64+
}
4665
}
4766
}
4867

4968
return PictureInPictureState(
5069
supportPip = isPipSupported,
51-
isInPictureInPicture = isInPictureInPicture.value,
70+
isInPictureInPicture = isInPictureInPicture,
5271
eventSink = ::handleEvent,
5372
)
5473
}
5574

56-
fun onCreate(activity: Activity) {
75+
fun setPipActivity(pipActivity: PipActivity?) {
5776
if (isPipSupported) {
58-
Timber.tag(loggerTag.value).d("onCreate: Setting PiP params")
59-
hostActivity = WeakReference(activity)
60-
hostActivity?.get()?.setPictureInPictureParams(getPictureInPictureParams())
77+
Timber.tag(loggerTag.value).d("Setting PiP params")
78+
this.pipActivity = pipActivity
79+
pipActivity?.setPipParams()
6180
} else {
6281
Timber.tag(loggerTag.value).d("onCreate: PiP is not supported")
6382
}
6483
}
6584

66-
fun onDestroy() {
67-
Timber.tag(loggerTag.value).d("onDestroy")
68-
hostActivity?.clear()
69-
hostActivity = null
70-
}
71-
72-
@RequiresApi(Build.VERSION_CODES.O)
73-
private fun getPictureInPictureParams(): PictureInPictureParams {
74-
return PictureInPictureParams.Builder()
75-
// Portrait for calls seems more appropriate
76-
.setAspectRatio(Rational(3, 5))
77-
.apply {
78-
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
79-
setAutoEnterEnabled(true)
80-
}
81-
}
82-
.build()
83-
}
84-
8585
/**
86-
* Enters Picture-in-Picture mode.
86+
* Enters Picture-in-Picture mode, if allowed by Element Call.
8787
*/
88-
private fun switchToPip() {
88+
private suspend fun switchToPip(webPipApi: WebPipApi?) {
8989
if (isPipSupported) {
90-
Timber.tag(loggerTag.value).d("Switch to PiP mode")
91-
hostActivity?.get()?.enterPictureInPictureMode(getPictureInPictureParams())
92-
?.also { Timber.tag(loggerTag.value).d("Switch to PiP mode result: $it") }
90+
if (webPipApi == null) {
91+
Timber.tag(loggerTag.value).w("webPipApi is not available")
92+
}
93+
if (webPipApi == null || webPipApi.canEnterPip()) {
94+
Timber.tag(loggerTag.value).d("Switch to PiP mode")
95+
pipActivity?.enterPipMode()
96+
?.also { Timber.tag(loggerTag.value).d("Switch to PiP mode result: $it") }
97+
} else {
98+
Timber.tag(loggerTag.value).w("Cannot enter PiP mode, hangup the call")
99+
pipActivity?.hangUp()
100+
}
93101
}
94102
}
95-
96-
fun onPictureInPictureModeChanged(isInPictureInPictureMode: Boolean) {
97-
Timber.tag(loggerTag.value).d("onPictureInPictureModeChanged: $isInPictureInPictureMode")
98-
isInPictureInPicture.value = isInPictureInPictureMode
99-
}
100-
101-
fun onUserLeaveHint() {
102-
Timber.tag(loggerTag.value).d("onUserLeaveHint")
103-
switchToPip()
104-
}
105103
}
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
/*
2+
* Copyright (c) 2024 New Vector Ltd
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
package io.element.android.features.call.impl.pip
18+
19+
interface PipActivity {
20+
fun setPipParams()
21+
fun enterPipMode(): Boolean
22+
fun hangUp()
23+
}

features/call/impl/src/main/kotlin/io/element/android/features/call/impl/ui/CallScreenView.kt

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@ import io.element.android.features.call.impl.pip.PictureInPictureEvents
4040
import io.element.android.features.call.impl.pip.PictureInPictureState
4141
import io.element.android.features.call.impl.pip.PictureInPictureStateProvider
4242
import io.element.android.features.call.impl.pip.aPictureInPictureState
43+
import io.element.android.features.call.impl.utils.WebViewWebPipApi
4344
import io.element.android.features.call.impl.utils.WebViewWidgetMessageInterceptor
4445
import io.element.android.libraries.architecture.AsyncData
4546
import io.element.android.libraries.designsystem.components.ProgressDialog
@@ -108,6 +109,8 @@ internal fun CallScreenView(
108109
onWebViewCreate = { webView ->
109110
val interceptor = WebViewWidgetMessageInterceptor(webView)
110111
state.eventSink(CallScreenEvents.SetupMessageChannels(interceptor))
112+
val webPipApi = WebViewWebPipApi(webView)
113+
pipState.eventSink(PictureInPictureEvents.SetupWebPipApi(webPipApi))
111114
}
112115
)
113116
when (state.urlState) {

features/call/impl/src/main/kotlin/io/element/android/features/call/impl/ui/ElementCallActivity.kt

Lines changed: 46 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -17,26 +17,31 @@
1717
package io.element.android.features.call.impl.ui
1818

1919
import android.Manifest
20+
import android.app.PictureInPictureParams
2021
import android.content.Intent
2122
import android.content.res.Configuration
2223
import android.media.AudioAttributes
2324
import android.media.AudioFocusRequest
2425
import android.media.AudioManager
2526
import android.os.Build
2627
import android.os.Bundle
28+
import android.util.Rational
2729
import android.view.WindowManager
2830
import android.webkit.PermissionRequest
2931
import androidx.activity.compose.setContent
3032
import androidx.activity.result.ActivityResultLauncher
3133
import androidx.activity.result.contract.ActivityResultContracts
34+
import androidx.annotation.RequiresApi
3235
import androidx.appcompat.app.AppCompatActivity
3336
import androidx.compose.runtime.mutableStateOf
3437
import androidx.core.content.IntentCompat
3538
import androidx.lifecycle.Lifecycle
3639
import io.element.android.features.call.api.CallType
3740
import io.element.android.features.call.impl.DefaultElementCallEntryPoint
3841
import io.element.android.features.call.impl.di.CallBindings
42+
import io.element.android.features.call.impl.pip.PictureInPictureEvents
3943
import io.element.android.features.call.impl.pip.PictureInPicturePresenter
44+
import io.element.android.features.call.impl.pip.PipActivity
4045
import io.element.android.features.call.impl.services.CallForegroundService
4146
import io.element.android.features.call.impl.utils.CallIntentDataParser
4247
import io.element.android.libraries.architecture.bindings
@@ -45,7 +50,10 @@ import io.element.android.libraries.preferences.api.store.AppPreferencesStore
4550
import timber.log.Timber
4651
import javax.inject.Inject
4752

48-
class ElementCallActivity : AppCompatActivity(), CallScreenNavigator {
53+
class ElementCallActivity :
54+
AppCompatActivity(),
55+
CallScreenNavigator,
56+
PipActivity {
4957
@Inject lateinit var callIntentDataParser: CallIntentDataParser
5058
@Inject lateinit var presenterFactory: CallScreenPresenter.Factory
5159
@Inject lateinit var appPreferencesStore: AppPreferencesStore
@@ -66,6 +74,7 @@ class ElementCallActivity : AppCompatActivity(), CallScreenNavigator {
6674
private val webViewTarget = mutableStateOf<CallType?>(null)
6775

6876
private var eventSink: ((CallScreenEvents) -> Unit)? = null
77+
private var pipEventSink: ((PictureInPictureEvents) -> Unit)? = null
6978

7079
override fun onCreate(savedInstanceState: Bundle?) {
7180
super.onCreate(savedInstanceState)
@@ -86,13 +95,14 @@ class ElementCallActivity : AppCompatActivity(), CallScreenNavigator {
8695
updateUiMode(resources.configuration)
8796
}
8897

89-
pictureInPicturePresenter.onCreate(this)
98+
pictureInPicturePresenter.setPipActivity(this)
9099

91100
audioManager = getSystemService(AUDIO_SERVICE) as AudioManager
92101
requestAudioFocus()
93102

94103
setContent {
95104
val pipState = pictureInPicturePresenter.present()
105+
pipEventSink = pipState.eventSink
96106
ElementThemeApp(appPreferencesStore) {
97107
val state = presenter.present()
98108
eventSink = state.eventSink
@@ -115,7 +125,7 @@ class ElementCallActivity : AppCompatActivity(), CallScreenNavigator {
115125

116126
override fun onPictureInPictureModeChanged(isInPictureInPictureMode: Boolean, newConfig: Configuration) {
117127
super.onPictureInPictureModeChanged(isInPictureInPictureMode, newConfig)
118-
pictureInPicturePresenter.onPictureInPictureModeChanged(isInPictureInPictureMode)
128+
pipEventSink?.invoke(PictureInPictureEvents.OnPictureInPictureModeChanged(isInPictureInPictureMode))
119129

120130
if (!isInPictureInPictureMode && !lifecycle.currentState.isAtLeast(Lifecycle.State.STARTED)) {
121131
Timber.d("Exiting PiP mode: Hangup the call")
@@ -142,14 +152,14 @@ class ElementCallActivity : AppCompatActivity(), CallScreenNavigator {
142152

143153
override fun onUserLeaveHint() {
144154
super.onUserLeaveHint()
145-
pictureInPicturePresenter.onUserLeaveHint()
155+
pipEventSink?.invoke(PictureInPictureEvents.EnterPictureInPicture)
146156
}
147157

148158
override fun onDestroy() {
149159
super.onDestroy()
150160
releaseAudioFocus()
151161
CallForegroundService.stop(this)
152-
pictureInPicturePresenter.onDestroy()
162+
pictureInPicturePresenter.setPipActivity(null)
153163
}
154164

155165
override fun finish() {
@@ -249,6 +259,37 @@ class ElementCallActivity : AppCompatActivity(), CallScreenNavigator {
249259
}
250260
}
251261
}
262+
263+
override fun setPipParams() {
264+
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
265+
setPictureInPictureParams(getPictureInPictureParams())
266+
}
267+
}
268+
269+
override fun enterPipMode(): Boolean {
270+
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
271+
enterPictureInPictureMode(getPictureInPictureParams())
272+
} else {
273+
false
274+
}
275+
}
276+
277+
@RequiresApi(Build.VERSION_CODES.O)
278+
private fun getPictureInPictureParams(): PictureInPictureParams {
279+
return PictureInPictureParams.Builder()
280+
// Portrait for calls seems more appropriate
281+
.setAspectRatio(Rational(3, 5))
282+
.apply {
283+
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
284+
setAutoEnterEnabled(true)
285+
}
286+
}
287+
.build()
288+
}
289+
290+
override fun hangUp() {
291+
eventSink?.invoke(CallScreenEvents.Hangup)
292+
}
252293
}
253294

254295
internal fun mapWebkitPermissions(permissions: Array<String>): List<String> {
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
/*
2+
* Copyright (c) 2024 New Vector Ltd
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
package io.element.android.features.call.impl.utils
18+
19+
interface WebPipApi {
20+
suspend fun canEnterPip(): Boolean
21+
fun enterPip()
22+
fun exitPip()
23+
}
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
/*
2+
* Copyright (c) 2024 New Vector Ltd
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
package io.element.android.features.call.impl.utils
18+
19+
import android.webkit.WebView
20+
import kotlin.coroutines.resume
21+
import kotlin.coroutines.suspendCoroutine
22+
23+
class WebViewWebPipApi(
24+
private val webView: WebView,
25+
) : WebPipApi {
26+
override suspend fun canEnterPip(): Boolean {
27+
return suspendCoroutine { continuation ->
28+
webView.evaluateJavascript("controls.canEnterPip()") { result ->
29+
continuation.resume(result == "true")
30+
}
31+
}
32+
}
33+
34+
override fun enterPip() {
35+
webView.evaluateJavascript("controls.enablePip()", null)
36+
}
37+
38+
override fun exitPip() {
39+
webView.evaluateJavascript("controls.disablePip()", null)
40+
}
41+
}

0 commit comments

Comments
 (0)