Browse Source

Merge pull request #3685 from element-hq/feature/bma/callImprovment

Call: ensure that the microphone is working when the application is backgrounded.
pull/3671/head
Benoit Marty 2 days ago committed by GitHub
parent
commit
2c253be3ab
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
  1. 6
      features/call/impl/src/main/AndroidManifest.xml
  2. 14
      features/call/impl/src/main/kotlin/io/element/android/features/call/impl/services/CallForegroundService.kt
  3. 1
      features/call/impl/src/main/kotlin/io/element/android/features/call/impl/ui/CallScreenPresenter.kt
  4. 1
      features/call/impl/src/main/kotlin/io/element/android/features/call/impl/ui/CallScreenState.kt
  5. 2
      features/call/impl/src/main/kotlin/io/element/android/features/call/impl/ui/CallScreenStateProvider.kt
  6. 32
      features/call/impl/src/main/kotlin/io/element/android/features/call/impl/ui/ElementCallActivity.kt
  7. 40
      features/call/impl/src/test/kotlin/io/element/android/features/call/ui/CallScreenPresenterTest.kt

6
features/call/impl/src/main/AndroidManifest.xml

@ -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"

14
features/call/impl/src/main/kotlin/io/element/android/features/call/impl/services/CallForegroundService.kt

@ -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
} }

1
features/call/impl/src/main/kotlin/io/element/android/features/call/impl/ui/CallScreenPresenter.kt

@ -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) },
) )

1
features/call/impl/src/main/kotlin/io/element/android/features/call/impl/ui/CallScreenState.kt

@ -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,
) )

2
features/call/impl/src/main/kotlin/io/element/android/features/call/impl/ui/CallScreenStateProvider.kt

@ -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,
) )

32
features/call/impl/src/main/kotlin/io/element/android/features/call/impl/ui/ElementCallActivity.kt

@ -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
} }
} }

40
features/call/impl/src/test/kotlin/io/element/android/features/call/ui/CallScreenPresenterTest.kt

@ -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…
Cancel
Save