Merge branch 'develop' into feature/bma/realDarkTheme
This commit is contained in:
commit
15873e9af2
21 changed files with 521 additions and 33 deletions
7
.github/pull_request_template.md
vendored
7
.github/pull_request_template.md
vendored
|
|
@ -1,12 +1,5 @@
|
||||||
<!-- Please read [CONTRIBUTING.md](https://github.com/element-hq/element-x-android/blob/develop/CONTRIBUTING.md) before submitting your pull request -->
|
<!-- Please read [CONTRIBUTING.md](https://github.com/element-hq/element-x-android/blob/develop/CONTRIBUTING.md) before submitting your pull request -->
|
||||||
|
|
||||||
## Type of change
|
|
||||||
|
|
||||||
- [ ] Feature
|
|
||||||
- [ ] Bugfix
|
|
||||||
- [ ] Technical
|
|
||||||
- [ ] Other :
|
|
||||||
|
|
||||||
## Content
|
## Content
|
||||||
|
|
||||||
<!-- Describe shortly what has been changed -->
|
<!-- Describe shortly what has been changed -->
|
||||||
|
|
|
||||||
|
|
@ -70,4 +70,6 @@ dependencies {
|
||||||
testImplementation(projects.libraries.push.test)
|
testImplementation(projects.libraries.push.test)
|
||||||
testImplementation(projects.services.analytics.test)
|
testImplementation(projects.services.analytics.test)
|
||||||
testImplementation(projects.tests.testutils)
|
testImplementation(projects.tests.testutils)
|
||||||
|
testImplementation(libs.androidx.compose.ui.test.junit)
|
||||||
|
testReleaseImplementation(libs.androidx.compose.ui.test.manifest)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -38,10 +38,11 @@
|
||||||
<application>
|
<application>
|
||||||
<activity
|
<activity
|
||||||
android:name=".ui.ElementCallActivity"
|
android:name=".ui.ElementCallActivity"
|
||||||
android:configChanges="screenSize|screenLayout|orientation|keyboardHidden|keyboard|navigation|uiMode"
|
android:configChanges="screenSize|smallestScreenSize|screenLayout|orientation|keyboardHidden|keyboard|navigation|uiMode"
|
||||||
android:exported="true"
|
android:exported="true"
|
||||||
android:label="@string/element_call"
|
android:label="@string/element_call"
|
||||||
android:launchMode="singleTask"
|
android:launchMode="singleTask"
|
||||||
|
android:supportsPictureInPicture="true"
|
||||||
android:taskAffinity="io.element.android.features.call">
|
android:taskAffinity="io.element.android.features.call">
|
||||||
|
|
||||||
<intent-filter android:autoVerify="true">
|
<intent-filter android:autoVerify="true">
|
||||||
|
|
@ -77,10 +78,11 @@
|
||||||
|
|
||||||
</activity>
|
</activity>
|
||||||
|
|
||||||
<activity android:name=".ui.IncomingCallActivity"
|
<activity
|
||||||
|
android:name=".ui.IncomingCallActivity"
|
||||||
android:configChanges="screenSize|screenLayout|orientation|keyboardHidden|keyboard|navigation|uiMode"
|
android:configChanges="screenSize|screenLayout|orientation|keyboardHidden|keyboard|navigation|uiMode"
|
||||||
android:exported="false"
|
|
||||||
android:excludeFromRecents="true"
|
android:excludeFromRecents="true"
|
||||||
|
android:exported="false"
|
||||||
android:launchMode="singleTask"
|
android:launchMode="singleTask"
|
||||||
android:taskAffinity="io.element.android.features.call" />
|
android:taskAffinity="io.element.android.features.call" />
|
||||||
|
|
||||||
|
|
@ -90,9 +92,10 @@
|
||||||
android:exported="false"
|
android:exported="false"
|
||||||
android:foregroundServiceType="phoneCall" />
|
android:foregroundServiceType="phoneCall" />
|
||||||
|
|
||||||
<receiver android:name=".receivers.DeclineCallBroadcastReceiver"
|
<receiver
|
||||||
android:exported="false"
|
android:name=".receivers.DeclineCallBroadcastReceiver"
|
||||||
android:enabled="true" />
|
android:enabled="true"
|
||||||
|
android:exported="false" />
|
||||||
|
|
||||||
</application>
|
</application>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -23,11 +23,12 @@ open class CallScreenStateProvider : PreviewParameterProvider<CallScreenState> {
|
||||||
override val values: Sequence<CallScreenState>
|
override val values: Sequence<CallScreenState>
|
||||||
get() = sequenceOf(
|
get() = sequenceOf(
|
||||||
aCallScreenState(),
|
aCallScreenState(),
|
||||||
|
aCallScreenState(urlState = AsyncData.Loading()),
|
||||||
aCallScreenState(urlState = AsyncData.Failure(Exception("An error occurred"))),
|
aCallScreenState(urlState = AsyncData.Failure(Exception("An error occurred"))),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun aCallScreenState(
|
internal fun aCallScreenState(
|
||||||
urlState: AsyncData<String> = AsyncData.Success("https://call.element.io/some-actual-call?with=parameters"),
|
urlState: AsyncData<String> = AsyncData.Success("https://call.element.io/some-actual-call?with=parameters"),
|
||||||
userAgent: String = "",
|
userAgent: String = "",
|
||||||
isInWidgetMode: Boolean = false,
|
isInWidgetMode: Boolean = false,
|
||||||
|
|
|
||||||
|
|
@ -36,6 +36,9 @@ import androidx.compose.ui.tooling.preview.PreviewParameter
|
||||||
import androidx.compose.ui.viewinterop.AndroidView
|
import androidx.compose.ui.viewinterop.AndroidView
|
||||||
import io.element.android.compound.tokens.generated.CompoundIcons
|
import io.element.android.compound.tokens.generated.CompoundIcons
|
||||||
import io.element.android.features.call.impl.R
|
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.features.call.impl.utils.WebViewWidgetMessageInterceptor
|
||||||
import io.element.android.libraries.architecture.AsyncData
|
import io.element.android.libraries.architecture.AsyncData
|
||||||
import io.element.android.libraries.designsystem.components.ProgressDialog
|
import io.element.android.libraries.designsystem.components.ProgressDialog
|
||||||
|
|
@ -58,25 +61,36 @@ interface CallScreenNavigator {
|
||||||
@Composable
|
@Composable
|
||||||
internal fun CallScreenView(
|
internal fun CallScreenView(
|
||||||
state: CallScreenState,
|
state: CallScreenState,
|
||||||
|
pipState: PictureInPictureState,
|
||||||
requestPermissions: (Array<String>, RequestPermissionCallback) -> Unit,
|
requestPermissions: (Array<String>, RequestPermissionCallback) -> Unit,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
|
fun handleBack() {
|
||||||
|
if (pipState.supportPip) {
|
||||||
|
pipState.eventSink.invoke(PictureInPictureEvents.EnterPictureInPicture)
|
||||||
|
} else {
|
||||||
|
state.eventSink(CallScreenEvents.Hangup)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Scaffold(
|
Scaffold(
|
||||||
modifier = modifier,
|
modifier = modifier,
|
||||||
topBar = {
|
topBar = {
|
||||||
TopAppBar(
|
if (!pipState.isInPictureInPicture) {
|
||||||
title = { Text(stringResource(R.string.element_call)) },
|
TopAppBar(
|
||||||
navigationIcon = {
|
title = { Text(stringResource(R.string.element_call)) },
|
||||||
BackButton(
|
navigationIcon = {
|
||||||
imageVector = CompoundIcons.Close(),
|
BackButton(
|
||||||
onClick = { state.eventSink(CallScreenEvents.Hangup) }
|
imageVector = CompoundIcons.Close(),
|
||||||
)
|
onClick = ::handleBack,
|
||||||
}
|
)
|
||||||
)
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
) { padding ->
|
) { padding ->
|
||||||
BackHandler {
|
BackHandler {
|
||||||
state.eventSink(CallScreenEvents.Hangup)
|
handleBack()
|
||||||
}
|
}
|
||||||
CallWebView(
|
CallWebView(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
|
|
@ -177,6 +191,7 @@ internal fun CallScreenViewPreview(
|
||||||
) = ElementPreview {
|
) = ElementPreview {
|
||||||
CallScreenView(
|
CallScreenView(
|
||||||
state = state,
|
state = state,
|
||||||
|
pipState = aPictureInPictureState(),
|
||||||
requestPermissions = { _, _ -> },
|
requestPermissions = { _, _ -> },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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.api.CallType
|
||||||
import io.element.android.features.call.impl.DefaultElementCallEntryPoint
|
import io.element.android.features.call.impl.DefaultElementCallEntryPoint
|
||||||
import io.element.android.features.call.impl.di.CallBindings
|
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.services.CallForegroundService
|
||||||
import io.element.android.features.call.impl.utils.CallIntentDataParser
|
import io.element.android.features.call.impl.utils.CallIntentDataParser
|
||||||
import io.element.android.libraries.architecture.bindings
|
import io.element.android.libraries.architecture.bindings
|
||||||
|
|
@ -52,6 +53,7 @@ class ElementCallActivity : AppCompatActivity(), CallScreenNavigator {
|
||||||
@Inject lateinit var callIntentDataParser: CallIntentDataParser
|
@Inject lateinit var callIntentDataParser: CallIntentDataParser
|
||||||
@Inject lateinit var presenterFactory: CallScreenPresenter.Factory
|
@Inject lateinit var presenterFactory: CallScreenPresenter.Factory
|
||||||
@Inject lateinit var appPreferencesStore: AppPreferencesStore
|
@Inject lateinit var appPreferencesStore: AppPreferencesStore
|
||||||
|
@Inject lateinit var pictureInPicturePresenter: PictureInPicturePresenter
|
||||||
|
|
||||||
private lateinit var presenter: CallScreenPresenter
|
private lateinit var presenter: CallScreenPresenter
|
||||||
|
|
||||||
|
|
@ -86,6 +88,8 @@ class ElementCallActivity : AppCompatActivity(), CallScreenNavigator {
|
||||||
updateUiMode(resources.configuration)
|
updateUiMode(resources.configuration)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pictureInPicturePresenter.onCreate(this)
|
||||||
|
|
||||||
audioManager = getSystemService(AUDIO_SERVICE) as AudioManager
|
audioManager = getSystemService(AUDIO_SERVICE) as AudioManager
|
||||||
requestAudioFocus()
|
requestAudioFocus()
|
||||||
|
|
||||||
|
|
@ -94,12 +98,14 @@ class ElementCallActivity : AppCompatActivity(), CallScreenNavigator {
|
||||||
appPreferencesStore.getThemeFlow().mapToTheme()
|
appPreferencesStore.getThemeFlow().mapToTheme()
|
||||||
}
|
}
|
||||||
.collectAsState(initial = Theme.System)
|
.collectAsState(initial = Theme.System)
|
||||||
|
val pipState = pictureInPicturePresenter.present()
|
||||||
ElementTheme(
|
ElementTheme(
|
||||||
darkTheme = theme.isDark()
|
darkTheme = theme.isDark()
|
||||||
) {
|
) {
|
||||||
val state = presenter.present()
|
val state = presenter.present()
|
||||||
CallScreenView(
|
CallScreenView(
|
||||||
state = state,
|
state = state,
|
||||||
|
pipState = pipState,
|
||||||
requestPermissions = { permissions, callback ->
|
requestPermissions = { permissions, callback ->
|
||||||
requestPermissionCallback = callback
|
requestPermissionCallback = callback
|
||||||
requestPermissionsLauncher.launch(permissions)
|
requestPermissionsLauncher.launch(permissions)
|
||||||
|
|
@ -114,6 +120,11 @@ class ElementCallActivity : AppCompatActivity(), CallScreenNavigator {
|
||||||
updateUiMode(newConfig)
|
updateUiMode(newConfig)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override fun onPictureInPictureModeChanged(isInPictureInPictureMode: Boolean, newConfig: Configuration) {
|
||||||
|
super.onPictureInPictureModeChanged(isInPictureInPictureMode, newConfig)
|
||||||
|
pictureInPicturePresenter.onPictureInPictureModeChanged(isInPictureInPictureMode)
|
||||||
|
}
|
||||||
|
|
||||||
override fun onNewIntent(intent: Intent) {
|
override fun onNewIntent(intent: Intent) {
|
||||||
super.onNewIntent(intent)
|
super.onNewIntent(intent)
|
||||||
setCallType(intent)
|
setCallType(intent)
|
||||||
|
|
@ -131,10 +142,16 @@ class ElementCallActivity : AppCompatActivity(), CallScreenNavigator {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override fun onUserLeaveHint() {
|
||||||
|
super.onUserLeaveHint()
|
||||||
|
pictureInPicturePresenter.onUserLeaveHint()
|
||||||
|
}
|
||||||
|
|
||||||
override fun onDestroy() {
|
override fun onDestroy() {
|
||||||
super.onDestroy()
|
super.onDestroy()
|
||||||
releaseAudioFocus()
|
releaseAudioFocus()
|
||||||
CallForegroundService.stop(this)
|
CallForegroundService.stop(this)
|
||||||
|
pictureInPicturePresenter.onDestroy()
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun finish() {
|
override fun finish() {
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -45,7 +45,7 @@ class BlockedUserViewTest {
|
||||||
fun `clicking on back invokes back callback`() {
|
fun `clicking on back invokes back callback`() {
|
||||||
val eventsRecorder = EventsRecorder<BlockedUsersEvents>(expectEvents = false)
|
val eventsRecorder = EventsRecorder<BlockedUsersEvents>(expectEvents = false)
|
||||||
ensureCalledOnce { callback ->
|
ensureCalledOnce { callback ->
|
||||||
rule.setLogoutView(
|
rule.setBlockedUsersView(
|
||||||
aBlockedUsersState(
|
aBlockedUsersState(
|
||||||
eventSink = eventsRecorder
|
eventSink = eventsRecorder
|
||||||
),
|
),
|
||||||
|
|
@ -59,7 +59,7 @@ class BlockedUserViewTest {
|
||||||
fun `clicking on a user emits the expected Event`() {
|
fun `clicking on a user emits the expected Event`() {
|
||||||
val eventsRecorder = EventsRecorder<BlockedUsersEvents>()
|
val eventsRecorder = EventsRecorder<BlockedUsersEvents>()
|
||||||
val userList = aMatrixUserList()
|
val userList = aMatrixUserList()
|
||||||
rule.setLogoutView(
|
rule.setBlockedUsersView(
|
||||||
aBlockedUsersState(
|
aBlockedUsersState(
|
||||||
blockedUsers = userList,
|
blockedUsers = userList,
|
||||||
eventSink = eventsRecorder
|
eventSink = eventsRecorder
|
||||||
|
|
@ -72,7 +72,7 @@ class BlockedUserViewTest {
|
||||||
@Test
|
@Test
|
||||||
fun `clicking on cancel sends a BlockedUsersEvents`() {
|
fun `clicking on cancel sends a BlockedUsersEvents`() {
|
||||||
val eventsRecorder = EventsRecorder<BlockedUsersEvents>()
|
val eventsRecorder = EventsRecorder<BlockedUsersEvents>()
|
||||||
rule.setLogoutView(
|
rule.setBlockedUsersView(
|
||||||
aBlockedUsersState(
|
aBlockedUsersState(
|
||||||
unblockUserAction = AsyncAction.Confirming,
|
unblockUserAction = AsyncAction.Confirming,
|
||||||
eventSink = eventsRecorder
|
eventSink = eventsRecorder
|
||||||
|
|
@ -85,7 +85,7 @@ class BlockedUserViewTest {
|
||||||
@Test
|
@Test
|
||||||
fun `clicking on confirm sends a BlockedUsersEvents`() {
|
fun `clicking on confirm sends a BlockedUsersEvents`() {
|
||||||
val eventsRecorder = EventsRecorder<BlockedUsersEvents>()
|
val eventsRecorder = EventsRecorder<BlockedUsersEvents>()
|
||||||
rule.setLogoutView(
|
rule.setBlockedUsersView(
|
||||||
aBlockedUsersState(
|
aBlockedUsersState(
|
||||||
unblockUserAction = AsyncAction.Confirming,
|
unblockUserAction = AsyncAction.Confirming,
|
||||||
eventSink = eventsRecorder
|
eventSink = eventsRecorder
|
||||||
|
|
@ -96,7 +96,7 @@ class BlockedUserViewTest {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun <R : TestRule> AndroidComposeTestRule<R, ComponentActivity>.setLogoutView(
|
private fun <R : TestRule> AndroidComposeTestRule<R, ComponentActivity>.setBlockedUsersView(
|
||||||
state: BlockedUsersState,
|
state: BlockedUsersState,
|
||||||
onBackClick: () -> Unit = EnsureNeverCalled(),
|
onBackClick: () -> Unit = EnsureNeverCalled(),
|
||||||
) {
|
) {
|
||||||
|
|
|
||||||
|
|
@ -42,4 +42,12 @@ class EventsRecorder<T>(
|
||||||
fun assertList(expectedEvents: List<T>) {
|
fun assertList(expectedEvents: List<T>) {
|
||||||
assertThat(events).isEqualTo(expectedEvents)
|
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()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
@ -227,6 +227,7 @@ cp "${fdroidTargetPath}"/app-fdroid-arm64-v8a-release.apk \
|
||||||
"${fdroidTargetPath}"/app-fdroid-arm64-v8a-release-signed.apk
|
"${fdroidTargetPath}"/app-fdroid-arm64-v8a-release-signed.apk
|
||||||
"${buildToolsPath}"/apksigner sign \
|
"${buildToolsPath}"/apksigner sign \
|
||||||
-v \
|
-v \
|
||||||
|
--alignment-preserved true \
|
||||||
--ks "${keyStorePath}" \
|
--ks "${keyStorePath}" \
|
||||||
--ks-pass pass:"${keyStorePassword}" \
|
--ks-pass pass:"${keyStorePassword}" \
|
||||||
--ks-key-alias elementx \
|
--ks-key-alias elementx \
|
||||||
|
|
@ -238,6 +239,7 @@ cp "${fdroidTargetPath}"/app-fdroid-armeabi-v7a-release.apk \
|
||||||
"${fdroidTargetPath}"/app-fdroid-armeabi-v7a-release-signed.apk
|
"${fdroidTargetPath}"/app-fdroid-armeabi-v7a-release-signed.apk
|
||||||
"${buildToolsPath}"/apksigner sign \
|
"${buildToolsPath}"/apksigner sign \
|
||||||
-v \
|
-v \
|
||||||
|
--alignment-preserved true \
|
||||||
--ks "${keyStorePath}" \
|
--ks "${keyStorePath}" \
|
||||||
--ks-pass pass:"${keyStorePassword}" \
|
--ks-pass pass:"${keyStorePassword}" \
|
||||||
--ks-key-alias elementx \
|
--ks-key-alias elementx \
|
||||||
|
|
@ -249,6 +251,7 @@ cp "${fdroidTargetPath}"/app-fdroid-x86-release.apk \
|
||||||
"${fdroidTargetPath}"/app-fdroid-x86-release-signed.apk
|
"${fdroidTargetPath}"/app-fdroid-x86-release-signed.apk
|
||||||
"${buildToolsPath}"/apksigner sign \
|
"${buildToolsPath}"/apksigner sign \
|
||||||
-v \
|
-v \
|
||||||
|
--alignment-preserved true \
|
||||||
--ks "${keyStorePath}" \
|
--ks "${keyStorePath}" \
|
||||||
--ks-pass pass:"${keyStorePassword}" \
|
--ks-pass pass:"${keyStorePassword}" \
|
||||||
--ks-key-alias elementx \
|
--ks-key-alias elementx \
|
||||||
|
|
@ -260,6 +263,7 @@ cp "${fdroidTargetPath}"/app-fdroid-x86_64-release.apk \
|
||||||
"${fdroidTargetPath}"/app-fdroid-x86_64-release-signed.apk
|
"${fdroidTargetPath}"/app-fdroid-x86_64-release-signed.apk
|
||||||
"${buildToolsPath}"/apksigner sign \
|
"${buildToolsPath}"/apksigner sign \
|
||||||
-v \
|
-v \
|
||||||
|
--alignment-preserved true \
|
||||||
--ks "${keyStorePath}" \
|
--ks "${keyStorePath}" \
|
||||||
--ks-pass pass:"${keyStorePassword}" \
|
--ks-pass pass:"${keyStorePassword}" \
|
||||||
--ks-key-alias elementx \
|
--ks-key-alias elementx \
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue