Added chat notifications as bubbles

This commit is contained in:
Sylvain Berfini 2021-02-02 12:20:57 +01:00
parent a757b097fa
commit b047ebae13
12 changed files with 406 additions and 12 deletions

View file

@ -121,6 +121,12 @@
android:launchMode="singleTop"
android:noHistory="true" />
<activity
android:name=".activities.chat_bubble.ChatBubbleActivity"
android:allowEmbedded="true"
android:documentLaunchMode="always"
android:resizeableActivity="true" />
<!-- Services -->
<service

View file

@ -0,0 +1,144 @@
/*
* Copyright (c) 2010-2021 Belledonne Communications SARL.
*
* This file is part of linphone-android
* (see https://www.linphone.org).
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package org.linphone.activities.chat_bubble
import android.content.Intent
import android.os.Bundle
import androidx.databinding.DataBindingUtil
import androidx.lifecycle.ViewModelProvider
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
import org.linphone.LinphoneApplication.Companion.coreContext
import org.linphone.R
import org.linphone.activities.GenericActivity
import org.linphone.activities.main.MainActivity
import org.linphone.activities.main.chat.adapters.ChatMessagesListAdapter
import org.linphone.activities.main.chat.viewmodels.*
import org.linphone.activities.main.viewmodels.ListTopBarViewModel
import org.linphone.core.ChatRoom
import org.linphone.core.Factory
import org.linphone.core.tools.Log
import org.linphone.databinding.ChatBubbleActivityBinding
class ChatBubbleActivity : GenericActivity() {
private lateinit var binding: ChatBubbleActivityBinding
private lateinit var viewModel: ChatRoomViewModel
private lateinit var listViewModel: ChatMessagesListViewModel
private lateinit var chatSendingViewModel: ChatMessageSendingViewModel
private lateinit var adapter: ChatMessagesListAdapter
private val observer = object : RecyclerView.AdapterDataObserver() {
override fun onItemRangeInserted(positionStart: Int, itemCount: Int) {
if (positionStart == adapter.itemCount - 1) {
adapter.notifyItemChanged(positionStart - 1) // For grouping purposes
scrollToBottom()
}
}
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
binding = DataBindingUtil.setContentView(this, R.layout.chat_bubble_activity)
binding.lifecycleOwner = this
val localSipUri = intent.getStringExtra("LocalSipUri")
val remoteSipUri = intent.getStringExtra("RemoteSipUri")
var chatRoom: ChatRoom? = null
if (localSipUri != null && remoteSipUri != null) {
Log.i("[Chat Bubble] Found local [$localSipUri] & remote [$remoteSipUri] addresses in arguments")
val localAddress = Factory.instance().createAddress(localSipUri)
val remoteSipAddress = Factory.instance().createAddress(remoteSipUri)
chatRoom = coreContext.core.searchChatRoom(
null, localAddress, remoteSipAddress, arrayOfNulls(
0
)
)
}
chatRoom ?: return
chatRoom.markAsRead()
viewModel = ViewModelProvider(
this,
ChatRoomViewModelFactory(chatRoom)
)[ChatRoomViewModel::class.java]
binding.viewModel = viewModel
listViewModel = ViewModelProvider(
this,
ChatMessagesListViewModelFactory(chatRoom)
)[ChatMessagesListViewModel::class.java]
chatSendingViewModel = ViewModelProvider(
this,
ChatMessageSendingViewModelFactory(chatRoom)
)[ChatMessageSendingViewModel::class.java]
binding.chatSendingViewModel = chatSendingViewModel
val listSelectionViewModel = ViewModelProvider(this).get(ListTopBarViewModel::class.java)
adapter = ChatMessagesListAdapter(listSelectionViewModel, this)
// SubmitList is done on a background thread
// We need this adapter data observer to know when to scroll
binding.chatMessagesList.adapter = adapter
adapter.registerAdapterDataObserver(observer)
// Disable context menu on each message
adapter.disableContextMenu()
val layoutManager = LinearLayoutManager(this)
layoutManager.stackFromEnd = true
binding.chatMessagesList.layoutManager = layoutManager
listViewModel.events.observe(this, { events ->
adapter.submitList(events)
})
chatSendingViewModel.textToSend.observe(this, {
chatSendingViewModel.onTextToSendChanged(it)
})
binding.setOpenAppClickListener {
val intent = Intent(this, MainActivity::class.java)
intent.putExtra("RemoteSipUri", remoteSipUri)
intent.putExtra("LocalSipUri", localSipUri)
intent.putExtra("Chat", true)
intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK and Intent.FLAG_ACTIVITY_CLEAR_TASK)
startActivity(intent)
}
binding.setCloseBubbleClickListener {
val notificationId = coreContext.notificationsManager.getChatNotificationIdForSipUri(viewModel.chatRoom.peerAddress.asStringUriOnly())
coreContext.notificationsManager.cancel(notificationId)
}
binding.setSendMessageClickListener {
chatSendingViewModel.sendMessage()
binding.message.text?.clear()
}
}
private fun scrollToBottom() {
if (adapter.itemCount > 0) {
binding.chatMessagesList.scrollToPosition(adapter.itemCount - 1)
}
}
}

View file

@ -83,6 +83,8 @@ class ChatMessagesListAdapter(
}
}
private var contextMenuDisabled: Boolean = false
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder {
return when (viewType) {
EventLog.Type.ConferenceChatMessage.toInt() -> createChatMessageViewHolder(parent)
@ -119,6 +121,10 @@ class ChatMessagesListAdapter(
return eventLog.type.toInt()
}
fun disableContextMenu() {
contextMenuDisabled = true
}
inner class ChatMessageViewHolder(
private val binding: ChatMessageListCellBinding
) : RecyclerView.ViewHolder(binding.root), PopupMenu.OnMenuItemClickListener {
@ -176,6 +182,8 @@ class ChatMessagesListAdapter(
executePendingBindings()
if (contextMenuDisabled) return
setContextMenuClickListener {
val popup = PopupMenu(root.context, background)
popup.setOnMenuItemClickListener(this@ChatMessageViewHolder)

View file

@ -72,8 +72,6 @@ class DetailChatRoomFragment : MasterFragment<ChatRoomDetailFragmentBinding, Cha
}
}
private var chatRoomAddress: String? = null
override fun getLayoutId(): Int {
return R.layout.chat_room_detail_fragment
}
@ -121,7 +119,6 @@ class DetailChatRoomFragment : MasterFragment<ChatRoomDetailFragmentBinding, Cha
Compatibility.setLocusIdInContentCaptureSession(binding.root, chatRoom)
chatRoomAddress = chatRoom.peerAddress.asStringUriOnly()
isSecure = chatRoom.currentParams.encryptionEnabled()
viewModel = ViewModelProvider(
@ -328,7 +325,7 @@ class DetailChatRoomFragment : MasterFragment<ChatRoomDetailFragmentBinding, Cha
super.onResume()
// Prevent notifications for this chat room to be displayed
coreContext.notificationsManager.currentlyDisplayedChatRoomAddress = chatRoomAddress
coreContext.notificationsManager.currentlyDisplayedChatRoomAddress = viewModel.chatRoom.peerAddress.asStringUriOnly()
}
override fun onPause() {

View file

@ -20,6 +20,7 @@
package org.linphone.compatibility
import android.annotation.TargetApi
import android.app.NotificationManager
import android.content.ContentValues
import android.content.Context
import android.content.Intent
@ -53,6 +54,14 @@ class Api29Compatibility {
}
}
fun canChatMessageChannelBubble(context: Context): Boolean {
val notificationManager: NotificationManager =
context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
val bubblesAllowed = notificationManager.areBubblesAllowed()
Log.i("[Notifications Manager] Bubbles notifications are ${if (bubblesAllowed) "allowed" else "forbidden"}")
return bubblesAllowed
}
suspend fun addImageToMediaStore(context: Context, content: Content): Boolean {
val filePath = content.filePath
if (filePath == null) {

View file

@ -167,6 +167,13 @@ class Compatibility {
}
}
fun canChatMessageChannelBubble(context: Context): Boolean {
if (Version.sdkAboveOrEqual(Version.API29_ANDROID_10)) {
return Api29Compatibility.canChatMessageChannelBubble(context)
}
return false
}
suspend fun addImageToMediaStore(context: Context, content: Content): Boolean {
if (Version.sdkAboveOrEqual(Version.API29_ANDROID_10)) {
return Api29Compatibility.addImageToMediaStore(context, content)

View file

@ -136,7 +136,7 @@ open class Contact : Comparable<Contact> {
if (bm == null) IconCompat.createWithResource(
coreContext.context,
R.drawable.avatar
) else IconCompat.createWithBitmap(bm)
) else IconCompat.createWithAdaptiveBitmap(bm)
if (icon != null) {
personBuilder.setIcon(icon)
}

View file

@ -71,7 +71,7 @@ class NativeContact(val nativeId: String, private val lookupKey: String? = null)
if (bm == null) IconCompat.createWithResource(
coreContext.context,
R.drawable.avatar
) else IconCompat.createWithBitmap(bm)
) else IconCompat.createWithAdaptiveBitmap(bm)
if (icon != null) {
personBuilder.setIcon(icon)
}

View file

@ -42,6 +42,7 @@ import org.linphone.R
import org.linphone.activities.call.CallActivity
import org.linphone.activities.call.IncomingCallActivity
import org.linphone.activities.call.OutgoingCallActivity
import org.linphone.activities.chat_bubble.ChatBubbleActivity
import org.linphone.activities.main.MainActivity
import org.linphone.compatibility.Compatibility
import org.linphone.contact.Contact
@ -68,7 +69,8 @@ private class NotifiableMessage(
val time: Long,
val senderAvatar: Bitmap? = null,
var filePath: Uri? = null,
var fileMime: String? = null
var fileMime: String? = null,
val isOutgoing: Boolean = false
)
class NotificationsManager(private val context: Context) {
@ -149,6 +151,10 @@ class NotificationsManager(private val context: Context) {
return
}
if (corePreferences.chatRoomShortcuts) {
Log.i("[Notifications Manager] Ensure chat room shortcut exists for bubble notification")
Compatibility.createShortcutsToChatRooms(context)
}
displayIncomingChatNotification(room, message)
}
}
@ -240,6 +246,14 @@ class NotificationsManager(private val context: Context) {
return null
}
fun getChatNotificationIdForSipUri(sipUri: String): Int {
val notifiable: Notifiable? = chatNotificationsMap[sipUri]
if (notifiable != null) {
return notifiable.notificationId
}
return 0
}
/* Service related */
fun startForeground() {
@ -529,8 +543,13 @@ class NotificationsManager(private val context: Context) {
.setArguments(args)
.createPendingIntent()
val target = Intent(context, ChatBubbleActivity::class.java)
target.putExtra("RemoteSipUri", peerAddress)
target.putExtra("LocalSipUri", localAddress)
val bubbleIntent = PendingIntent.getActivity(context, notifiable.notificationId, target, PendingIntent.FLAG_UPDATE_CURRENT)
val id = LinphoneUtils.getChatRoomId(localAddress, peerAddress)
val notification = createMessageNotification(notifiable, pendingIntent, id)
val notification = createMessageNotification(notifiable, pendingIntent, bubbleIntent, id)
notify(notifiable.notificationId, notification)
}
@ -548,7 +567,7 @@ class NotificationsManager(private val context: Context) {
text += content.name
}
}
val notifiableMessage = NotifiableMessage(text, contact, displayName, message.time, senderAvatar = roundPicture)
val notifiableMessage = NotifiableMessage(text, contact, displayName, message.time, senderAvatar = roundPicture, isOutgoing = message.isOutgoing)
notifiable.messages.add(notifiableMessage)
for (content in message.contents) {
@ -601,7 +620,8 @@ class NotificationsManager(private val context: Context) {
message.textContent.orEmpty(),
null,
notifiable.myself ?: LinphoneUtils.getDisplayName(message.fromAddress),
System.currentTimeMillis()
System.currentTimeMillis(),
isOutgoing = true
)
notifiable.messages.add(reply)
@ -623,12 +643,14 @@ class NotificationsManager(private val context: Context) {
private fun createMessageNotification(
notifiable: Notifiable,
pendingIntent: PendingIntent,
bubbleIntent: PendingIntent,
id: String
): Notification {
val me = Person.Builder().setName(notifiable.myself).build()
val style = NotificationCompat.MessagingStyle(me)
val largeIcon: Bitmap? = notifiable.messages.last().senderAvatar
var lastPerson: Person? = null
for (message in notifiable.messages) {
val contact = message.contact
val person = if (contact != null) {
@ -637,7 +659,7 @@ class NotificationsManager(private val context: Context) {
val builder = Person.Builder().setName(message.sender)
val userIcon =
if (message.senderAvatar != null) {
IconCompat.createWithBitmap(message.senderAvatar)
IconCompat.createWithAdaptiveBitmap(message.senderAvatar)
} else {
IconCompat.createWithResource(context, R.drawable.avatar)
}
@ -645,6 +667,11 @@ class NotificationsManager(private val context: Context) {
builder.build()
}
// We don't want to see our own avatar
if (!message.isOutgoing) {
lastPerson = person
}
val msg = if (!corePreferences.hideChatMessageContentInNotification) {
NotificationCompat.MessagingStyle.Message(message.message, message.time, person)
} else {
@ -663,6 +690,10 @@ class NotificationsManager(private val context: Context) {
}
style.isGroupConversation = notifiable.isGroup
val icon = lastPerson?.icon ?: IconCompat.createWithResource(context, R.drawable.avatar)
val bubble = NotificationCompat.BubbleMetadata.Builder(bubbleIntent, icon)
.build()
val notificationBuilder = NotificationCompat.Builder(context, context.getString(R.string.notification_channel_chat_id))
.setSmallIcon(R.drawable.topbar_chat_notification)
.setAutoCancel(true)
@ -680,11 +711,16 @@ class NotificationsManager(private val context: Context) {
.addAction(getMarkMessageAsReadAction(notifiable))
.setShortcutId(id)
.setLocusId(LocusIdCompat(id))
if (corePreferences.markAsReadUponChatMessageNotificationDismissal) {
Log.i("[Notifications Manager] Chat room will be marked as read when notification will be dismissed")
notificationBuilder
.setDeleteIntent(getMarkMessageAsReadPendingIntent(notifiable))
}
if (Compatibility.canChatMessageChannelBubble(context)) {
notificationBuilder.bubbleMetadata = bubble
}
return notificationBuilder.build()
}

View file

@ -0,0 +1,184 @@
<?xml version="1.0" encoding="utf-8"?>
<layout xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:android="http://schemas.android.com/apk/res/android">
<data>
<import type="android.view.View"/>
<variable
name="closeBubbleClickListener"
type="android.view.View.OnClickListener"/>
<variable
name="openAppClickListener"
type="android.view.View.OnClickListener"/>
<variable
name="sendMessageClickListener"
type="android.view.View.OnClickListener"/>
<variable
name="viewModel"
type="org.linphone.activities.main.chat.viewmodels.ChatRoomViewModel" />
<variable
name="chatSendingViewModel"
type="org.linphone.activities.main.chat.viewmodels.ChatMessageSendingViewModel" />
</data>
<RelativeLayout
android:layout_width="match_parent"
android:layout_height="match_parent">
<LinearLayout
android:id="@+id/top_bar"
android:layout_width="match_parent"
android:layout_height="60dp"
android:background="?attr/lightToolbarBackgroundColor"
android:orientation="horizontal">
<!-- Disabled for now -->
<ImageView
android:contentDescription="@string/content_description_close_bubble"
android:visibility="gone"
android:onClick="@{closeBubbleClickListener}"
android:layout_width="0dp"
android:layout_height="match_parent"
android:layout_weight="0.2"
android:layout_margin="5dp"
android:background="?attr/button_background_drawable"
android:src="@drawable/menu_delete_default"
app:tint="?attr/drawableTintColor" />
<LinearLayout
android:layout_width="0dp"
android:layout_height="match_parent"
android:layout_gravity="center"
android:layout_weight="0.4"
android:gravity="center"
android:orientation="vertical"
android:paddingLeft="5dp">
<org.linphone.views.MarqueeTextView
android:text="@{viewModel.oneToOneChatRoom ? (viewModel.contact.fullName ?? viewModel.displayName) : viewModel.subject}"
style="@style/toolbar_small_title_font"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:singleLine="true" />
<org.linphone.views.MarqueeTextView
android:text="@{viewModel.participants}"
android:visibility="@{viewModel.oneToOneChatRoom ? View.GONE : View.VISIBLE}"
style="@style/toolbar_small_title_font"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:singleLine="true" />
</LinearLayout>
<!-- Disabled for now -->
<ImageView
android:onClick="@{openAppClickListener}"
android:visibility="gone"
android:contentDescription="@string/content_description_open_app"
android:layout_width="0dp"
android:layout_height="match_parent"
android:layout_weight="0.2"
android:layout_margin="5dp"
android:background="?attr/button_background_drawable"
app:tint="?attr/colorPrimary"
android:src="@drawable/linphone_logo"/>
</LinearLayout>
<LinearLayout
android:id="@+id/footer"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_alignParentBottom="true"
android:gravity="center_vertical"
android:background="?attr/backgroundColor"
android:orientation="horizontal">
<org.linphone.activities.main.chat.views.RichEditText
android:id="@+id/message"
android:enabled="@{!chatSendingViewModel.isReadOnly}"
android:text="@={chatSendingViewModel.textToSend}"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_gravity="center_vertical"
android:layout_marginTop="5dp"
android:layout_marginBottom="5dp"
android:layout_marginLeft="5dp"
android:layout_weight="1"
android:background="@drawable/resizable_text_field"
android:imeOptions="flagNoExtractUi"
android:inputType="textShortMessage|textMultiLine|textAutoComplete|textAutoCorrect|textCapSentences"
android:maxLines="6"
android:padding="5dp"
android:textColor="@color/black_color"
android:textCursorDrawable="@null" />
<RelativeLayout
android:layout_width="wrap_content"
android:layout_height="wrap_content">
<ImageView
android:id="@+id/send_message"
android:onClick="@{sendMessageClickListener}"
android:enabled="@{chatSendingViewModel.sendMessageEnabled &amp;&amp; !chatSendingViewModel.isReadOnly}"
android:contentDescription="@string/content_description_send_message"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:padding="5dp"
android:src="@drawable/chat_send_message" />
<ImageView
android:visibility="@{viewModel.chatRoom.ephemeralEnabled() ? View.VISIBLE : View.GONE, default=gone}"
android:enabled="@{chatSendingViewModel.sendMessageEnabled &amp;&amp; !chatSendingViewModel.isReadOnly}"
android:contentDescription="@string/content_description_ephemeral_message"
android:clickable="false"
android:layout_width="20dp"
android:layout_height="wrap_content"
android:adjustViewBounds="true"
android:layout_alignRight="@id/send_message"
android:layout_alignBottom="@id/send_message"
android:padding="5dp"
android:src="@drawable/ephemeral_messages" />
</RelativeLayout>
</LinearLayout>
<TextView
android:id="@+id/remote_composing"
android:text="@{viewModel.composingList, default=@string/chat_remote_is_composing}"
android:visibility="@{viewModel.remoteIsComposing ? View.VISIBLE : View.INVISIBLE}"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_above="@id/footer"
android:layout_marginLeft="5dp"
style="@style/standard_small_text_font" />
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/chat_messages_list"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:layout_above="@id/remote_composing"
android:layout_below="@+id/top_bar"
android:cacheColorHint="@color/transparent_color"
android:choiceMode="multipleChoice"
android:divider="@android:color/transparent"
android:listSelector="@color/transparent_color"
android:transcriptMode="normal" />
<ImageView
android:src="@{viewModel.securityLevelIcon, default=@drawable/security_alert_indicator}"
android:visibility="@{viewModel.encryptedChatRoom ? View.VISIBLE : View.GONE}"
android:contentDescription="@{viewModel.securityLevelContentDescription}"
android:adjustViewBounds="true"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_below="@id/top_bar"
android:layout_alignParentRight="true"
android:layout_marginTop="8dp"
android:layout_marginRight="8dp" />
</RelativeLayout>
</layout>

View file

@ -185,7 +185,8 @@
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_gravity="center_vertical"
android:layout_margin="5dp"
android:layout_marginTop="5dp"
android:layout_marginBottom="5dp"
android:layout_weight="1"
android:background="@drawable/resizable_text_field"
android:imeOptions="flagNoExtractUi"

View file

@ -707,4 +707,6 @@
<string name="content_description_toggle_call_stats">Show or hide call statistics</string>
<string name="content_description_chat_message_video_attachment">Video attachment</string>
<string name="content_description_take_screenshot">Take a screenshot of received video</string>
<string name="content_description_close_bubble">Close notification bubble</string>
<string name="content_description_open_app">Open conversation in app instead of bubble</string>
</resources>