Merge pull request #3685 from element-hq/feature/bma/callImprovment
Call: ensure that the microphone is working when the application is backgrounded.
This commit is contained in:
commit
0b31231711
7 changed files with 72 additions and 24 deletions
|
|
@ -21,8 +21,8 @@
|
||||||
|
|
||||||
<!-- Permissions for call foreground services -->
|
<!-- Permissions for call foreground services -->
|
||||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
||||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_PHONE_CALL" />
|
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MICROPHONE" />
|
||||||
<uses-permission android:name="android.permission.MANAGE_OWN_CALLS" />
|
|
||||||
<uses-permission android:name="android.permission.USE_FULL_SCREEN_INTENT" />
|
<uses-permission android:name="android.permission.USE_FULL_SCREEN_INTENT" />
|
||||||
|
|
||||||
<application>
|
<application>
|
||||||
|
|
@ -80,7 +80,7 @@
|
||||||
android:name=".services.CallForegroundService"
|
android:name=".services.CallForegroundService"
|
||||||
android:enabled="true"
|
android:enabled="true"
|
||||||
android:exported="false"
|
android:exported="false"
|
||||||
android:foregroundServiceType="phoneCall" />
|
android:foregroundServiceType="microphone" />
|
||||||
|
|
||||||
<receiver
|
<receiver
|
||||||
android:name=".receivers.DeclineCallBroadcastReceiver"
|
android:name=".receivers.DeclineCallBroadcastReceiver"
|
||||||
|
|
|
||||||
|
|
@ -7,9 +7,11 @@
|
||||||
|
|
||||||
package io.element.android.features.call.impl.services
|
package io.element.android.features.call.impl.services
|
||||||
|
|
||||||
|
import android.Manifest
|
||||||
import android.app.Service
|
import android.app.Service
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
|
import android.content.pm.PackageManager
|
||||||
import android.content.pm.ServiceInfo
|
import android.content.pm.ServiceInfo
|
||||||
import android.os.Build
|
import android.os.Build
|
||||||
import android.os.IBinder
|
import android.os.IBinder
|
||||||
|
|
@ -33,8 +35,12 @@ import timber.log.Timber
|
||||||
class CallForegroundService : Service() {
|
class CallForegroundService : Service() {
|
||||||
companion object {
|
companion object {
|
||||||
fun start(context: Context) {
|
fun start(context: Context) {
|
||||||
val intent = Intent(context, CallForegroundService::class.java)
|
if (ContextCompat.checkSelfPermission(context, Manifest.permission.RECORD_AUDIO) == PackageManager.PERMISSION_GRANTED) {
|
||||||
ContextCompat.startForegroundService(context, intent)
|
val intent = Intent(context, CallForegroundService::class.java)
|
||||||
|
ContextCompat.startForegroundService(context, intent)
|
||||||
|
} else {
|
||||||
|
Timber.w("Microphone permission is not granted, cannot start the call foreground service")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun stop(context: Context) {
|
fun stop(context: Context) {
|
||||||
|
|
@ -67,8 +73,8 @@ class CallForegroundService : Service() {
|
||||||
.setContentIntent(pendingIntent)
|
.setContentIntent(pendingIntent)
|
||||||
.build()
|
.build()
|
||||||
val notificationId = NotificationIdProvider.getForegroundServiceNotificationId(ForegroundServiceType.ONGOING_CALL)
|
val notificationId = NotificationIdProvider.getForegroundServiceNotificationId(ForegroundServiceType.ONGOING_CALL)
|
||||||
val serviceType = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
val serviceType = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
|
||||||
ServiceInfo.FOREGROUND_SERVICE_TYPE_PHONE_CALL
|
ServiceInfo.FOREGROUND_SERVICE_TYPE_MICROPHONE
|
||||||
} else {
|
} else {
|
||||||
0
|
0
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -180,6 +180,7 @@ class CallScreenPresenter @AssistedInject constructor(
|
||||||
urlState = urlState.value,
|
urlState = urlState.value,
|
||||||
webViewError = webViewError,
|
webViewError = webViewError,
|
||||||
userAgent = userAgent,
|
userAgent = userAgent,
|
||||||
|
isCallActive = isJoinedCall,
|
||||||
isInWidgetMode = isInWidgetMode,
|
isInWidgetMode = isInWidgetMode,
|
||||||
eventSink = { handleEvents(it) },
|
eventSink = { handleEvents(it) },
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,7 @@ data class CallScreenState(
|
||||||
val urlState: AsyncData<String>,
|
val urlState: AsyncData<String>,
|
||||||
val webViewError: String?,
|
val webViewError: String?,
|
||||||
val userAgent: String,
|
val userAgent: String,
|
||||||
|
val isCallActive: Boolean,
|
||||||
val isInWidgetMode: Boolean,
|
val isInWidgetMode: Boolean,
|
||||||
val eventSink: (CallScreenEvents) -> Unit,
|
val eventSink: (CallScreenEvents) -> Unit,
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -24,6 +24,7 @@ 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"),
|
||||||
webViewError: String? = null,
|
webViewError: String? = null,
|
||||||
userAgent: String = "",
|
userAgent: String = "",
|
||||||
|
isCallActive: Boolean = true,
|
||||||
isInWidgetMode: Boolean = false,
|
isInWidgetMode: Boolean = false,
|
||||||
eventSink: (CallScreenEvents) -> Unit = {},
|
eventSink: (CallScreenEvents) -> Unit = {},
|
||||||
): CallScreenState {
|
): CallScreenState {
|
||||||
|
|
@ -31,6 +32,7 @@ internal fun aCallScreenState(
|
||||||
urlState = urlState,
|
urlState = urlState,
|
||||||
webViewError = webViewError,
|
webViewError = webViewError,
|
||||||
userAgent = userAgent,
|
userAgent = userAgent,
|
||||||
|
isCallActive = isCallActive,
|
||||||
isInWidgetMode = isInWidgetMode,
|
isInWidgetMode = isInWidgetMode,
|
||||||
eventSink = eventSink,
|
eventSink = eventSink,
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -26,6 +26,7 @@ import androidx.annotation.RequiresApi
|
||||||
import androidx.appcompat.app.AppCompatActivity
|
import androidx.appcompat.app.AppCompatActivity
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.DisposableEffect
|
import androidx.compose.runtime.DisposableEffect
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.rememberUpdatedState
|
import androidx.compose.runtime.rememberUpdatedState
|
||||||
|
|
@ -95,7 +96,6 @@ class ElementCallActivity :
|
||||||
pictureInPicturePresenter.setPipView(this)
|
pictureInPicturePresenter.setPipView(this)
|
||||||
|
|
||||||
audioManager = getSystemService(AUDIO_SERVICE) as AudioManager
|
audioManager = getSystemService(AUDIO_SERVICE) as AudioManager
|
||||||
requestAudioFocus()
|
|
||||||
|
|
||||||
setContent {
|
setContent {
|
||||||
val pipState = pictureInPicturePresenter.present()
|
val pipState = pictureInPicturePresenter.present()
|
||||||
|
|
@ -103,6 +103,12 @@ class ElementCallActivity :
|
||||||
ElementThemeApp(appPreferencesStore) {
|
ElementThemeApp(appPreferencesStore) {
|
||||||
val state = presenter.present()
|
val state = presenter.present()
|
||||||
eventSink = state.eventSink
|
eventSink = state.eventSink
|
||||||
|
LaunchedEffect(state.isCallActive, state.isInWidgetMode) {
|
||||||
|
// Note when not in WidgetMode, isCallActive will never be true, so consider the call is active
|
||||||
|
if (state.isCallActive || !state.isInWidgetMode) {
|
||||||
|
setCallIsActive()
|
||||||
|
}
|
||||||
|
}
|
||||||
CallScreenView(
|
CallScreenView(
|
||||||
state = state,
|
state = state,
|
||||||
pipState = pipState,
|
pipState = pipState,
|
||||||
|
|
@ -115,6 +121,11 @@ class ElementCallActivity :
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun setCallIsActive() {
|
||||||
|
requestAudioFocus()
|
||||||
|
CallForegroundService.start(this)
|
||||||
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun ListenToAndroidEvents(pipState: PictureInPictureState) {
|
private fun ListenToAndroidEvents(pipState: PictureInPictureState) {
|
||||||
val pipEventSink by rememberUpdatedState(pipState.eventSink)
|
val pipEventSink by rememberUpdatedState(pipState.eventSink)
|
||||||
|
|
@ -156,18 +167,6 @@ class ElementCallActivity :
|
||||||
setCallType(intent)
|
setCallType(intent)
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onStart() {
|
|
||||||
super.onStart()
|
|
||||||
CallForegroundService.stop(this)
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onStop() {
|
|
||||||
super.onStop()
|
|
||||||
if (!isFinishing && !isChangingConfigurations) {
|
|
||||||
CallForegroundService.start(this)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onDestroy() {
|
override fun onDestroy() {
|
||||||
super.onDestroy()
|
super.onDestroy()
|
||||||
releaseAudioFocus()
|
releaseAudioFocus()
|
||||||
|
|
@ -231,10 +230,10 @@ class ElementCallActivity :
|
||||||
|
|
||||||
@Suppress("DEPRECATION")
|
@Suppress("DEPRECATION")
|
||||||
private fun requestAudioFocus() {
|
private fun requestAudioFocus() {
|
||||||
val audioAttributes = AudioAttributes.Builder()
|
|
||||||
.setUsage(AudioAttributes.USAGE_VOICE_COMMUNICATION)
|
|
||||||
.build()
|
|
||||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||||
|
val audioAttributes = AudioAttributes.Builder()
|
||||||
|
.setUsage(AudioAttributes.USAGE_VOICE_COMMUNICATION)
|
||||||
|
.build()
|
||||||
val request = AudioFocusRequest.Builder(AudioManager.AUDIOFOCUS_GAIN)
|
val request = AudioFocusRequest.Builder(AudioManager.AUDIOFOCUS_GAIN)
|
||||||
.setAudioAttributes(audioAttributes)
|
.setAudioAttributes(audioAttributes)
|
||||||
.build()
|
.build()
|
||||||
|
|
@ -247,7 +246,6 @@ class ElementCallActivity :
|
||||||
AudioManager.STREAM_VOICE_CALL,
|
AudioManager.STREAM_VOICE_CALL,
|
||||||
AudioManager.AUDIOFOCUS_GAIN_TRANSIENT_EXCLUSIVE,
|
AudioManager.AUDIOFOCUS_GAIN_TRANSIENT_EXCLUSIVE,
|
||||||
)
|
)
|
||||||
|
|
||||||
audioFocusChangeListener = listener
|
audioFocusChangeListener = listener
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -73,6 +73,7 @@ class CallScreenPresenterTest {
|
||||||
assertThat(initialState.urlState).isEqualTo(AsyncData.Success("https://call.element.io"))
|
assertThat(initialState.urlState).isEqualTo(AsyncData.Success("https://call.element.io"))
|
||||||
assertThat(initialState.webViewError).isNull()
|
assertThat(initialState.webViewError).isNull()
|
||||||
assertThat(initialState.isInWidgetMode).isFalse()
|
assertThat(initialState.isInWidgetMode).isFalse()
|
||||||
|
assertThat(initialState.isCallActive).isFalse()
|
||||||
analyticsLambda.assertions().isNeverCalled()
|
analyticsLambda.assertions().isNeverCalled()
|
||||||
joinedCallLambda.assertions().isCalledOnce()
|
joinedCallLambda.assertions().isCalledOnce()
|
||||||
}
|
}
|
||||||
|
|
@ -106,6 +107,7 @@ class CallScreenPresenterTest {
|
||||||
joinedCallLambda.assertions().isCalledOnce()
|
joinedCallLambda.assertions().isCalledOnce()
|
||||||
val initialState = awaitItem()
|
val initialState = awaitItem()
|
||||||
assertThat(initialState.urlState).isInstanceOf(AsyncData.Success::class.java)
|
assertThat(initialState.urlState).isInstanceOf(AsyncData.Success::class.java)
|
||||||
|
assertThat(initialState.isCallActive).isFalse()
|
||||||
assertThat(initialState.isInWidgetMode).isTrue()
|
assertThat(initialState.isInWidgetMode).isTrue()
|
||||||
assertThat(widgetProvider.getWidgetCalled).isTrue()
|
assertThat(widgetProvider.getWidgetCalled).isTrue()
|
||||||
assertThat(widgetDriver.runCalledCount).isEqualTo(1)
|
assertThat(widgetDriver.runCalledCount).isEqualTo(1)
|
||||||
|
|
@ -203,6 +205,44 @@ class CallScreenPresenterTest {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `present - a received room member message makes the call to be active`() = runTest {
|
||||||
|
val navigator = FakeCallScreenNavigator()
|
||||||
|
val widgetDriver = FakeMatrixWidgetDriver()
|
||||||
|
val presenter = createCallScreenPresenter(
|
||||||
|
callType = CallType.RoomCall(A_SESSION_ID, A_ROOM_ID),
|
||||||
|
widgetDriver = widgetDriver,
|
||||||
|
navigator = navigator,
|
||||||
|
dispatchers = testCoroutineDispatchers(useUnconfinedTestDispatcher = true),
|
||||||
|
screenTracker = FakeScreenTracker {},
|
||||||
|
)
|
||||||
|
val messageInterceptor = FakeWidgetMessageInterceptor()
|
||||||
|
moleculeFlow(RecompositionMode.Immediate) {
|
||||||
|
presenter.present()
|
||||||
|
}.test {
|
||||||
|
skipItems(1)
|
||||||
|
val initialState = awaitItem()
|
||||||
|
assertThat(initialState.isCallActive).isFalse()
|
||||||
|
initialState.eventSink(CallScreenEvents.SetupMessageChannels(messageInterceptor))
|
||||||
|
messageInterceptor.givenInterceptedMessage(
|
||||||
|
"""
|
||||||
|
{
|
||||||
|
"action":"send_event",
|
||||||
|
"api":"fromWidget",
|
||||||
|
"widgetId":"1",
|
||||||
|
"requestId":"1",
|
||||||
|
"data":{
|
||||||
|
"type":"org.matrix.msc3401.call.member"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
""".trimIndent()
|
||||||
|
)
|
||||||
|
skipItems(1)
|
||||||
|
val finalState = awaitItem()
|
||||||
|
assertThat(finalState.isCallActive).isTrue()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `present - automatically starts the Matrix client sync when on RoomCall`() = runTest {
|
fun `present - automatically starts the Matrix client sync when on RoomCall`() = runTest {
|
||||||
val navigator = FakeCallScreenNavigator()
|
val navigator = FakeCallScreenNavigator()
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue