From d531a47c7050e6674f3f9d0dad076bd52a2f300b Mon Sep 17 00:00:00 2001 From: Sylvain Berfini Date: Thu, 14 Sep 2023 15:47:19 +0200 Subject: [PATCH] Prevent calls list to scroll to top at each refresh --- .../org/linphone/contacts/ContactsManager.kt | 34 ++ .../java/org/linphone/core/CoreContext.kt | 8 + .../java/org/linphone/core/CorePreferences.kt | 2 +- .../notifications/NotificationsManager.kt | 448 +++++++++++++++++- .../main/calls/fragment/CallsListFragment.kt | 14 +- .../java/org/linphone/utils/LinphoneUtils.kt | 15 + app/src/main/res/values/strings.xml | 4 + 7 files changed, 497 insertions(+), 28 deletions(-) diff --git a/app/src/main/java/org/linphone/contacts/ContactsManager.kt b/app/src/main/java/org/linphone/contacts/ContactsManager.kt index d6fc36f42..287249e2f 100644 --- a/app/src/main/java/org/linphone/contacts/ContactsManager.kt +++ b/app/src/main/java/org/linphone/contacts/ContactsManager.kt @@ -41,6 +41,7 @@ import org.linphone.ui.main.contacts.model.ContactNumberOrAddressClickListener import org.linphone.ui.main.contacts.model.ContactNumberOrAddressModel import org.linphone.ui.main.model.isInSecureMode import org.linphone.utils.ImageUtils +import org.linphone.utils.LinphoneUtils import org.linphone.utils.PhoneNumberUtils class ContactsManager @UiThread constructor(context: Context) { @@ -152,6 +153,39 @@ class ContactsManager @UiThread constructor(context: Context) { } } + @WorkerThread + fun getMePerson(localAddress: Address): Person { + val account = coreContext.core.accountList.find { + it.params.identityAddress?.weakEqual(localAddress) ?: false + } + val name = account?.params?.identityAddress?.displayName ?: LinphoneUtils.getDisplayName( + localAddress + ) + val personBuilder = Person.Builder().setName(name) + + val photo = account?.params?.pictureUri.orEmpty() + val bm: Bitmap? = if (photo.isNotEmpty()) { + ImageUtils.getRoundBitmapFromUri( + coreContext.context, + Uri.parse(photo ?: "") + ) + } else { + null + } + + personBuilder.setIcon( + if (bm == null) { + coreContext.contactsManager.contactAvatar + } else { + IconCompat.createWithAdaptiveBitmap(bm) + } + ) + + personBuilder.setKey("") // TODO FIXME: use a valid key + personBuilder.setImportant(false) + return personBuilder.build() + } + interface ContactsListener { fun onContactsLoaded() } diff --git a/app/src/main/java/org/linphone/core/CoreContext.kt b/app/src/main/java/org/linphone/core/CoreContext.kt index c4ff862c5..183675188 100644 --- a/app/src/main/java/org/linphone/core/CoreContext.kt +++ b/app/src/main/java/org/linphone/core/CoreContext.kt @@ -193,6 +193,14 @@ class CoreContext @UiThread constructor(val context: Context) : HandlerThread("C } } + @WorkerThread + fun isAddressMyself(address: Address): Boolean { + val found = core.accountList.find { + it.params.identityAddress?.weakEqual(address) ?: false + } + return found != null + } + @WorkerThread fun startCall( address: Address, diff --git a/app/src/main/java/org/linphone/core/CorePreferences.kt b/app/src/main/java/org/linphone/core/CorePreferences.kt index 7c07b8dd6..d62536fe7 100644 --- a/app/src/main/java/org/linphone/core/CorePreferences.kt +++ b/app/src/main/java/org/linphone/core/CorePreferences.kt @@ -79,7 +79,7 @@ class CorePreferences @UiThread constructor(private val context: Context) { // Will disable chat feature completely @get:WorkerThread val disableChat: Boolean - get() = config.getBool("app", "disable_chat_feature", false) + get() = config.getBool("app", "disable_chat_feature", false) // TODO FIXME: set it to true for first "release" without chat @get:WorkerThread val defaultDomain: String diff --git a/app/src/main/java/org/linphone/notifications/NotificationsManager.kt b/app/src/main/java/org/linphone/notifications/NotificationsManager.kt index 298d3fbf2..74f1bc6f3 100644 --- a/app/src/main/java/org/linphone/notifications/NotificationsManager.kt +++ b/app/src/main/java/org/linphone/notifications/NotificationsManager.kt @@ -33,24 +33,30 @@ import android.graphics.Bitmap import android.net.Uri import androidx.annotation.AnyThread import androidx.annotation.MainThread -import androidx.annotation.RequiresPermission import androidx.annotation.WorkerThread import androidx.core.app.ActivityCompat import androidx.core.app.NotificationCompat import androidx.core.app.NotificationManagerCompat import androidx.core.app.Person import androidx.core.content.ContextCompat +import androidx.core.content.LocusIdCompat import androidx.core.graphics.drawable.IconCompat import org.linphone.LinphoneApplication.Companion.coreContext +import org.linphone.LinphoneApplication.Companion.corePreferences import org.linphone.R import org.linphone.contacts.getPerson +import org.linphone.core.Address import org.linphone.core.Call +import org.linphone.core.ChatMessage +import org.linphone.core.ChatMessageReaction +import org.linphone.core.ChatRoom import org.linphone.core.Core import org.linphone.core.CoreForegroundService import org.linphone.core.CoreListenerStub import org.linphone.core.Friend import org.linphone.core.tools.Log import org.linphone.ui.voip.VoipActivity +import org.linphone.utils.AppUtils import org.linphone.utils.ImageUtils import org.linphone.utils.LinphoneUtils @@ -63,6 +69,9 @@ class NotificationsManager @MainThread constructor(private val context: Context) const val INTENT_CALL_ID = "CALL_ID" const val INTENT_NOTIF_ID = "NOTIFICATION_ID" + + const val CHAT_TAG = "Chat" + const val CHAT_NOTIFICATIONS_GROUP = "CHAT_NOTIF_GROUP" } private val notificationManager: NotificationManagerCompat by lazy { @@ -97,16 +106,122 @@ class NotificationsManager @MainThread constructor(private val context: Context) Log.i("$TAG Last call ended, stopping foreground service") stopCallForeground() } + + @WorkerThread + override fun onMessagesReceived( + core: Core, + chatRoom: ChatRoom, + messages: Array + ) { + Log.i("$TAG Received ${messages.size} aggregated messages") + if (corePreferences.disableChat) return + + if (chatRoom.muted) { + val id = LinphoneUtils.getChatRoomId(chatRoom.localAddress, chatRoom.peerAddress) + Log.i("$TAG Chat room $id has been muted") + return + } + + showChatRoomNotification(chatRoom, messages) + } + + @WorkerThread + override fun onNewMessageReaction( + core: Core, + chatRoom: ChatRoom, + message: ChatMessage, + reaction: ChatMessageReaction + ) { + val address = reaction.fromAddress + val defaultAccountAddress = core.defaultAccount?.params?.identityAddress + // Do not notify our own reactions, it won't be done anyway since the chat room is very likely to be currently displayed + if (defaultAccountAddress != null && defaultAccountAddress.weakEqual(address)) return + + Log.i( + "$TAG Reaction received [${reaction.body}] from [${address.asStringUriOnly()}] for chat message [$message]" + ) + if (corePreferences.disableChat) return + + if (chatRoom.muted) { + val id = LinphoneUtils.getChatRoomId(chatRoom.localAddress, chatRoom.peerAddress) + Log.i("$TAG Chat room $id has been muted") + return + } + if (coreContext.isAddressMyself(address)) { + Log.i("$TAG Reaction has been sent by ourselves, do not notify it") + return + } + + if (reaction.body.isNotEmpty()) { + showChatMessageReactionNotification(chatRoom, reaction.body, address, message) + } + } + + @WorkerThread + override fun onReactionRemoved( + core: Core, + chatRoom: ChatRoom, + message: ChatMessage, + address: Address + ) { + Log.i( + "$TAG [${address.asStringUriOnly()}] removed it's previously sent reaction for chat message [$message]" + ) + if (corePreferences.disableChat) return + + if (chatRoom.muted) { + val id = LinphoneUtils.getChatRoomId(chatRoom.localAddress, chatRoom.peerAddress) + Log.i("$TAG Chat room $id has been muted") + return + } + + val chatRoomPeerAddress = chatRoom.peerAddress.asStringUriOnly() + var notifiable: Notifiable? = chatNotificationsMap[chatRoomPeerAddress] + if (notifiable == null) { + Log.i("$TAG No notification for chat room [$chatRoomPeerAddress], nothing to do") + return + } + + val from = address.asStringUriOnly() + val found = notifiable.messages.find { + it.isReaction && it.reactionToMessageId == message.messageId && it.reactionFrom == from + } + if (found != null) { + if (notifiable.messages.remove(found)) { + if (notifiable.messages.isNotEmpty()) { + Log.i( + "$TAG After removing original reaction notification there is still messages, updating notification" + ) + val me = coreContext.contactsManager.getMePerson(chatRoom.localAddress) + val notification = createMessageNotification( + notifiable, + LinphoneUtils.getChatRoomId(chatRoom), + me + ) + notify(notifiable.notificationId, notification, CHAT_TAG) + } else { + Log.i( + "$TAG After removing original reaction notification there is nothing left to display, remove notification" + ) + notificationManager.cancel(CHAT_TAG, notifiable.notificationId) + } + } + } else { + Log.w("$TAG Original reaction not found in currently displayed notification") + } + } } private var coreService: CoreForegroundService? = null private val callNotificationsMap: HashMap = HashMap() + private val chatNotificationsMap: HashMap = HashMap() init { createServiceChannel() createIncomingCallNotificationChannel() createActiveCallNotificationChannel() + createMessageChannel() for (notification in notificationManager.activeNotifications) { if (notification.tag.isNullOrEmpty()) { @@ -171,13 +286,7 @@ class NotificationsManager @MainThread constructor(private val context: Context) pendingIntent, isIncoming ) - if (ActivityCompat.checkSelfPermission( - context, - Manifest.permission.POST_NOTIFICATIONS - ) == PackageManager.PERMISSION_GRANTED - ) { - notify(notifiable.notificationId, notification) - } + notify(notifiable.notificationId, notification) } @WorkerThread @@ -230,23 +339,141 @@ class NotificationsManager @MainThread constructor(private val context: Context) } } - @RequiresPermission(Manifest.permission.POST_NOTIFICATIONS) + private fun getNotifiableForRoom(chatRoom: ChatRoom): Notifiable { + val address = chatRoom.peerAddress.asStringUriOnly() + var notifiable: Notifiable? = chatNotificationsMap[address] + if (notifiable == null) { + notifiable = Notifiable(LinphoneUtils.getChatRoomId(chatRoom).hashCode()) + notifiable.myself = LinphoneUtils.getDisplayName(chatRoom.localAddress) + notifiable.localIdentity = chatRoom.localAddress.asStringUriOnly() + notifiable.remoteAddress = chatRoom.peerAddress.asStringUriOnly() + + chatNotificationsMap[address] = notifiable + + if (chatRoom.hasCapability(ChatRoom.Capabilities.OneToOne.toInt())) { + notifiable.isGroup = false + } else { + notifiable.isGroup = true + notifiable.groupTitle = chatRoom.subject + } + } + return notifiable + } + + @WorkerThread + private fun showChatRoomNotification(chatRoom: ChatRoom, messages: Array) { + val notifiable = getNotifiableForRoom(chatRoom) + + var updated = false + for (message in messages) { + if (message.isRead || message.isOutgoing) continue + val notifiableMessage = getNotifiableForChatMessage(message) + notifiable.messages.add(notifiableMessage) + updated = true + } + + if (chatRoom.hasCapability(ChatRoom.Capabilities.OneToOne.toInt())) { + notifiable.isGroup = false + } else { + notifiable.isGroup = true + notifiable.groupTitle = chatRoom.subject + } + if (!updated) { + Log.w("$TAG No changes made to notifiable, do not display it again") + return + } + + if (notifiable.messages.isNotEmpty()) { + val me = coreContext.contactsManager.getMePerson(chatRoom.localAddress) + val notification = createMessageNotification( + notifiable, + LinphoneUtils.getChatRoomId(chatRoom), + me + ) + notify(notifiable.notificationId, notification, CHAT_TAG) + } else { + Log.w( + "$TAG No message to display in received aggregated messages" + ) + } + } + + @WorkerThread + private fun showChatMessageReactionNotification( + chatRoom: ChatRoom, + reaction: String, + from: Address, + message: ChatMessage + ) { + val notifiable = getNotifiableForRoom(chatRoom) + + val contact = + coreContext.contactsManager.findContactByAddress(from) + val contactPicture = contact?.photo + val roundPicture = if (!contactPicture.isNullOrEmpty()) { + ImageUtils.getRoundBitmapFromUri(context, Uri.parse(contactPicture)) + } else { + null + } + val displayName = contact?.name ?: LinphoneUtils.getDisplayName(from) + + val originalMessage = getTextDescribingMessage(message) + val text = AppUtils.getString(R.string.chat_message_reaction_received).format( + displayName, + reaction, + originalMessage + ) + + val notifiableMessage = NotifiableMessage( + text, + contact, + displayName, + message.time, + senderAvatar = roundPicture, + isOutgoing = false, + isReaction = true, + reactionToMessageId = message.messageId, + reactionFrom = from.asStringUriOnly() + ) + notifiable.messages.add(notifiableMessage) + + if (notifiable.messages.isNotEmpty()) { + val me = coreContext.contactsManager.getMePerson(chatRoom.localAddress) + val notification = createMessageNotification( + notifiable, + LinphoneUtils.getChatRoomId(chatRoom), + me + ) + notify(notifiable.notificationId, notification, CHAT_TAG) + } else { + Log.e( + "$TAG Notifiable is empty but we should have displayed the reaction!" + ) + } + } + @WorkerThread private fun notify(id: Int, notification: Notification, tag: String? = null) { - Log.i("$TAG Notifying [$id] with tag [$tag]") - try { - notificationManager.notify(tag, id, notification) - } catch (iae: IllegalArgumentException) { - if (coreService == null && tag == null) { - // We can't notify using CallStyle if there isn't a foreground service running - Log.w( - "$TAG Foreground service hasn't started yet, can't display a CallStyle notification until then: $iae" - ) - } else { - Log.e("$TAG Illegal Argument Exception occurred: $iae") + if (ActivityCompat.checkSelfPermission( + context, + Manifest.permission.POST_NOTIFICATIONS + ) == PackageManager.PERMISSION_GRANTED + ) { + Log.i("$TAG Notifying [$id] with tag [$tag]") + try { + notificationManager.notify(tag, id, notification) + } catch (iae: IllegalArgumentException) { + if (coreService == null && tag == null) { + // We can't notify using CallStyle if there isn't a foreground service running + Log.w( + "$TAG Foreground service hasn't started yet, can't display a CallStyle notification until then: $iae" + ) + } else { + Log.e("$TAG Illegal Argument Exception occurred: $iae") + } + } catch (e: Exception) { + Log.e("$TAG Exception occurred: $e") } - } catch (e: Exception) { - Log.e("$TAG Exception occurred: $e") } } @@ -274,6 +501,55 @@ class NotificationsManager @MainThread constructor(private val context: Context) return notifiable } + @WorkerThread + private fun getNotifiableForChatMessage(message: ChatMessage): NotifiableMessage { + val contact = + coreContext.contactsManager.findContactByAddress(message.fromAddress) + val contactPicture = contact?.photo + val roundPicture = if (!contactPicture.isNullOrEmpty()) { + ImageUtils.getRoundBitmapFromUri(context, Uri.parse(contactPicture)) + } else { + null + } + val displayName = contact?.name ?: LinphoneUtils.getDisplayName(message.fromAddress) + + val text = getTextDescribingMessage(message) + val notifiableMessage = NotifiableMessage( + text, + contact, + displayName, + message.time * 1000, /* Linphone timestamps are in seconds */ + senderAvatar = roundPicture, + isOutgoing = message.isOutgoing + ) + + for (content in message.contents) { + /*if (content.isFile) { // TODO + val path = content.filePath + if (path != null) { + val contentUri: Uri = FileUtils.getFilePath(context, path) + val filePath: String = contentUri.toString() + val extension = FileUtils.getExtensionFromFileName(filePath) + if (extension.isNotEmpty()) { + val mime = + MimeTypeMap.getSingleton().getMimeTypeFromExtension(extension) + notifiableMessage.filePath = contentUri + notifiableMessage.fileMime = mime + Log.i( + "$TAG Added file $contentUri with MIME $mime to notification" + ) + } else { + Log.e( + "$TAG Couldn't find extension for incoming message with file $path" + ) + } + } + }*/ + } + + return notifiableMessage + } + @WorkerThread private fun createCallNotification( context: Context, @@ -370,6 +646,73 @@ class NotificationsManager @MainThread constructor(private val context: Context) return builder.build() } + private fun createMessageNotification( + notifiable: Notifiable, + id: String, + me: Person + ): Notification { + val style = NotificationCompat.MessagingStyle(me) + val allPersons = arrayListOf() + + var lastPersonAvatar: Bitmap? = null + var lastPerson: Person? = null + for (message in notifiable.messages) { + val friend = message.friend + val person = getPerson(friend, message.sender, message.senderAvatar) + + if (!message.isOutgoing) { + // We don't want to see our own avatar + lastPerson = person + lastPersonAvatar = message.senderAvatar + + if (allPersons.find { it.key == person.key } == null) { + allPersons.add(person) + } + } + + val senderPerson = if (message.isOutgoing) null else person // Use null for ourselves + val tmp = NotificationCompat.MessagingStyle.Message( + message.message, + message.time, + senderPerson + ) + if (message.filePath != null) tmp.setData(message.fileMime, message.filePath) + + style.addMessage(tmp) + if (message.isOutgoing) { + style.addHistoricMessage(tmp) + } + } + + style.conversationTitle = if (notifiable.isGroup) notifiable.groupTitle else lastPerson?.name + style.isGroupConversation = notifiable.isGroup + + val largeIcon = lastPersonAvatar + val notificationBuilder = NotificationCompat.Builder( + context, + context.getString(R.string.notification_channel_chat_id) + ) + .setSmallIcon(R.drawable.chat_text) + .setAutoCancel(true) + .setLargeIcon(largeIcon) + .setColor(ContextCompat.getColor(context, R.color.primary_color)) + .setCategory(NotificationCompat.CATEGORY_MESSAGE) + .setGroup(CHAT_NOTIFICATIONS_GROUP) + .setVisibility(NotificationCompat.VISIBILITY_PRIVATE) + .setNumber(notifiable.messages.size) + .setWhen(System.currentTimeMillis()) + .setShowWhen(true) + .setStyle(style) + .setShortcutId(id) + .setLocusId(LocusIdCompat(id)) + + for (person in allPersons) { + notificationBuilder.addPerson(person) + } + + return notificationBuilder.build() + } + @WorkerThread private fun dismissCallNotification(call: Call) { val address = call.remoteAddress.asStringUriOnly() @@ -457,6 +800,22 @@ class NotificationsManager @MainThread constructor(private val context: Context) notificationManager.createNotificationChannel(channel) } + @MainThread + private fun createMessageChannel() { + val id = context.getString(R.string.notification_channel_chat_id) + val name = context.getString(R.string.notification_channel_chat_name) + + val channel = NotificationChannel(id, name, NotificationManager.IMPORTANCE_HIGH) + channel.description = name + channel.lightColor = context.getColor(R.color.primary_color) + channel.lockscreenVisibility = Notification.VISIBILITY_PUBLIC + channel.enableLights(true) + channel.enableVibration(true) + channel.setShowBadge(true) + channel.setAllowBubbles(true) + notificationManager.createNotificationChannel(channel) + } + @MainThread private fun createServiceChannel() { val id = context.getString(R.string.notification_channel_service_id) @@ -470,8 +829,53 @@ class NotificationsManager @MainThread constructor(private val context: Context) notificationManager.createNotificationChannel(channel) } + @WorkerThread + private fun getTextDescribingMessage(message: ChatMessage): String { + // If message contains text, then use that + var text = message.contents.find { content -> content.isText }?.utf8Text ?: "" + + if (text.isEmpty()) { + val firstContent = message.contents.firstOrNull() + if (firstContent?.isIcalendar == true) { + text = "meeting invite" // TODO: use translated string + } else if (firstContent?.isVoiceRecording == true) { + text = "voice message" // TODO: use translated string + } else { + for (content in message.contents) { + if (text.isNotEmpty()) { + text += ", " + } + text += content.name + } + } + } + + return text + } + class Notifiable(val notificationId: Int) { var myself: String? = null var callId: String? = null + + var localIdentity: String? = null + var remoteAddress: String? = null + + var isGroup: Boolean = false + var groupTitle: String? = null + val messages: ArrayList = arrayListOf() } + + class NotifiableMessage( + var message: String, + val friend: Friend?, + val sender: String, + val time: Long, + val senderAvatar: Bitmap? = null, + var filePath: Uri? = null, + var fileMime: String? = null, + val isOutgoing: Boolean = false, + val isReaction: Boolean = false, + val reactionToMessageId: String? = null, + val reactionFrom: String? = null + ) } diff --git a/app/src/main/java/org/linphone/ui/main/calls/fragment/CallsListFragment.kt b/app/src/main/java/org/linphone/ui/main/calls/fragment/CallsListFragment.kt index 318546c75..997467f04 100644 --- a/app/src/main/java/org/linphone/ui/main/calls/fragment/CallsListFragment.kt +++ b/app/src/main/java/org/linphone/ui/main/calls/fragment/CallsListFragment.kt @@ -148,13 +148,17 @@ class CallsListFragment : AbstractTopBarFragment() { binding.callsList.layoutManager = layoutManager listViewModel.callLogs.observe(viewLifecycleOwner) { - Log.i("$TAG Call logs ready with [${it.size}] items") + val currentCount = adapter.itemCount adapter.submitList(it) - binding.callsList.scrollToPosition(0) + Log.i("$TAG Call logs ready with [${it.size}] items") - (view.parent as? ViewGroup)?.doOnPreDraw { - startPostponedEnterTransition() - sharedViewModel.callsListReadyToBeDisplayedEvent.value = Event(true) + if (currentCount == 0) { + (view.parent as? ViewGroup)?.doOnPreDraw { + startPostponedEnterTransition() + sharedViewModel.callsListReadyToBeDisplayedEvent.value = Event(true) + } + } else if (currentCount < it.size) { + binding.callsList.scrollToPosition(0) } } diff --git a/app/src/main/java/org/linphone/utils/LinphoneUtils.kt b/app/src/main/java/org/linphone/utils/LinphoneUtils.kt index e7ca8d065..26f15998a 100644 --- a/app/src/main/java/org/linphone/utils/LinphoneUtils.kt +++ b/app/src/main/java/org/linphone/utils/LinphoneUtils.kt @@ -38,6 +38,7 @@ import org.linphone.core.Address import org.linphone.core.Call import org.linphone.core.Call.Dir import org.linphone.core.Call.Status +import org.linphone.core.ChatRoom import org.linphone.core.tools.Log class LinphoneUtils { @@ -174,5 +175,19 @@ class LinphoneUtils { } return name } + + @WorkerThread + fun getChatRoomId(room: ChatRoom): String { + return getChatRoomId(room.localAddress, room.peerAddress) + } + + @WorkerThread + fun getChatRoomId(localAddress: Address, remoteAddress: Address): String { + val localSipUri = localAddress.clone() + localSipUri.clean() + val remoteSipUri = remoteAddress.clone() + remoteSipUri.clean() + return "${localSipUri.asStringUriOnly()}~${remoteSipUri.asStringUriOnly()}" + } } } diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index fe7e3a06e..7707f845c 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -13,6 +13,7 @@ linphone_notification_call_id linphone_notification_incoming_call_id linphone_notification_service_id + linphone_notification_chat_id GNU General Public License v3.0 © Belledonne Communications 2010-2023 @@ -40,6 +41,7 @@ &appName; active calls notifications &appName; incoming calls notifications &appName; service notification + &appName; instant messages notifications Contacts Calls @@ -252,6 +254,8 @@ Pause Pause Record + + %s has reacted by %s to: %s Skip