From 8b07a2aa72baf01162eff512753fccfb1ffd4130 Mon Sep 17 00:00:00 2001 From: Dariusz Olszewski Date: Mon, 18 Apr 2022 21:21:44 +0200 Subject: [PATCH] Reply from notification - initial implementation Signed-off-by: Dariusz Olszewski --- app/src/main/AndroidManifest.xml | 2 + .../talk/jobs/NotificationWorker.java | 35 ++++ .../talk/receivers/DirectReplyReceiver.kt | 186 ++++++++++++++++++ .../nextcloud/talk/utils/NotificationUtils.kt | 3 + 4 files changed, 226 insertions(+) create mode 100644 app/src/main/java/com/nextcloud/talk/receivers/DirectReplyReceiver.kt diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 9e52ddd26..e6e3536c0 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -177,6 +177,8 @@ + + diff --git a/app/src/main/java/com/nextcloud/talk/jobs/NotificationWorker.java b/app/src/main/java/com/nextcloud/talk/jobs/NotificationWorker.java index 86c67b5ee..a61500283 100644 --- a/app/src/main/java/com/nextcloud/talk/jobs/NotificationWorker.java +++ b/app/src/main/java/com/nextcloud/talk/jobs/NotificationWorker.java @@ -60,6 +60,7 @@ import com.nextcloud.talk.models.json.conversations.RoomOverall; import com.nextcloud.talk.models.json.notifications.NotificationOverall; import com.nextcloud.talk.models.json.push.DecryptedPushMessage; import com.nextcloud.talk.models.json.push.NotificationUser; +import com.nextcloud.talk.receivers.DirectReplyReceiver; import com.nextcloud.talk.utils.ApiUtils; import com.nextcloud.talk.utils.DisplayUtils; import com.nextcloud.talk.utils.DoNotDisturbUtils; @@ -90,6 +91,7 @@ import androidx.annotation.Nullable; import androidx.core.app.NotificationCompat; import androidx.core.app.NotificationManagerCompat; import androidx.core.app.Person; +import androidx.core.app.RemoteInput; import androidx.core.graphics.drawable.IconCompat; import androidx.emoji.text.EmojiCompat; import androidx.work.Data; @@ -386,6 +388,7 @@ public class NotificationWorker extends Worker { "@" + decryptedPushMessage.getNotificationUser().getId()).setName(EmojiCompat.get().process(decryptedPushMessage.getNotificationUser().getName())).setBot(decryptedPushMessage.getNotificationUser().getType().equals("bot")); notificationBuilder.setOnlyAlertOnce(true); + addReplyAction(notificationBuilder, notificationId); if (decryptedPushMessage.getNotificationUser().getType().equals("user") || decryptedPushMessage.getNotificationUser().getType().equals("guest")) { String avatarUrl = ApiUtils.getUrlForAvatar(signatureVerification.getUserEntity().getBaseUrl(), @@ -434,6 +437,38 @@ public class NotificationWorker extends Worker { } + private void addReplyAction(NotificationCompat.Builder notificationBuilder, int notificationId) { + String replyLabel = context.getResources().getString(R.string.nc_reply); + + RemoteInput remoteInput = new RemoteInput.Builder(NotificationUtils.KEY_DIRECT_REPLY) + .setLabel(replyLabel) + .build(); + + // Build a PendingIntent for the reply action + Intent actualIntent = new Intent(context, DirectReplyReceiver.class); + + // NOTE - This notificationId is an internal ID used on the device only. + // It is NOT the same as the notification ID used in communication with the server. + actualIntent.putExtra(BundleKeys.INSTANCE.getKEY_NOTIFICATION_ID(), notificationId); + actualIntent.putExtra(BundleKeys.INSTANCE.getKEY_ROOM_TOKEN(), decryptedPushMessage.getId()); + PendingIntent replyPendingIntent = + PendingIntent.getBroadcast(getApplicationContext(), + notificationId, actualIntent, PendingIntent.FLAG_UPDATE_CURRENT); + + NotificationCompat.Action replyAction = + new NotificationCompat.Action.Builder(R.drawable.ic_reply, replyLabel, replyPendingIntent) + .setSemanticAction(NotificationCompat.Action.SEMANTIC_ACTION_REPLY) + .setShowsUserInterface(false) + // Allows system to generate replies by context of conversation. + // https://developer.android.com/reference/androidx/core/app/NotificationCompat.Action.Builder#setAllowGeneratedReplies(boolean) + // Good question is - do we really want it? + .setAllowGeneratedReplies(true) + .addRemoteInput(remoteInput) + .build(); + + notificationBuilder.addAction(replyAction); + } + private NotificationCompat.MessagingStyle getStyle(Person person, @Nullable NotificationCompat.MessagingStyle style) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { NotificationCompat.MessagingStyle newStyle = diff --git a/app/src/main/java/com/nextcloud/talk/receivers/DirectReplyReceiver.kt b/app/src/main/java/com/nextcloud/talk/receivers/DirectReplyReceiver.kt new file mode 100644 index 000000000..dd9eed6f4 --- /dev/null +++ b/app/src/main/java/com/nextcloud/talk/receivers/DirectReplyReceiver.kt @@ -0,0 +1,186 @@ +/* + * Nextcloud Talk application + * + * @author Dariusz Olszewski + * Copyright (C) 2022 Dariusz Olszewski + * + * 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 . + */ + +package com.nextcloud.talk.receivers + +import android.app.Notification +import android.app.NotificationManager +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import android.graphics.Bitmap +import android.os.Build +import androidx.annotation.RequiresApi +import androidx.core.app.NotificationCompat +import androidx.core.app.NotificationManagerCompat +import androidx.core.app.Person +import androidx.core.app.RemoteInput +import androidx.core.graphics.drawable.IconCompat +import autodagger.AutoInjector +import com.facebook.common.executors.UiThreadImmediateExecutorService +import com.facebook.common.references.CloseableReference +import com.facebook.datasource.DataSource +import com.facebook.drawee.backends.pipeline.Fresco +import com.facebook.imagepipeline.datasource.BaseBitmapDataSubscriber +import com.facebook.imagepipeline.image.CloseableImage +import com.facebook.imagepipeline.postprocessors.RoundAsCirclePostprocessor +import com.nextcloud.talk.api.NcApi +import com.nextcloud.talk.application.NextcloudTalkApplication +import com.nextcloud.talk.models.database.UserEntity +import com.nextcloud.talk.models.json.generic.GenericOverall +import com.nextcloud.talk.utils.ApiUtils +import com.nextcloud.talk.utils.DisplayUtils +import com.nextcloud.talk.utils.NotificationUtils +import com.nextcloud.talk.utils.bundle.BundleKeys.KEY_NOTIFICATION_ID +import com.nextcloud.talk.utils.bundle.BundleKeys.KEY_ROOM_TOKEN +import com.nextcloud.talk.utils.database.user.UserUtils +import io.reactivex.Observer +import io.reactivex.android.schedulers.AndroidSchedulers +import io.reactivex.disposables.Disposable +import io.reactivex.schedulers.Schedulers +import javax.inject.Inject + +@AutoInjector(NextcloudTalkApplication::class) +class DirectReplyReceiver : BroadcastReceiver() { + + @Inject + @JvmField + var userUtils: UserUtils? = null + + @Inject + @JvmField + var ncApi: NcApi? = null + + lateinit var context: Context + lateinit var currentUser: UserEntity + private var notificationId: Int? = null + private var roomToken: String? = null + private var replyMessage: CharSequence? = null + + init { + NextcloudTalkApplication.sharedApplication!!.componentApplication.inject(this) + } + + override fun onReceive(receiveContext: Context, intent: Intent?) { + context = receiveContext + currentUser = userUtils!!.currentUser!! + + // NOTE - This notificationId is an internal ID used on the device only. + // It is NOT the same as the notification ID used in communication with the server. + notificationId = intent!!.getIntExtra(KEY_NOTIFICATION_ID, 0) + roomToken = intent.getStringExtra(KEY_ROOM_TOKEN) + + replyMessage = getMessageText(intent) + sendDirectReply() + } + + private fun getMessageText(intent: Intent): CharSequence? { + return RemoteInput.getResultsFromIntent(intent)?.getCharSequence(NotificationUtils.KEY_DIRECT_REPLY) + } + + private fun sendDirectReply() { + val credentials = ApiUtils.getCredentials(currentUser.username, currentUser.token) + val apiVersion = ApiUtils.getChatApiVersion(currentUser, intArrayOf(1)) + val url = ApiUtils.getUrlForChat(apiVersion, currentUser.baseUrl, roomToken) + + ncApi!!.sendChatMessage(credentials, url, replyMessage, currentUser.displayName, null) + ?.subscribeOn(Schedulers.io()) + ?.observeOn(AndroidSchedulers.mainThread()) + ?.subscribe(object : Observer { + override fun onSubscribe(d: Disposable) { + // unused atm + } + + @RequiresApi(Build.VERSION_CODES.N) + override fun onNext(genericOverall: GenericOverall) { + loadAvatar(::confirmReplySent) + } + + override fun onError(e: Throwable) { + // TODO - inform the user that sending of the reply failed + // unused atm + } + + override fun onComplete() { + // unused atm + } + }) + } + + private fun loadAvatar(callback: (avatarIcon: IconCompat) -> Unit) { + val avatarUrl = ApiUtils.getUrlForAvatar(currentUser.baseUrl, currentUser.userId, false) + val imageRequest = DisplayUtils.getImageRequestForUrl(avatarUrl, currentUser) + val dataSource = Fresco.getImagePipeline().fetchDecodedImage(imageRequest, null) + dataSource.subscribe(object : BaseBitmapDataSubscriber() { + override fun onNewResultImpl(bitmap: Bitmap?) { + if (bitmap != null) { + RoundAsCirclePostprocessor(true).process(bitmap) + callback(IconCompat.createWithBitmap(bitmap)) + } + } + + override fun onFailureImpl(dataSource: DataSource>) { + // unused atm + } + }, UiThreadImmediateExecutorService.getInstance()) + } + + @RequiresApi(Build.VERSION_CODES.N) + private fun findActiveNotification(notificationId: Int): Notification? { + val notificationManager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager + return notificationManager.activeNotifications.find { it.id == notificationId }?.notification + } + + @RequiresApi(Build.VERSION_CODES.N) + private fun confirmReplySent(avatarIcon: IconCompat) { + // Implementation inspired by the SO question and article below: + // https://stackoverflow.com/questions/51549456/android-o-notification-for-direct-reply-message + // https://medium.com/@sidorovroman3/android-how-to-use-messagingstyle-for-notifications-without-caching-messages-c414ef2b816c + // + // Tries to follow "Best practices for messaging apps" described here: + // https://developer.android.com/training/notify-user/build-notification#messaging-best-practices + + // Find the original (active) notification + val previousNotification = findActiveNotification(notificationId!!) ?: return + + // Recreate builder based on the active notification + val previousBuilder = NotificationCompat.Builder(context, previousNotification) + + // Extract MessagingStyle from the active notification + val previousStyle = NotificationCompat.MessagingStyle + .extractMessagingStyleFromNotification(previousNotification) + + // Add reply + val me = Person.Builder() + .setName(currentUser.displayName) + // .setIcon(IconCompat.createWithResource(context, R.drawable.ic_user)) + .setIcon(avatarIcon) + .build() + val message = NotificationCompat.MessagingStyle.Message(replyMessage, System.currentTimeMillis(), me) + previousStyle?.addMessage(message) + + // Set the updated style + previousBuilder.setStyle(previousStyle) + + // Update the active notification. + NotificationManagerCompat.from(context).notify(notificationId!!, previousBuilder.build()) + } + +} diff --git a/app/src/main/java/com/nextcloud/talk/utils/NotificationUtils.kt b/app/src/main/java/com/nextcloud/talk/utils/NotificationUtils.kt index 61941f251..4400d8ad9 100644 --- a/app/src/main/java/com/nextcloud/talk/utils/NotificationUtils.kt +++ b/app/src/main/java/com/nextcloud/talk/utils/NotificationUtils.kt @@ -61,6 +61,9 @@ object NotificationUtils { const val DEFAULT_MESSAGE_RINGTONE_URI = "android.resource://" + BuildConfig.APPLICATION_ID + "/raw/librem_by_feandesign_message" + // RemoteInput key - used for replies sent directly from notification + const val KEY_DIRECT_REPLY = "key_direct_reply" + @TargetApi(Build.VERSION_CODES.O) private fun createNotificationChannel( context: Context,