diff --git a/features/call/impl/build.gradle.kts b/features/call/impl/build.gradle.kts index d6d46d06b0..b2d845a003 100644 --- a/features/call/impl/build.gradle.kts +++ b/features/call/impl/build.gradle.kts @@ -70,4 +70,6 @@ dependencies { testImplementation(projects.libraries.push.test) testImplementation(projects.services.analytics.test) testImplementation(projects.tests.testutils) + testImplementation(libs.androidx.compose.ui.test.junit) + testReleaseImplementation(libs.androidx.compose.ui.test.manifest) } diff --git a/features/call/impl/src/main/AndroidManifest.xml b/features/call/impl/src/main/AndroidManifest.xml index 354ea7533d..bdd88cf47a 100644 --- a/features/call/impl/src/main/AndroidManifest.xml +++ b/features/call/impl/src/main/AndroidManifest.xml @@ -38,10 +38,11 @@ @@ -77,10 +78,11 @@ - @@ -90,9 +92,10 @@ android:exported="false" android:foregroundServiceType="phoneCall" /> - + 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/CallScreenStateProvider.kt b/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/ui/CallScreenStateProvider.kt index be6622d8ee..ec30725fff 100644 --- a/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/ui/CallScreenStateProvider.kt +++ b/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/ui/CallScreenStateProvider.kt @@ -23,11 +23,12 @@ open class CallScreenStateProvider : PreviewParameterProvider { override val values: Sequence get() = sequenceOf( aCallScreenState(), + aCallScreenState(urlState = AsyncData.Loading()), aCallScreenState(urlState = AsyncData.Failure(Exception("An error occurred"))), ) } -private fun aCallScreenState( +internal fun aCallScreenState( urlState: AsyncData = AsyncData.Success("https://call.element.io/some-actual-call?with=parameters"), userAgent: String = "", isInWidgetMode: Boolean = false, 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..895505c278 --- /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.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()) + } + } +} diff --git a/features/call/impl/src/test/kotlin/io/element/android/features/call/impl/ui/CallScreenViewTest.kt b/features/call/impl/src/test/kotlin/io/element/android/features/call/impl/ui/CallScreenViewTest.kt new file mode 100644 index 0000000000..6d15e5001c --- /dev/null +++ b/features/call/impl/src/test/kotlin/io/element/android/features/call/impl/ui/CallScreenViewTest.kt @@ -0,0 +1,88 @@ +/* + * 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.ui + +import androidx.activity.ComponentActivity +import androidx.compose.ui.test.junit4.AndroidComposeTestRule +import androidx.compose.ui.test.junit4.createAndroidComposeRule +import androidx.test.ext.junit.runners.AndroidJUnit4 +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.tests.testutils.EventsRecorder +import io.element.android.tests.testutils.pressBack +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TestRule +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class CallScreenViewTest { + @get:Rule val rule = createAndroidComposeRule() + + @Test + fun `clicking on back when pip is not supported hangs up`() { + val eventsRecorder = EventsRecorder() + val pipEventsRecorder = EventsRecorder(expectEvents = false) + rule.setCallScreenView( + aCallScreenState( + eventSink = eventsRecorder + ), + aPictureInPictureState( + supportPip = false, + eventSink = pipEventsRecorder, + ), + ) + rule.pressBack() + eventsRecorder.assertSize(2) + eventsRecorder.assertTrue(0) { it is CallScreenEvents.SetupMessageChannels } + eventsRecorder.assertTrue(1) { it == CallScreenEvents.Hangup } + } + + @Test + fun `clicking on back when pip is supported enables PiP`() { + val eventsRecorder = EventsRecorder() + val pipEventsRecorder = EventsRecorder() + rule.setCallScreenView( + aCallScreenState( + eventSink = eventsRecorder + ), + aPictureInPictureState( + supportPip = true, + eventSink = pipEventsRecorder, + ), + ) + rule.pressBack() + eventsRecorder.assertSize(1) + eventsRecorder.assertTrue(0) { it is CallScreenEvents.SetupMessageChannels } + pipEventsRecorder.assertSingle(PictureInPictureEvents.EnterPictureInPicture) + } +} + +private fun AndroidComposeTestRule.setCallScreenView( + state: CallScreenState, + pipState: PictureInPictureState, + requestPermissions: (Array, RequestPermissionCallback) -> Unit = { _, _ -> }, +) { + setContent { + CallScreenView( + state = state, + pipState = pipState, + requestPermissions = requestPermissions, + ) + } +} diff --git a/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/blockedusers/BlockedUserViewTest.kt b/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/blockedusers/BlockedUserViewTest.kt index 353d505e50..e7e70623f2 100644 --- a/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/blockedusers/BlockedUserViewTest.kt +++ b/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/blockedusers/BlockedUserViewTest.kt @@ -45,7 +45,7 @@ class BlockedUserViewTest { fun `clicking on back invokes back callback`() { val eventsRecorder = EventsRecorder(expectEvents = false) ensureCalledOnce { callback -> - rule.setLogoutView( + rule.setBlockedUsersView( aBlockedUsersState( eventSink = eventsRecorder ), @@ -59,7 +59,7 @@ class BlockedUserViewTest { fun `clicking on a user emits the expected Event`() { val eventsRecorder = EventsRecorder() val userList = aMatrixUserList() - rule.setLogoutView( + rule.setBlockedUsersView( aBlockedUsersState( blockedUsers = userList, eventSink = eventsRecorder @@ -72,7 +72,7 @@ class BlockedUserViewTest { @Test fun `clicking on cancel sends a BlockedUsersEvents`() { val eventsRecorder = EventsRecorder() - rule.setLogoutView( + rule.setBlockedUsersView( aBlockedUsersState( unblockUserAction = AsyncAction.Confirming, eventSink = eventsRecorder @@ -85,7 +85,7 @@ class BlockedUserViewTest { @Test fun `clicking on confirm sends a BlockedUsersEvents`() { val eventsRecorder = EventsRecorder() - rule.setLogoutView( + rule.setBlockedUsersView( aBlockedUsersState( unblockUserAction = AsyncAction.Confirming, eventSink = eventsRecorder @@ -96,7 +96,7 @@ class BlockedUserViewTest { } } -private fun AndroidComposeTestRule.setLogoutView( +private fun AndroidComposeTestRule.setBlockedUsersView( state: BlockedUsersState, onBackClick: () -> Unit = EnsureNeverCalled(), ) { diff --git a/tests/testutils/src/main/kotlin/io/element/android/tests/testutils/EventsRecorder.kt b/tests/testutils/src/main/kotlin/io/element/android/tests/testutils/EventsRecorder.kt index 3a1c4babff..7818de4118 100644 --- a/tests/testutils/src/main/kotlin/io/element/android/tests/testutils/EventsRecorder.kt +++ b/tests/testutils/src/main/kotlin/io/element/android/tests/testutils/EventsRecorder.kt @@ -42,4 +42,12 @@ class EventsRecorder( fun assertList(expectedEvents: List) { assertThat(events).isEqualTo(expectedEvents) } + + fun assertSize(size: Int) { + assertThat(events.size).isEqualTo(size) + } + + fun assertTrue(index: Int, predicate: (T) -> Boolean) { + assertThat(predicate(events[index])).isTrue() + } } diff --git a/tests/uitests/src/test/snapshots/images/features.call.impl.ui_CallScreenView_Day_1_en.png b/tests/uitests/src/test/snapshots/images/features.call.impl.ui_CallScreenView_Day_1_en.png index abd445c244..9d11ad5395 100644 --- a/tests/uitests/src/test/snapshots/images/features.call.impl.ui_CallScreenView_Day_1_en.png +++ b/tests/uitests/src/test/snapshots/images/features.call.impl.ui_CallScreenView_Day_1_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:68d9ca60586aac84157c60f126b17b70ca9d52087da80f253b60f47de87d7ff6 -size 13750 +oid sha256:c976f3c1d4809c28cb865b0dfe7ce1eed5fe2c9959a80da8efab5d3594e38e41 +size 14427 diff --git a/tests/uitests/src/test/snapshots/images/features.call.impl.ui_CallScreenView_Day_2_en.png b/tests/uitests/src/test/snapshots/images/features.call.impl.ui_CallScreenView_Day_2_en.png new file mode 100644 index 0000000000..abd445c244 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.call.impl.ui_CallScreenView_Day_2_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:68d9ca60586aac84157c60f126b17b70ca9d52087da80f253b60f47de87d7ff6 +size 13750 diff --git a/tests/uitests/src/test/snapshots/images/features.call.impl.ui_CallScreenView_Night_1_en.png b/tests/uitests/src/test/snapshots/images/features.call.impl.ui_CallScreenView_Night_1_en.png index 1c4cfe0583..3fee0596fb 100644 --- a/tests/uitests/src/test/snapshots/images/features.call.impl.ui_CallScreenView_Night_1_en.png +++ b/tests/uitests/src/test/snapshots/images/features.call.impl.ui_CallScreenView_Night_1_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:b676c158a1f820d50c9ffd50d048d5c236ee8356279663f257a4882f06c5a1a9 -size 12214 +oid sha256:d6acbdb4ea1e66fa4638fc9b454566968081c511e0dcfde3f1e57fd9725a1edb +size 13263 diff --git a/tests/uitests/src/test/snapshots/images/features.call.impl.ui_CallScreenView_Night_2_en.png b/tests/uitests/src/test/snapshots/images/features.call.impl.ui_CallScreenView_Night_2_en.png new file mode 100644 index 0000000000..1c4cfe0583 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.call.impl.ui_CallScreenView_Night_2_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:b676c158a1f820d50c9ffd50d048d5c236ee8356279663f257a4882f06c5a1a9 +size 12214