Skip to content

Commit c8d9030

Browse files
authored
fix: showing multiple calls at the same time [WPB-10430] (#3583)
1 parent dc2d4b6 commit c8d9030

File tree

18 files changed

+658
-281
lines changed

18 files changed

+658
-281
lines changed

app/src/main/kotlin/com/wire/android/notification/CallNotificationManager.kt

Lines changed: 77 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -21,10 +21,12 @@ package com.wire.android.notification
2121
import android.annotation.SuppressLint
2222
import android.app.Notification
2323
import android.content.Context
24+
import android.service.notification.StatusBarNotification
2425
import androidx.core.app.NotificationCompat
2526
import androidx.core.app.NotificationManagerCompat
2627
import com.wire.android.R
2728
import com.wire.android.appLogger
29+
import com.wire.android.notification.NotificationConstants.INCOMING_CALL_ID_PREFIX
2830
import com.wire.android.util.dispatchers.DispatcherProvider
2931
import com.wire.kalium.logic.data.call.Call
3032
import com.wire.kalium.logic.data.call.CallStatus
@@ -34,17 +36,16 @@ import com.wire.kalium.logic.data.id.QualifiedID
3436
import com.wire.kalium.logic.data.user.UserId
3537
import kotlinx.coroutines.CoroutineScope
3638
import kotlinx.coroutines.SupervisorJob
37-
import kotlinx.coroutines.flow.Flow
3839
import kotlinx.coroutines.flow.MutableSharedFlow
3940
import kotlinx.coroutines.flow.MutableStateFlow
4041
import kotlinx.coroutines.flow.collectLatest
4142
import kotlinx.coroutines.flow.debounce
4243
import kotlinx.coroutines.flow.distinctUntilChanged
4344
import kotlinx.coroutines.flow.filter
4445
import kotlinx.coroutines.flow.flatMapLatest
45-
import kotlinx.coroutines.flow.flowOf
4646
import kotlinx.coroutines.flow.map
4747
import kotlinx.coroutines.flow.onStart
48+
import kotlinx.coroutines.flow.scan
4849
import kotlinx.coroutines.flow.update
4950
import kotlinx.coroutines.launch
5051
import org.jetbrains.annotations.VisibleForTesting
@@ -62,75 +63,108 @@ class CallNotificationManager @Inject constructor(
6263

6364
private val notificationManager = NotificationManagerCompat.from(context)
6465
private val scope = CoroutineScope(SupervisorJob() + dispatcherProvider.default())
65-
private val incomingCallsForUsers = MutableStateFlow<Map<UserId, Call>>(mapOf())
66+
private val incomingCallsForUsers = MutableStateFlow<Map<UserId, IncomingCallsForUser>>(mapOf())
6667
private val reloadCallNotification = MutableSharedFlow<CallNotificationIds>()
6768

6869
init {
6970
scope.launch {
7071
incomingCallsForUsers
7172
.debounce { if (it.isEmpty()) 0L else DEBOUNCE_TIME } // debounce to avoid showing and hiding notification too fast
72-
.map { it.entries.firstOrNull()?.toCallNotificationData() }
73+
.map {
74+
it.values.map { (userId, userName, calls) ->
75+
calls.map { call ->
76+
CallNotificationData(userId, call, userName)
77+
}
78+
}.flatten()
79+
}
80+
.scan(emptyList<CallNotificationData>() to emptyList<CallNotificationData>()) { (previousCalls, _), currentCalls ->
81+
currentCalls to (currentCalls - previousCalls.toSet())
82+
}
7383
.distinctUntilChanged()
74-
.reloadIfNeeded()
75-
.collectLatest { incomingCallData ->
76-
if (incomingCallData == null) {
77-
hideIncomingCallNotification()
78-
} else {
79-
appLogger.i("$TAG: showing incoming call")
80-
showIncomingCallNotification(incomingCallData)
84+
.flatMapLatest { (allCurrentCalls, newCalls) ->
85+
reloadCallNotification
86+
.map { (userIdString, conversationIdString) ->
87+
allCurrentCalls to allCurrentCalls.filter { // emit call that needs to be reloaded as newOrUpdated
88+
it.userId.toString() == userIdString && it.conversationId.toString() == conversationIdString
89+
}
90+
}
91+
.filter { (_, newCalls) -> newCalls.isNotEmpty() } // only emit if there is something to reload
92+
.onStart { emit(allCurrentCalls to newCalls) }
93+
}
94+
.collectLatest { (allCurrentCalls, newCalls) ->
95+
// remove outdated incoming call notifications
96+
hideOutdatedIncomingCallNotifications(allCurrentCalls)
97+
// show current incoming call notifications
98+
appLogger.i("$TAG: showing ${newCalls.size} new incoming calls (all incoming calls: ${allCurrentCalls.size})")
99+
newCalls.forEach { data ->
100+
showIncomingCallNotification(data)
81101
}
82102
}
83103
}
84104
}
85105

86-
fun reloadIfNeeded(data: CallNotificationData): Flow<CallNotificationData> = reloadCallNotification
87-
.filter { reloadCallNotificationIds -> // check if the reload action is for the same call
88-
reloadCallNotificationIds.userIdString == data.userId.toString()
89-
&& reloadCallNotificationIds.conversationIdString == data.conversationId.toString()
106+
@VisibleForTesting
107+
internal fun hideOutdatedIncomingCallNotifications(currentIncomingCalls: List<CallNotificationData>) {
108+
val currentIncomingCallNotificationIds = currentIncomingCalls.map {
109+
NotificationConstants.getIncomingCallId(it.userId.toString(), it.conversationId.toString())
90110
}
91-
.map { data }
92-
.onStart { emit(data) }
93-
94-
private fun Flow<CallNotificationData?>.reloadIfNeeded(): Flow<CallNotificationData?> = this.flatMapLatest { callEntry ->
95-
callEntry?.let { reloadIfNeeded(it) } ?: flowOf(null)
111+
hideIncomingCallNotifications { _, id -> !currentIncomingCallNotificationIds.contains(id) }
96112
}
97113

98114
fun reloadCallNotifications(reloadCallNotificationIds: CallNotificationIds) = scope.launch {
99115
reloadCallNotification.emit(reloadCallNotificationIds)
100116
}
101117

102-
fun handleIncomingCallNotifications(calls: List<Call>, userId: UserId) {
118+
fun handleIncomingCalls(calls: List<Call>, userId: UserId, userName: String) {
103119
if (calls.isEmpty()) {
104-
incomingCallsForUsers.update { it.filter { it.key != userId } }
120+
incomingCallsForUsers.update {
121+
it.minus(userId)
122+
}
105123
} else {
106-
incomingCallsForUsers.update { it.filter { it.key != userId } + (userId to calls.first()) }
124+
incomingCallsForUsers.update {
125+
it.plus(userId to IncomingCallsForUser(userId, userName, calls))
126+
}
107127
}
108128
}
109129

110-
fun hideAllNotifications() {
111-
hideIncomingCallNotification()
130+
private fun hideIncomingCallNotifications(predicate: (tag: String, id: Int) -> Boolean) {
131+
notificationManager.activeNotifications.filter {
132+
it.tag?.startsWith(INCOMING_CALL_ID_PREFIX) == true && predicate(it.tag, it.id)
133+
}.forEach {
134+
it.hideIncomingCallNotification()
135+
}
112136
}
113137

114-
private fun hideIncomingCallNotification() {
138+
fun hideAllIncomingCallNotifications() = hideIncomingCallNotifications { _, _ -> true }
139+
140+
fun hideAllIncomingCallNotificationsForUser(userId: UserId) =
141+
hideIncomingCallNotifications { tag, _ -> tag == NotificationConstants.getIncomingCallTag(userId.toString()) }
142+
143+
fun hideIncomingCallNotification(userIdString: String, conversationIdString: String) =
144+
hideIncomingCallNotifications { _, id -> id == NotificationConstants.getIncomingCallId(userIdString, conversationIdString) }
145+
146+
private fun StatusBarNotification.hideIncomingCallNotification() {
115147
appLogger.i("$TAG: hiding incoming call")
116148

117149
// This delay is just so when the user receives two calling signals one straight after the other [INCOMING -> CANCEL]
118-
// Due to the signals being one after the other we are creating a notification when we are trying to cancel it, it wasn't properly
119-
// cancelling vibration as probably when we were cancelling, the vibration object was still being created and started and thus
120-
// never stopped.
150+
// Due to the signals being one after the other we are creating a notification when we are trying to cancel it, it wasn't
151+
// properly cancelling vibration as probably when we were cancelling, the vibration object was still being created and started
152+
// and thus never stopped.
121153
TimeUnit.MILLISECONDS.sleep(CANCEL_CALL_NOTIFICATION_DELAY)
122-
notificationManager.cancel(NotificationIds.CALL_INCOMING_NOTIFICATION_ID.ordinal)
154+
notificationManager.cancel(tag, id)
123155
}
124156

125157
@SuppressLint("MissingPermission")
126158
@VisibleForTesting
127159
internal fun showIncomingCallNotification(data: CallNotificationData) {
128-
appLogger.i("$TAG: showing incoming call notification for user ${data.userId.toLogString()}")
129-
val notification = builder.getIncomingCallNotification(data)
130-
notificationManager.notify(
131-
NotificationIds.CALL_INCOMING_NOTIFICATION_ID.ordinal,
132-
notification
160+
appLogger.i(
161+
"$TAG: showing incoming call notification for user ${data.userId.toLogString()}" +
162+
" and conversation ${data.conversationId.toLogString()}"
133163
)
164+
val tag = NotificationConstants.getIncomingCallTag(data.userId.toString())
165+
val id = NotificationConstants.getIncomingCallId(data.userId.toString(), data.conversationId.toString())
166+
val notification = builder.getIncomingCallNotification(data)
167+
notificationManager.notify(tag, id, notification)
134168
}
135169

136170
// Notifications
@@ -141,10 +175,6 @@ class CallNotificationManager @Inject constructor(
141175

142176
@VisibleForTesting
143177
internal const val DEBOUNCE_TIME = 200L
144-
145-
fun hideIncomingCallNotification(context: Context) {
146-
NotificationManagerCompat.from(context).cancel(NotificationIds.CALL_INCOMING_NOTIFICATION_ID.ordinal)
147-
}
148178
}
149179
}
150180

@@ -164,6 +194,7 @@ class CallNotificationBuilder @Inject constructor(
164194
.setSmallIcon(R.drawable.notification_icon_small)
165195
.setContentTitle(data.conversationName)
166196
.setContentText(context.getString(R.string.notification_outgoing_call_tap_to_return))
197+
.setSubText(data.userName)
167198
.setAutoCancel(false)
168199
.setOngoing(true)
169200
.setSilent(true)
@@ -188,6 +219,7 @@ class CallNotificationBuilder @Inject constructor(
188219
.setSmallIcon(R.drawable.notification_icon_small)
189220
.setContentTitle(title)
190221
.setContentText(content)
222+
.setSubText(data.userName)
191223
.setAutoCancel(false)
192224
.setOngoing(true)
193225
.setVibrate(VIBRATE_PATTERN)
@@ -214,6 +246,7 @@ class CallNotificationBuilder @Inject constructor(
214246
return NotificationCompat.Builder(context, channelId)
215247
.setContentTitle(title)
216248
.setContentText(context.getString(R.string.notification_ongoing_call_content))
249+
.setSubText(data.userName)
217250
.setPriority(NotificationCompat.PRIORITY_DEFAULT)
218251
.setCategory(NotificationCompat.CATEGORY_CALL)
219252
.setVisibility(NotificationCompat.VISIBILITY_PUBLIC)
@@ -275,19 +308,23 @@ class CallNotificationBuilder @Inject constructor(
275308
}
276309
}
277310

311+
data class IncomingCallsForUser(val userId: UserId, val userName: String, val incomingCalls: List<Call>)
312+
278313
data class CallNotificationIds(val userIdString: String, val conversationIdString: String)
279314

280315
data class CallNotificationData(
281316
val userId: QualifiedID,
317+
val userName: String,
282318
val conversationId: ConversationId,
283319
val conversationName: String?,
284320
val conversationType: Conversation.Type,
285321
val callerName: String?,
286322
val callerTeamName: String?,
287323
val callStatus: CallStatus
288324
) {
289-
constructor(userId: UserId, call: Call) : this(
325+
constructor(userId: UserId, call: Call, userName: String) : this(
290326
userId,
327+
userName,
291328
call.conversationId,
292329
call.conversationName,
293330
call.conversationType,
@@ -296,5 +333,3 @@ data class CallNotificationData(
296333
call.status
297334
)
298335
}
299-
300-
fun Map.Entry<UserId, Call>.toCallNotificationData() = CallNotificationData(userId = key, call = value)

app/src/main/kotlin/com/wire/android/notification/NotificationConstants.kt

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,9 @@ object NotificationConstants {
5252
// MessagesSummaryNotification ID depends on User, use fun getMessagesSummaryId(userId: UserId) to get it
5353
private const val MESSAGE_SUMMARY_ID_STRING = "wire_messages_summary_notification"
5454

55+
private const val INCOMING_CALL_TAG_PREFIX = "wire_incoming_call_tag_"
56+
const val INCOMING_CALL_ID_PREFIX = "wire_incoming_call_"
57+
5558
fun getConversationNotificationId(conversationIdString: String, userIdString: String) = (conversationIdString + userIdString).hashCode()
5659
fun getMessagesGroupKey(userId: UserId?): String = "$MESSAGE_GROUP_KEY_PREFIX${userId?.toString() ?: ""}"
5760
fun getMessagesSummaryId(userId: UserId): Int = "$MESSAGE_SUMMARY_ID_STRING$userId".hashCode()
@@ -60,6 +63,10 @@ object NotificationConstants {
6063
fun getPingsChannelId(userId: UserId): String = getChanelIdForUser(userId, PING_CHANNEL_ID)
6164
fun getIncomingChannelId(userId: UserId): String = getChanelIdForUser(userId, INCOMING_CALL_CHANNEL_ID)
6265
fun getOutgoingChannelId(userId: UserId): String = getChanelIdForUser(userId, OUTGOING_CALL_CHANNEL_ID)
66+
fun getIncomingCallId(userIdString: String, conversationIdString: String): Int =
67+
"$INCOMING_CALL_ID_PREFIX${userIdString}_$conversationIdString".hashCode()
68+
69+
fun getIncomingCallTag(userIdString: String): String = "$INCOMING_CALL_TAG_PREFIX$userIdString"
6370

6471
/**
6572
* @return NotificationChannelId [String] specific for user, use it to post a notifications.
@@ -72,7 +79,12 @@ object NotificationConstants {
7279

7380
// Notification IDs (has to be unique!)
7481
enum class NotificationIds {
75-
CALL_INCOMING_NOTIFICATION_ID,
82+
@Suppress("unused")
83+
@Deprecated(
84+
message = "Do not use it, it's here just because we use .ordinal as ID and ID for the foreground service notification cannot be 0",
85+
level = DeprecationLevel.ERROR
86+
)
87+
ZERO_ID,
7688
CALL_OUTGOING_ONGOING_NOTIFICATION_ID,
7789
PERSISTENT_NOTIFICATION_ID,
7890
MESSAGE_SYNC_NOTIFICATION_ID,

app/src/main/kotlin/com/wire/android/notification/WireNotificationManager.kt

Lines changed: 24 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ import com.wire.android.util.CurrentScreen
2828
import com.wire.android.util.CurrentScreenManager
2929
import com.wire.android.util.dispatchers.DispatcherProvider
3030
import com.wire.android.util.lifecycle.ConnectionPolicyManager
31+
import com.wire.android.util.logIfEmptyUserName
3132
import com.wire.kalium.logger.obfuscateId
3233
import com.wire.kalium.logic.CoreLogic
3334
import com.wire.kalium.logic.data.id.ConversationId
@@ -51,6 +52,7 @@ import kotlinx.coroutines.flow.StateFlow
5152
import kotlinx.coroutines.flow.cancellable
5253
import kotlinx.coroutines.flow.combine
5354
import kotlinx.coroutines.flow.distinctUntilChanged
55+
import kotlinx.coroutines.flow.first
5456
import kotlinx.coroutines.flow.flatMapLatest
5557
import kotlinx.coroutines.flow.flowOf
5658
import kotlinx.coroutines.flow.map
@@ -249,7 +251,7 @@ class WireNotificationManager @Inject constructor(
249251
// and remove the notifications that were displayed previously
250252
appLogger.i("$TAG no Users -> hide all the notifications")
251253
messagesNotificationManager.hideAllNotifications()
252-
callNotificationManager.hideAllNotifications()
254+
callNotificationManager.hideAllIncomingCallNotifications()
253255
servicesManager.stopCallService()
254256

255257
return
@@ -297,6 +299,7 @@ class WireNotificationManager @Inject constructor(
297299

298300
private fun stopObservingForUser(userId: UserId, observingJobs: ObservingJobs) {
299301
messagesNotificationManager.hideAllNotificationsForUser(userId)
302+
callNotificationManager.hideAllIncomingCallNotificationsForUser(userId)
300303
observingJobs.userJobs[userId]?.cancelAll()
301304
observingJobs.userJobs.remove(userId)
302305
}
@@ -336,20 +339,26 @@ class WireNotificationManager @Inject constructor(
336339
) {
337340
appLogger.d("$TAG observe incoming calls")
338341

339-
coreLogic.getSessionScope(userId).observeE2EIRequired()
340-
.map { it is E2EIRequiredResult.NoGracePeriod }
341-
.distinctUntilChanged()
342-
.flatMapLatest { isBlockedByE2EIRequired ->
343-
if (isBlockedByE2EIRequired) {
344-
appLogger.d("$TAG calls were blocked as E2EI is required")
345-
flowOf(listOf())
346-
} else {
347-
coreLogic.getSessionScope(userId).calls.getIncomingCalls()
342+
coreLogic.getSessionScope(userId).let { userSessionScope ->
343+
userSessionScope.observeE2EIRequired()
344+
.map { it is E2EIRequiredResult.NoGracePeriod }
345+
.distinctUntilChanged()
346+
.flatMapLatest { isBlockedByE2EIRequired ->
347+
if (isBlockedByE2EIRequired) {
348+
appLogger.d("$TAG calls were blocked as E2EI is required")
349+
flowOf(listOf())
350+
} else {
351+
userSessionScope.calls.getIncomingCalls()
352+
}.map { calls ->
353+
userSessionScope.users.getSelfUser().first()
354+
.also { it.logIfEmptyUserName() }
355+
.let { it.handle ?: it.name ?: "" } to calls
356+
}
348357
}
349-
}
350-
.collect { calls ->
351-
callNotificationManager.handleIncomingCallNotifications(calls, userId)
352-
}
358+
.collect { (userName, calls) ->
359+
callNotificationManager.handleIncomingCalls(calls, userId, userName)
360+
}
361+
}
353362
}
354363

355364
/**
@@ -366,6 +375,7 @@ class WireNotificationManager @Inject constructor(
366375
val selfUserNameState = coreLogic.getSessionScope(userId)
367376
.users
368377
.getSelfUser()
378+
.onEach { it.logIfEmptyUserName() }
369379
.map { it.handle ?: it.name ?: "" }
370380
.distinctUntilChanged()
371381
.stateIn(scope)

app/src/main/kotlin/com/wire/android/notification/broadcastreceivers/CallNotificationDismissedReceiver.kt

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ import android.content.Intent
2424
import com.wire.android.appLogger
2525
import com.wire.android.notification.CallNotificationIds
2626
import com.wire.android.notification.CallNotificationManager
27+
import com.wire.kalium.logger.obfuscateId
2728
import dagger.hilt.android.AndroidEntryPoint
2829
import javax.inject.Inject
2930

@@ -36,7 +37,10 @@ class CallNotificationDismissedReceiver : BroadcastReceiver() { // requires zero
3637
override fun onReceive(context: Context, intent: Intent) {
3738
val conversationIdString: String = intent.getStringExtra(EXTRA_CONVERSATION_ID) ?: return
3839
val userIdString: String = intent.getStringExtra(EXTRA_USER_ID) ?: return
39-
appLogger.i("CallNotificationDismissedReceiver: onReceive")
40+
appLogger.i(
41+
"CallNotificationDismissedReceiver: onReceive for user ${userIdString.obfuscateId()}" +
42+
" and conversation ${conversationIdString.obfuscateId()}"
43+
)
4044
callNotificationManager.reloadCallNotifications(CallNotificationIds(userIdString, conversationIdString))
4145
}
4246

0 commit comments

Comments
 (0)