diff --git a/features/call/impl/src/main/AndroidManifest.xml b/features/call/impl/src/main/AndroidManifest.xml index 4edfb73362..bdd88cf47a 100644 --- a/features/call/impl/src/main/AndroidManifest.xml +++ b/features/call/impl/src/main/AndroidManifest.xml @@ -38,10 +38,11 @@ diff --git a/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/pip/PictureInPictureEvents.kt b/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/pip/PictureInPictureEvents.kt new file mode 100644 index 0000000000..da3c08da32 --- /dev/null +++ b/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/pip/PictureInPictureEvents.kt @@ -0,0 +1,21 @@ +/* + * 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 + +sealed interface PictureInPictureEvents { + data object EnterPictureInPicture : PictureInPictureEvents +} diff --git a/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/pip/PictureInPicturePresenter.kt b/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/pip/PictureInPicturePresenter.kt new file mode 100644 index 0000000000..2c974382d0 --- /dev/null +++ b/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/pip/PictureInPicturePresenter.kt @@ -0,0 +1,105 @@ +/* + * 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 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.mutableStateOf +import io.element.android.libraries.architecture.Presenter +import io.element.android.libraries.core.log.logger.LoggerTag +import timber.log.Timber +import java.lang.ref.WeakReference +import javax.inject.Inject + +private val loggerTag = LoggerTag("PiP") + +class PictureInPicturePresenter @Inject constructor( + pipSupportProvider: PipSupportProvider, +) : Presenter { + private val isPipSupported = pipSupportProvider.isPipSupported() + private var isInPictureInPicture = mutableStateOf(false) + private var hostActivity: WeakReference? = null + + @Composable + override fun present(): PictureInPictureState { + fun handleEvent(event: PictureInPictureEvents) { + when (event) { + PictureInPictureEvents.EnterPictureInPicture -> switchToPip() + } + } + + return PictureInPictureState( + supportPip = isPipSupported, + isInPictureInPicture = isInPictureInPicture.value, + eventSink = ::handleEvent, + ) + } + + fun onCreate(activity: Activity) { + if (isPipSupported) { + Timber.tag(loggerTag.value).d("onCreate: Setting PiP params") + hostActivity = WeakReference(activity) + hostActivity?.get()?.setPictureInPictureParams(getPictureInPictureParams()) + } 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. + */ + private fun switchToPip() { + 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") } + } + } + + fun onPictureInPictureModeChanged(isInPictureInPictureMode: Boolean) { + Timber.tag(loggerTag.value).d("onPictureInPictureModeChanged: $isInPictureInPictureMode") + isInPictureInPicture.value = isInPictureInPictureMode + } + + fun onUserLeaveHint() { + Timber.tag(loggerTag.value).d("onUserLeaveHint") + switchToPip() + } +} diff --git a/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/pip/PictureInPictureState.kt b/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/pip/PictureInPictureState.kt new file mode 100644 index 0000000000..e6b86c82f0 --- /dev/null +++ b/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/pip/PictureInPictureState.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 + * + * 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 + +data class PictureInPictureState( + val supportPip: Boolean, + val isInPictureInPicture: Boolean, + val eventSink: (PictureInPictureEvents) -> Unit, +) diff --git a/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/pip/PictureInPictureStateProvider.kt b/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/pip/PictureInPictureStateProvider.kt new file mode 100644 index 0000000000..360ee54d3f --- /dev/null +++ b/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/pip/PictureInPictureStateProvider.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 + +fun aPictureInPictureState( + supportPip: Boolean = false, + isInPictureInPicture: Boolean = false, + eventSink: (PictureInPictureEvents) -> Unit = {}, +): PictureInPictureState { + return PictureInPictureState( + supportPip = supportPip, + isInPictureInPicture = isInPictureInPicture, + eventSink = eventSink, + ) +} diff --git a/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/pip/PipSupportProvider.kt b/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/pip/PipSupportProvider.kt new file mode 100644 index 0000000000..16dcb8d66c --- /dev/null +++ b/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/pip/PipSupportProvider.kt @@ -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 + * + * 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 android.content.Context +import android.content.pm.PackageManager +import android.os.Build +import androidx.annotation.ChecksSdkIntAtLeast +import com.squareup.anvil.annotations.ContributesBinding +import io.element.android.libraries.core.bool.orFalse +import io.element.android.libraries.di.AppScope +import io.element.android.libraries.di.ApplicationContext +import javax.inject.Inject + +interface PipSupportProvider { + @ChecksSdkIntAtLeast(Build.VERSION_CODES.O) + fun isPipSupported(): Boolean +} + +@ContributesBinding(AppScope::class) +class DefaultPipSupportProvider @Inject constructor( + @ApplicationContext private val context: Context, +) : PipSupportProvider { + override fun isPipSupported(): Boolean { + val hasSystemFeaturePip = context.packageManager?.hasSystemFeature(PackageManager.FEATURE_PICTURE_IN_PICTURE).orFalse() + return Build.VERSION.SDK_INT >= Build.VERSION_CODES.O && hasSystemFeaturePip + } +} diff --git a/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/ui/CallScreenView.kt b/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/ui/CallScreenView.kt index 23d0a4769e..c8d202f8cc 100644 --- a/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/ui/CallScreenView.kt +++ b/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/ui/CallScreenView.kt @@ -36,6 +36,9 @@ import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.compose.ui.viewinterop.AndroidView import io.element.android.compound.tokens.generated.CompoundIcons import io.element.android.features.call.impl.R +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.aPictureInPictureState import io.element.android.features.call.impl.utils.WebViewWidgetMessageInterceptor import io.element.android.libraries.architecture.AsyncData import io.element.android.libraries.designsystem.components.ProgressDialog @@ -58,25 +61,36 @@ interface CallScreenNavigator { @Composable internal fun CallScreenView( state: CallScreenState, + pipState: PictureInPictureState, requestPermissions: (Array, RequestPermissionCallback) -> Unit, modifier: Modifier = Modifier, ) { + fun handleBack() { + if (pipState.supportPip) { + pipState.eventSink.invoke(PictureInPictureEvents.EnterPictureInPicture) + } else { + state.eventSink(CallScreenEvents.Hangup) + } + } + Scaffold( modifier = modifier, topBar = { - TopAppBar( - title = { Text(stringResource(R.string.element_call)) }, - navigationIcon = { - BackButton( - imageVector = CompoundIcons.Close(), - onClick = { state.eventSink(CallScreenEvents.Hangup) } - ) - } - ) + if (!pipState.isInPictureInPicture) { + TopAppBar( + title = { Text(stringResource(R.string.element_call)) }, + navigationIcon = { + BackButton( + imageVector = CompoundIcons.Close(), + onClick = ::handleBack, + ) + } + ) + } } ) { padding -> BackHandler { - state.eventSink(CallScreenEvents.Hangup) + handleBack() } CallWebView( modifier = Modifier @@ -177,6 +191,7 @@ internal fun CallScreenViewPreview( ) = ElementPreview { CallScreenView( state = state, + pipState = aPictureInPictureState(), requestPermissions = { _, _ -> }, ) } diff --git a/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/ui/ElementCallActivity.kt b/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/ui/ElementCallActivity.kt index 1f4313864d..c770deff60 100644 --- a/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/ui/ElementCallActivity.kt +++ b/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/ui/ElementCallActivity.kt @@ -42,6 +42,7 @@ import io.element.android.compound.theme.mapToTheme 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.PictureInPicturePresenter 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 @@ -52,6 +53,7 @@ class ElementCallActivity : AppCompatActivity(), CallScreenNavigator { @Inject lateinit var callIntentDataParser: CallIntentDataParser @Inject lateinit var presenterFactory: CallScreenPresenter.Factory @Inject lateinit var appPreferencesStore: AppPreferencesStore + @Inject lateinit var pictureInPicturePresenter: PictureInPicturePresenter private lateinit var presenter: CallScreenPresenter @@ -86,6 +88,8 @@ class ElementCallActivity : AppCompatActivity(), CallScreenNavigator { updateUiMode(resources.configuration) } + pictureInPicturePresenter.onCreate(this) + audioManager = getSystemService(AUDIO_SERVICE) as AudioManager requestAudioFocus() @@ -95,11 +99,13 @@ class ElementCallActivity : AppCompatActivity(), CallScreenNavigator { } .collectAsState(initial = Theme.System) val state = presenter.present() + val pipState = pictureInPicturePresenter.present() ElementTheme( darkTheme = theme.isDark() ) { CallScreenView( state = state, + pipState = pipState, requestPermissions = { permissions, callback -> requestPermissionCallback = callback requestPermissionsLauncher.launch(permissions) @@ -114,6 +120,11 @@ class ElementCallActivity : AppCompatActivity(), CallScreenNavigator { updateUiMode(newConfig) } + override fun onPictureInPictureModeChanged(isInPictureInPictureMode: Boolean, newConfig: Configuration) { + super.onPictureInPictureModeChanged(isInPictureInPictureMode, newConfig) + pictureInPicturePresenter.onPictureInPictureModeChanged(isInPictureInPictureMode) + } + override fun onNewIntent(intent: Intent) { super.onNewIntent(intent) setCallType(intent) @@ -131,10 +142,16 @@ class ElementCallActivity : AppCompatActivity(), CallScreenNavigator { } } + override fun onUserLeaveHint() { + super.onUserLeaveHint() + pictureInPicturePresenter.onUserLeaveHint() + } + override fun onDestroy() { super.onDestroy() releaseAudioFocus() CallForegroundService.stop(this) + pictureInPicturePresenter.onDestroy() } override fun finish() { diff --git a/features/call/impl/src/test/kotlin/io/element/android/features/call/impl/pip/FakePipSupportProvider.kt b/features/call/impl/src/test/kotlin/io/element/android/features/call/impl/pip/FakePipSupportProvider.kt new file mode 100644 index 0000000000..5a4dc98275 --- /dev/null +++ b/features/call/impl/src/test/kotlin/io/element/android/features/call/impl/pip/FakePipSupportProvider.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 + * + * 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 + +class FakePipSupportProvider( + private val isPipSupported: Boolean +) : PipSupportProvider { + override fun isPipSupported() = isPipSupported +} diff --git a/features/call/impl/src/test/kotlin/io/element/android/features/call/impl/pip/PictureInPicturePresenterTest.kt b/features/call/impl/src/test/kotlin/io/element/android/features/call/impl/pip/PictureInPicturePresenterTest.kt new file mode 100644 index 0000000000..b1d9df68dd --- /dev/null +++ b/features/call/impl/src/test/kotlin/io/element/android/features/call/impl/pip/PictureInPicturePresenterTest.kt @@ -0,0 +1,108 @@ +/* + * 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 android.os.Build.VERSION_CODES +import app.cash.molecule.RecompositionMode +import app.cash.molecule.moleculeFlow +import app.cash.turbine.test +import com.google.common.truth.Truth.assertThat +import io.element.android.features.call.impl.ui.ElementCallActivity +import kotlinx.coroutines.test.runTest +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 { + @Test + @Config(sdk = [VERSION_CODES.N, VERSION_CODES.O, VERSION_CODES.S]) + fun `when pip is not supported, the state value supportPip is false`() = runTest { + val presenter = createPictureInPicturePresenter(supportPip = false) + moleculeFlow(RecompositionMode.Immediate) { + presenter.present() + }.test { + val initialState = awaitItem() + assertThat(initialState.supportPip).isFalse() + } + presenter.onDestroy() + } + + @Test + @Config(sdk = [VERSION_CODES.O, VERSION_CODES.S]) + fun `when pip is supported, the state value supportPip is true`() = runTest { + val presenter = createPictureInPicturePresenter(supportPip = true) + moleculeFlow(RecompositionMode.Immediate) { + presenter.present() + }.test { + val initialState = awaitItem() + assertThat(initialState.supportPip).isTrue() + } + presenter.onDestroy() + } + + @Test + @Config(sdk = [VERSION_CODES.S]) + fun `when entering pip is supported, the state value isInPictureInPicture is true`() = runTest { + val presenter = createPictureInPicturePresenter(supportPip = true) + moleculeFlow(RecompositionMode.Immediate) { + presenter.present() + }.test { + val initialState = awaitItem() + assertThat(initialState.isInPictureInPicture).isFalse() + initialState.eventSink(PictureInPictureEvents.EnterPictureInPicture) + presenter.onPictureInPictureModeChanged(true) + val pipState = awaitItem() + assertThat(pipState.isInPictureInPicture).isTrue() + // User stops pip + presenter.onPictureInPictureModeChanged(false) + val finalState = awaitItem() + assertThat(finalState.isInPictureInPicture).isFalse() + } + presenter.onDestroy() + } + + @Test + @Config(sdk = [VERSION_CODES.S]) + fun `when onUserLeaveHint is called, the state value isInPictureInPicture becomes true`() = runTest { + val presenter = createPictureInPicturePresenter(supportPip = true) + moleculeFlow(RecompositionMode.Immediate) { + presenter.present() + }.test { + val initialState = awaitItem() + assertThat(initialState.isInPictureInPicture).isFalse() + presenter.onUserLeaveHint() + presenter.onPictureInPictureModeChanged(true) + val pipState = awaitItem() + assertThat(pipState.isInPictureInPicture).isTrue() + } + presenter.onDestroy() + } + + private fun createPictureInPicturePresenter( + supportPip: Boolean = true, + ): PictureInPicturePresenter { + val activity = Robolectric.buildActivity(ElementCallActivity::class.java) + return PictureInPicturePresenter( + pipSupportProvider = FakePipSupportProvider(supportPip), + ).apply { + onCreate(activity.get()) + } + } +}