Skip to content

Commit 1a43aa3

Browse files
authored
Merge pull request #3334 from element-hq/feature/bma/pipCallApi
Use new functions exposed by Element Call about PiP
2 parents 5033abc + 7f4b846 commit 1a43aa3

File tree

11 files changed

+355
-98
lines changed

11 files changed

+355
-98
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.PipController
20+
1921
sealed interface PictureInPictureEvents {
22+
data class SetPipController(val pipController: PipController) : 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: 47 additions & 49 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.PipController
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 pipView: PipView? = null
4039

4140
@Composable
4241
override fun present(): PictureInPictureState {
42+
val coroutineScope = rememberCoroutineScope()
43+
var isInPictureInPicture by remember { mutableStateOf(false) }
44+
var pipController by remember { mutableStateOf<PipController?>(null) }
45+
4346
fun handleEvent(event: PictureInPictureEvents) {
4447
when (event) {
45-
PictureInPictureEvents.EnterPictureInPicture -> switchToPip()
48+
is PictureInPictureEvents.SetPipController -> {
49+
pipController = event.pipController
50+
}
51+
PictureInPictureEvents.EnterPictureInPicture -> {
52+
coroutineScope.launch {
53+
switchToPip(pipController)
54+
}
55+
}
56+
is PictureInPictureEvents.OnPictureInPictureModeChanged -> {
57+
Timber.tag(loggerTag.value).d("onPictureInPictureModeChanged: ${event.isInPip}")
58+
isInPictureInPicture = event.isInPip
59+
if (event.isInPip) {
60+
pipController?.enterPip()
61+
} else {
62+
pipController?.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 setPipView(pipView: PipView?) {
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.pipView = pipView
79+
pipView?.setPipParams()
6180
} else {
62-
Timber.tag(loggerTag.value).d("onCreate: PiP is not supported")
81+
Timber.tag(loggerTag.value).d("setPipView: 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(pipController: PipController?) {
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 (pipController == null) {
91+
Timber.tag(loggerTag.value).w("webPipApi is not available")
92+
}
93+
if (pipController == null || pipController.canEnterPip()) {
94+
Timber.tag(loggerTag.value).d("Switch to PiP mode")
95+
pipView?.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+
pipView?.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 PipView {
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: 6 additions & 3 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.WebViewPipController
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
@@ -95,9 +96,9 @@ internal fun CallScreenView(
9596
}
9697
CallWebView(
9798
modifier = Modifier
98-
.padding(padding)
99-
.consumeWindowInsets(padding)
100-
.fillMaxSize(),
99+
.padding(padding)
100+
.consumeWindowInsets(padding)
101+
.fillMaxSize(),
101102
url = state.urlState,
102103
userAgent = state.userAgent,
103104
onPermissionsRequest = { request ->
@@ -108,6 +109,8 @@ internal fun CallScreenView(
108109
onWebViewCreate = { webView ->
109110
val interceptor = WebViewWidgetMessageInterceptor(webView)
110111
state.eventSink(CallScreenEvents.SetupMessageChannels(interceptor))
112+
val pipController = WebViewPipController(webView)
113+
pipState.eventSink(PictureInPictureEvents.SetPipController(pipController))
111114
}
112115
)
113116
when (state.urlState) {

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

Lines changed: 73 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -17,26 +17,38 @@
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
36+
import androidx.compose.runtime.Composable
37+
import androidx.compose.runtime.DisposableEffect
38+
import androidx.compose.runtime.getValue
3339
import androidx.compose.runtime.mutableStateOf
40+
import androidx.compose.runtime.rememberUpdatedState
41+
import androidx.core.app.PictureInPictureModeChangedInfo
3442
import androidx.core.content.IntentCompat
43+
import androidx.core.util.Consumer
3544
import androidx.lifecycle.Lifecycle
3645
import io.element.android.features.call.api.CallType
3746
import io.element.android.features.call.impl.DefaultElementCallEntryPoint
3847
import io.element.android.features.call.impl.di.CallBindings
48+
import io.element.android.features.call.impl.pip.PictureInPictureEvents
3949
import io.element.android.features.call.impl.pip.PictureInPicturePresenter
50+
import io.element.android.features.call.impl.pip.PictureInPictureState
51+
import io.element.android.features.call.impl.pip.PipView
4052
import io.element.android.features.call.impl.services.CallForegroundService
4153
import io.element.android.features.call.impl.utils.CallIntentDataParser
4254
import io.element.android.libraries.architecture.bindings
@@ -45,7 +57,10 @@ import io.element.android.libraries.preferences.api.store.AppPreferencesStore
4557
import timber.log.Timber
4658
import javax.inject.Inject
4759

48-
class ElementCallActivity : AppCompatActivity(), CallScreenNavigator {
60+
class ElementCallActivity :
61+
AppCompatActivity(),
62+
CallScreenNavigator,
63+
PipView {
4964
@Inject lateinit var callIntentDataParser: CallIntentDataParser
5065
@Inject lateinit var presenterFactory: CallScreenPresenter.Factory
5166
@Inject lateinit var appPreferencesStore: AppPreferencesStore
@@ -86,13 +101,14 @@ class ElementCallActivity : AppCompatActivity(), CallScreenNavigator {
86101
updateUiMode(resources.configuration)
87102
}
88103

89-
pictureInPicturePresenter.onCreate(this)
104+
pictureInPicturePresenter.setPipView(this)
90105

91106
audioManager = getSystemService(AUDIO_SERVICE) as AudioManager
92107
requestAudioFocus()
93108

94109
setContent {
95110
val pipState = pictureInPicturePresenter.present()
111+
ListenToAndroidEvents(pipState)
96112
ElementThemeApp(appPreferencesStore) {
97113
val state = presenter.present()
98114
eventSink = state.eventSink
@@ -108,21 +124,38 @@ class ElementCallActivity : AppCompatActivity(), CallScreenNavigator {
108124
}
109125
}
110126

127+
@Composable
128+
private fun ListenToAndroidEvents(pipState: PictureInPictureState) {
129+
val pipEventSink by rememberUpdatedState(pipState.eventSink)
130+
DisposableEffect(Unit) {
131+
val onUserLeaveHintListener = Runnable {
132+
pipEventSink(PictureInPictureEvents.EnterPictureInPicture)
133+
}
134+
addOnUserLeaveHintListener(onUserLeaveHintListener)
135+
onDispose {
136+
removeOnUserLeaveHintListener(onUserLeaveHintListener)
137+
}
138+
}
139+
DisposableEffect(Unit) {
140+
val onPictureInPictureModeChangedListener = Consumer { _: PictureInPictureModeChangedInfo ->
141+
pipEventSink(PictureInPictureEvents.OnPictureInPictureModeChanged(isInPictureInPictureMode))
142+
if (!isInPictureInPictureMode && !lifecycle.currentState.isAtLeast(Lifecycle.State.STARTED)) {
143+
Timber.d("Exiting PiP mode: Hangup the call")
144+
eventSink?.invoke(CallScreenEvents.Hangup)
145+
}
146+
}
147+
addOnPictureInPictureModeChangedListener(onPictureInPictureModeChangedListener)
148+
onDispose {
149+
removeOnPictureInPictureModeChangedListener(onPictureInPictureModeChangedListener)
150+
}
151+
}
152+
}
153+
111154
override fun onConfigurationChanged(newConfig: Configuration) {
112155
super.onConfigurationChanged(newConfig)
113156
updateUiMode(newConfig)
114157
}
115158

116-
override fun onPictureInPictureModeChanged(isInPictureInPictureMode: Boolean, newConfig: Configuration) {
117-
super.onPictureInPictureModeChanged(isInPictureInPictureMode, newConfig)
118-
pictureInPicturePresenter.onPictureInPictureModeChanged(isInPictureInPictureMode)
119-
120-
if (!isInPictureInPictureMode && !lifecycle.currentState.isAtLeast(Lifecycle.State.STARTED)) {
121-
Timber.d("Exiting PiP mode: Hangup the call")
122-
eventSink?.invoke(CallScreenEvents.Hangup)
123-
}
124-
}
125-
126159
override fun onNewIntent(intent: Intent) {
127160
super.onNewIntent(intent)
128161
setCallType(intent)
@@ -140,16 +173,11 @@ class ElementCallActivity : AppCompatActivity(), CallScreenNavigator {
140173
}
141174
}
142175

143-
override fun onUserLeaveHint() {
144-
super.onUserLeaveHint()
145-
pictureInPicturePresenter.onUserLeaveHint()
146-
}
147-
148176
override fun onDestroy() {
149177
super.onDestroy()
150178
releaseAudioFocus()
151179
CallForegroundService.stop(this)
152-
pictureInPicturePresenter.onDestroy()
180+
pictureInPicturePresenter.setPipView(null)
153181
}
154182

155183
override fun finish() {
@@ -249,6 +277,33 @@ class ElementCallActivity : AppCompatActivity(), CallScreenNavigator {
249277
}
250278
}
251279
}
280+
281+
@RequiresApi(Build.VERSION_CODES.O)
282+
override fun setPipParams() {
283+
setPictureInPictureParams(getPictureInPictureParams())
284+
}
285+
286+
@RequiresApi(Build.VERSION_CODES.O)
287+
override fun enterPipMode(): Boolean {
288+
return enterPictureInPictureMode(getPictureInPictureParams())
289+
}
290+
291+
@RequiresApi(Build.VERSION_CODES.O)
292+
private fun getPictureInPictureParams(): PictureInPictureParams {
293+
return PictureInPictureParams.Builder()
294+
// Portrait for calls seems more appropriate
295+
.setAspectRatio(Rational(3, 5))
296+
.apply {
297+
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
298+
setAutoEnterEnabled(true)
299+
}
300+
}
301+
.build()
302+
}
303+
304+
override fun hangUp() {
305+
eventSink?.invoke(CallScreenEvents.Hangup)
306+
}
252307
}
253308

254309
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 PipController {
20+
suspend fun canEnterPip(): Boolean
21+
fun enterPip()
22+
fun exitPip()
23+
}

0 commit comments

Comments
 (0)