Benoit Marty
3 months ago
10 changed files with 395 additions and 11 deletions
@ -0,0 +1,21 @@
@@ -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 @@
@@ -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 @@
@@ -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 @@
@@ -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 @@
@@ -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 @@
@@ -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 @@
@@ -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