Benoit Marty
3 months ago
10 changed files with 395 additions and 11 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.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()) |
||||||
|
} |
||||||
|
} |
||||||
|
} |
Loading…
Reference in new issue