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