Skip to content

Commit

Permalink
fix: answer call from notification in foreground service [WPB-9648] (#…
Browse files Browse the repository at this point in the history
  • Loading branch information
saleniuk authored Jan 20, 2025
1 parent 318f3a4 commit 3a09719
Show file tree
Hide file tree
Showing 4 changed files with 167 additions and 27 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ import com.wire.android.di.ApplicationScope
import com.wire.android.di.KaliumCoreLogic
import com.wire.android.di.NoSession
import com.wire.android.notification.CallNotificationManager
import com.wire.android.services.ServicesManager
import com.wire.android.util.dispatchers.DispatcherProvider
import com.wire.kalium.logger.obfuscateId
import com.wire.kalium.logic.CoreLogic
Expand Down Expand Up @@ -59,6 +60,9 @@ class IncomingCallActionReceiver : BroadcastReceiver() {
@Inject
lateinit var callNotificationManager: CallNotificationManager

@Inject
lateinit var servicesManager: ServicesManager

@Suppress("ReturnCount")
override fun onReceive(context: Context, intent: Intent) {
val conversationIdString: String = intent.getStringExtra(EXTRA_CONVERSATION_ID) ?: run {
Expand All @@ -77,9 +81,10 @@ class IncomingCallActionReceiver : BroadcastReceiver() {

coroutineScope.launch(Dispatchers.Default) {
with(coreLogic.getSessionScope(userId)) {
val conversationId = qualifiedIdMapper.fromStringToQualifiedID(conversationIdString)
when (action) {
ACTION_DECLINE_CALL -> calls.rejectCall(qualifiedIdMapper.fromStringToQualifiedID(conversationIdString))
ACTION_ANSWER_CALL -> calls.answerCall(qualifiedIdMapper.fromStringToQualifiedID(conversationIdString))
ACTION_DECLINE_CALL -> calls.rejectCall(conversationId)
ACTION_ANSWER_CALL -> servicesManager.startCallServiceToAnswer(userId, conversationId)
}
}
callNotificationManager.hideIncomingCallNotification(userId.toString(), conversationIdString)
Expand Down
58 changes: 44 additions & 14 deletions app/src/main/kotlin/com/wire/android/services/CallService.kt
Original file line number Diff line number Diff line change
Expand Up @@ -31,15 +31,20 @@ import com.wire.android.di.NoSession
import com.wire.android.notification.CallNotificationData
import com.wire.android.notification.CallNotificationManager
import com.wire.android.notification.NotificationIds
import com.wire.android.services.CallService.Action
import com.wire.android.util.dispatchers.DispatcherProvider
import com.wire.android.util.logIfEmptyUserName
import com.wire.kalium.logic.CoreLogic
import com.wire.kalium.logic.data.call.CallStatus
import com.wire.kalium.logic.data.id.ConversationId
import com.wire.kalium.logic.data.id.QualifiedIdMapper
import com.wire.kalium.logic.data.user.UserId
import com.wire.kalium.logic.feature.call.CallsScope
import com.wire.kalium.logic.feature.session.CurrentSessionResult
import com.wire.kalium.logic.functional.Either
import com.wire.kalium.logic.functional.fold
import dagger.hilt.android.AndroidEntryPoint
import dev.ahmedmourad.bundlizer.Bundlizer
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
Expand All @@ -50,7 +55,9 @@ import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.launch
import kotlinx.serialization.Serializable
import java.util.concurrent.atomic.AtomicReference
import javax.inject.Inject

Expand Down Expand Up @@ -90,27 +97,32 @@ class CallService : Service() {

override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
appLogger.i("$TAG: onStartCommand")
val stopService = intent?.getBooleanExtra(EXTRA_STOP_SERVICE, false)
val action = intent?.getActionTypeExtra(EXTRA_ACTION_TYPE)
generatePlaceholderForegroundNotification()
serviceState.set(ServiceState.FOREGROUND)
if (stopService == true) {
if (action is Action.Stop) {
appLogger.i("$TAG: stopSelf. Reason: stopService was called")
stopSelf()
} else {
scope.launch {
if (action is Action.AnswerCall) {
coreLogic.getSessionScope(action.userId).calls.answerCall(action.conversationId)
}
coreLogic.getGlobalScope().session.currentSessionFlow()
.flatMapLatest {
if (it is CurrentSessionResult.Success && it.accountInfo.isValid()) {
val userId = it.accountInfo.userId
val userSessionScope = coreLogic.getSessionScope(userId)
val outgoingCallsFlow = userSessionScope.calls.observeOutgoingCall()
val establishedCallsFlow = userSessionScope.calls.establishedCall()
val callCurrentlyBeingAnsweredFlow = userSessionScope.calls.observeCallCurrentlyBeingAnswered(action)

combine(
outgoingCallsFlow,
establishedCallsFlow
) { outgoingCalls, establishedCalls ->
val calls = outgoingCalls + establishedCalls
establishedCallsFlow,
callCurrentlyBeingAnsweredFlow
) { outgoingCalls, establishedCalls, answeringCall ->
val calls = outgoingCalls + establishedCalls + answeringCall
calls.firstOrNull()?.let { call ->
val userName = userSessionScope.users.observeSelfUser().first()
.also { it.logIfEmptyUserName() }
Expand Down Expand Up @@ -180,22 +192,40 @@ class CallService : Service() {
appLogger.i("$TAG: started foreground with placeholder notification")
}

private suspend fun CallsScope.observeCallCurrentlyBeingAnswered(action: Action?) = when (action) {
is Action.AnswerCall -> getIncomingCalls().map { it.filter { it.conversationId == action.conversationId } }
else -> flowOf(emptyList())
}

companion object {
private const val TAG = "CallService"
private const val EXTRA_STOP_SERVICE = "stop_service"

fun newIntent(context: Context): Intent = Intent(context, CallService::class.java)
private const val EXTRA_ACTION_TYPE = "action_type"

fun newIntentToStop(context: Context): Intent =
Intent(context, CallService::class.java).apply {
putExtra(EXTRA_STOP_SERVICE, true)
}
fun newIntent(context: Context, actionType: Action = Action.Default): Intent = Intent(context, CallService::class.java)
.putExtra(EXTRA_ACTION_TYPE, actionType)

var serviceState: AtomicReference<ServiceState> = AtomicReference(ServiceState.NOT_STARTED)
private set
val serviceState: AtomicReference<ServiceState> = AtomicReference(ServiceState.NOT_STARTED)
}

enum class ServiceState {
NOT_STARTED, STARTED, FOREGROUND
}

@Serializable
sealed class Action {
@Serializable
data object Default : Action()

@Serializable
data class AnswerCall(val userId: UserId, val conversationId: ConversationId) : Action()

@Serializable
data object Stop : Action()
}
}

private fun Intent.putExtra(name: String, actionType: Action): Intent = putExtra(name, Bundlizer.bundle(Action.serializer(), actionType))

private fun Intent.getActionTypeExtra(name: String): Action? = getBundleExtra(name)?.let {
Bundlizer.unbundle(Action.serializer(), it)
}
29 changes: 20 additions & 9 deletions app/src/main/kotlin/com/wire/android/services/ServicesManager.kt
Original file line number Diff line number Diff line change
Expand Up @@ -23,9 +23,11 @@ import android.content.Intent
import android.os.Build
import com.wire.android.appLogger
import com.wire.android.util.dispatchers.DispatcherProvider
import com.wire.kalium.logic.data.id.ConversationId
import com.wire.kalium.logic.data.user.UserId
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.debounce
import kotlinx.coroutines.flow.distinctUntilChanged
Expand All @@ -45,15 +47,17 @@ class ServicesManager @Inject constructor(
dispatcherProvider: DispatcherProvider,
) {
private val scope = CoroutineScope(SupervisorJob() + dispatcherProvider.default())
private val callServiceEvents = MutableStateFlow(false)
private val callServiceEvents = MutableSharedFlow<CallService.Action>()

init {
scope.launch {
callServiceEvents
.debounce { if (it) 0L else DEBOUNCE_TIME } // debounce to avoid starting and stopping service too fast
.debounce { action ->
if (action is CallService.Action.Stop) DEBOUNCE_TIME else 0 // debounce to avoid stopping and starting service too fast
}
.distinctUntilChanged()
.collectLatest { shouldBeStarted ->
if (!shouldBeStarted) {
.collectLatest { action ->
if (action is CallService.Action.Stop) {
appLogger.i("ServicesManager: stopping CallService because there are no calls")
when (CallService.serviceState.get()) {
CallService.ServiceState.STARTED -> {
Expand All @@ -62,7 +66,7 @@ class ServicesManager @Inject constructor(
// or some specific argument that tells the service that it should stop itself right after startForeground.
// This way, when this service is killed and recreated by the system, it will stop itself right after
// recreating so it won't cause any problems.
startService(CallService.newIntentToStop(context))
startService(CallService.newIntent(context, CallService.Action.Stop))
appLogger.i("ServicesManager: CallService stopped by passing stop argument")
}

Expand All @@ -78,7 +82,7 @@ class ServicesManager @Inject constructor(
}
} else {
appLogger.i("ServicesManager: starting CallService")
startService(CallService.newIntent(context))
startService(CallService.newIntent(context, action))
}
}
}
Expand All @@ -87,14 +91,21 @@ class ServicesManager @Inject constructor(
fun startCallService() {
appLogger.i("ServicesManager: start CallService event")
scope.launch {
callServiceEvents.emit(true)
callServiceEvents.emit(CallService.Action.Default)
}
}

fun startCallServiceToAnswer(userId: UserId, conversationId: ConversationId) {
appLogger.i("ServicesManager: start CallService event")
scope.launch {
callServiceEvents.emit(CallService.Action.AnswerCall(userId, conversationId))
}
}

fun stopCallService() {
appLogger.i("ServicesManager: stop CallService event")
scope.launch {
callServiceEvents.emit(false)
callServiceEvents.emit(CallService.Action.Stop)
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,10 +20,13 @@ package com.wire.android.services
import android.content.Context
import android.content.Intent
import com.wire.android.config.TestDispatcherProvider
import com.wire.kalium.logic.data.id.ConversationId
import com.wire.kalium.logic.data.user.UserId
import io.mockk.MockKAnnotations
import io.mockk.clearMocks
import io.mockk.every
import io.mockk.impl.annotations.MockK
import io.mockk.mockk
import io.mockk.mockkObject
import io.mockk.verify
import kotlinx.coroutines.ExperimentalCoroutinesApi
Expand Down Expand Up @@ -139,6 +142,93 @@ class ServicesManagerTest {
verify(exactly = 0) { arrangement.context.stopService(arrangement.callServiceIntent) }
}

@Test
fun `given ongoing call service running, when start called again with the same action, then do not start the service again`() =
runTest(dispatcherProvider.main()) {
// given
val (arrangement, servicesManager) = Arrangement()
.withServiceState(CallService.ServiceState.FOREGROUND)
.arrange()
servicesManager.startCallService()
advanceUntilIdle()
arrangement.clearRecordedCallsForContext() // clear calls recorded when initializing the state
// when
servicesManager.startCallService() // start again with the same action
advanceUntilIdle()
// then
verify(exactly = 0) { arrangement.context.startService(arrangement.callServiceIntent) } // not called again
}

@Test
fun `given ongoing call service not started, when answering call, then start with proper action`() =
runTest(dispatcherProvider.main()) {
// given
val userId = UserId("userId", "domain")
val conversationId = ConversationId("conversationId", "domain")
val action = CallService.Action.AnswerCall(userId, conversationId)
val intentForAction = mockk<Intent>()
val (arrangement, servicesManager) = Arrangement()
.withServiceState(CallService.ServiceState.NOT_STARTED)
.callServiceIntentForAction(action, intentForAction)
.arrange()
advanceUntilIdle()
// when
servicesManager.startCallServiceToAnswer(userId, conversationId)
// then
verify(exactly = 1) { arrangement.context.startService(intentForAction) }
verify(exactly = 0) { arrangement.context.startService(arrangement.callServiceIntent) }
verify(exactly = 0) { arrangement.context.startService(arrangement.ongoingCallServiceIntentWithStopArgument) }
verify(exactly = 0) { arrangement.context.stopService(arrangement.callServiceIntent) }
}

@Test
fun `given ongoing call service already started, when answering different call, then start again with proper action`() =
runTest(dispatcherProvider.main()) {
// given
val userId = UserId("userId", "domain")
val conversationId = ConversationId("conversationId", "domain")
val action = CallService.Action.AnswerCall(userId, conversationId)
val intentForAction = mockk<Intent>()
val (arrangement, servicesManager) = Arrangement()
.withServiceState(CallService.ServiceState.NOT_STARTED)
.callServiceIntentForAction(action, intentForAction)
.arrange()
servicesManager.startCallService()
advanceUntilIdle()
arrangement.clearRecordedCallsForContext() // clear calls recorded when initializing the state
// when
servicesManager.startCallServiceToAnswer(userId, conversationId)
// then
verify(exactly = 1) { arrangement.context.startService(intentForAction) }
verify(exactly = 0) { arrangement.context.startService(arrangement.callServiceIntent) }
verify(exactly = 0) { arrangement.context.startService(arrangement.ongoingCallServiceIntentWithStopArgument) }
verify(exactly = 0) { arrangement.context.stopService(arrangement.callServiceIntent) }
}

@Test
fun `given ongoing call service already started, when needs to answer the same call, then do not start again`() =
runTest(dispatcherProvider.main()) {
// given
val userId = UserId("userId", "domain")
val conversationId = ConversationId("conversationId", "domain")
val action = CallService.Action.AnswerCall(userId, conversationId)
val intentForAction = mockk<Intent>()
val (arrangement, servicesManager) = Arrangement()
.withServiceState(CallService.ServiceState.NOT_STARTED)
.callServiceIntentForAction(action, intentForAction)
.arrange()
servicesManager.startCallServiceToAnswer(userId, conversationId)
advanceUntilIdle()
arrangement.clearRecordedCallsForContext() // clear calls recorded when initializing the state
// when
servicesManager.startCallServiceToAnswer(userId, conversationId)
// then
verify(exactly = 0) { arrangement.context.startService(intentForAction) }
verify(exactly = 0) { arrangement.context.startService(arrangement.callServiceIntent) }
verify(exactly = 0) { arrangement.context.startService(arrangement.ongoingCallServiceIntentWithStopArgument) }
verify(exactly = 0) { arrangement.context.stopService(arrangement.callServiceIntent) }
}

private inner class Arrangement {

@MockK(relaxed = true)
Expand All @@ -155,8 +245,8 @@ class ServicesManagerTest {
init {
MockKAnnotations.init(this, relaxUnitFun = true)
mockkObject(CallService.Companion)
every { CallService.Companion.newIntent(context) } returns callServiceIntent
every { CallService.Companion.newIntentToStop(context) } returns ongoingCallServiceIntentWithStopArgument
callServiceIntentForAction(CallService.Action.Default, callServiceIntent)
callServiceIntentForAction(CallService.Action.Stop, ongoingCallServiceIntentWithStopArgument)
}

fun clearRecordedCallsForContext() {
Expand All @@ -175,6 +265,10 @@ class ServicesManagerTest {
every { CallService.serviceState.get() } returns state
}

fun callServiceIntentForAction(action: CallService.Action, intent: Intent) = apply {
every { CallService.Companion.newIntent(context, action) } returns intent
}

fun arrange() = this to servicesManager
}
}

0 comments on commit 3a09719

Please sign in to comment.