Benoit Marty
2 months ago
committed by
GitHub
21 changed files with 521 additions and 33 deletions
@ -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 |
||||||
|
} |
@ -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<PictureInPictureState> { |
||||||
|
private val isPipSupported = pipSupportProvider.isPipSupported() |
||||||
|
private var isInPictureInPicture = mutableStateOf(false) |
||||||
|
private var hostActivity: WeakReference<Activity>? = 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() |
||||||
|
} |
||||||
|
} |
@ -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, |
||||||
|
) |
@ -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, |
||||||
|
) |
||||||
|
} |
@ -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 |
||||||
|
} |
||||||
|
} |
@ -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 |
||||||
|
} |
@ -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()) |
||||||
|
} |
||||||
|
} |
||||||
|
} |
@ -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<ComponentActivity>() |
||||||
|
|
||||||
|
@Test |
||||||
|
fun `clicking on back when pip is not supported hangs up`() { |
||||||
|
val eventsRecorder = EventsRecorder<CallScreenEvents>() |
||||||
|
val pipEventsRecorder = EventsRecorder<PictureInPictureEvents>(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<CallScreenEvents>() |
||||||
|
val pipEventsRecorder = EventsRecorder<PictureInPictureEvents>() |
||||||
|
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 <R : TestRule> AndroidComposeTestRule<R, ComponentActivity>.setCallScreenView( |
||||||
|
state: CallScreenState, |
||||||
|
pipState: PictureInPictureState, |
||||||
|
requestPermissions: (Array<String>, RequestPermissionCallback) -> Unit = { _, _ -> }, |
||||||
|
) { |
||||||
|
setContent { |
||||||
|
CallScreenView( |
||||||
|
state = state, |
||||||
|
pipState = pipState, |
||||||
|
requestPermissions = requestPermissions, |
||||||
|
) |
||||||
|
} |
||||||
|
} |
@ -1,3 +1,3 @@ |
|||||||
version https://git-lfs.github.com/spec/v1 |
version https://git-lfs.github.com/spec/v1 |
||||||
oid sha256:68d9ca60586aac84157c60f126b17b70ca9d52087da80f253b60f47de87d7ff6 |
oid sha256:c976f3c1d4809c28cb865b0dfe7ce1eed5fe2c9959a80da8efab5d3594e38e41 |
||||||
size 13750 |
size 14427 |
||||||
|
@ -0,0 +1,3 @@ |
|||||||
|
version https://git-lfs.github.com/spec/v1 |
||||||
|
oid sha256:68d9ca60586aac84157c60f126b17b70ca9d52087da80f253b60f47de87d7ff6 |
||||||
|
size 13750 |
@ -1,3 +1,3 @@ |
|||||||
version https://git-lfs.github.com/spec/v1 |
version https://git-lfs.github.com/spec/v1 |
||||||
oid sha256:b676c158a1f820d50c9ffd50d048d5c236ee8356279663f257a4882f06c5a1a9 |
oid sha256:d6acbdb4ea1e66fa4638fc9b454566968081c511e0dcfde3f1e57fd9725a1edb |
||||||
size 12214 |
size 13263 |
||||||
|
@ -0,0 +1,3 @@ |
|||||||
|
version https://git-lfs.github.com/spec/v1 |
||||||
|
oid sha256:b676c158a1f820d50c9ffd50d048d5c236ee8356279663f257a4882f06c5a1a9 |
||||||
|
size 12214 |
Loading…
Reference in new issue