From e8910964e20a8ccc11119bd6925e2cd5c00ffb1a Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Tue, 16 Jun 2026 12:49:10 -0400 Subject: [PATCH] feat(notifications): add Reply and Mark as Read actions to chat notifications Inline Reply and Mark as Read buttons on MessagingStyle chat notifications so users can respond or clear unread state directly from the notification shade without opening the app. - Add NotificationActionReceiver handling ACTION_REPLY and ACTION_MARK_READ - Add ChatCoordinator.markAsRead(chatId) convenience method - Add buildSelfPerson/toCircularBitmap shared helpers (NotificationPersons) - Resolve device owner profile photo for selfPerson via ContactsContract.Profile - Thread notification group key through action intents to preserve grouping - Add ic_reply and ic_mark_read vector drawables Signed-off-by: Brandon McAnsh --- .../flipcash/shared/chat/ChatCoordinator.kt | 10 + .../flipcash/app/contacts/ContactResolver.kt | 4 + .../contacts/device/DeviceContactLookup.kt | 1 + .../internal/AndroidDeviceContactLookup.kt | 14 ++ .../app/contacts/ContactResolverTest.kt | 1 + .../src/main/AndroidManifest.xml | 4 + .../NotificationActionReceiver.kt | 180 ++++++++++++++++++ .../app/notifications/NotificationPersons.kt | 53 ++++++ .../app/notifications/NotificationService.kt | 72 ++++--- .../src/main/res/drawable/ic_mark_read.xml | 10 + .../src/main/res/drawable/ic_reply.xml | 10 + .../src/main/res/values/strings.xml | 4 + 12 files changed, 338 insertions(+), 25 deletions(-) create mode 100644 apps/flipcash/shared/notifications/src/main/kotlin/com/flipcash/app/notifications/NotificationActionReceiver.kt create mode 100644 apps/flipcash/shared/notifications/src/main/kotlin/com/flipcash/app/notifications/NotificationPersons.kt create mode 100644 apps/flipcash/shared/notifications/src/main/res/drawable/ic_mark_read.xml create mode 100644 apps/flipcash/shared/notifications/src/main/res/drawable/ic_reply.xml diff --git a/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/ChatCoordinator.kt b/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/ChatCoordinator.kt index 9ebd48d35..cdfbeec32 100644 --- a/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/ChatCoordinator.kt +++ b/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/ChatCoordinator.kt @@ -285,6 +285,16 @@ class ChatCoordinator @Inject constructor( notificationManager.cancel(chatId.hashCode()) } + suspend fun markAsRead(chatId: ChatId): Result { + val messageId = state.value.feed + .firstOrNull { it.chatId == chatId } + ?.lastMessage?.messageId + ?: messageDataSource.getLatestMessageId(chatId) + ?: return Result.success(Unit) + return advanceReadPointer(chatId, messageId) + .also { dismissNotifications(chatId) } + } + suspend fun notifyTyping(chatId: ChatId, typingState: TypingState): Result { return messagingController.notifyIsTyping(chatId, typingState) } diff --git a/apps/flipcash/shared/contacts/src/main/kotlin/com/flipcash/app/contacts/ContactResolver.kt b/apps/flipcash/shared/contacts/src/main/kotlin/com/flipcash/app/contacts/ContactResolver.kt index cb4c35fd6..20a18edbc 100644 --- a/apps/flipcash/shared/contacts/src/main/kotlin/com/flipcash/app/contacts/ContactResolver.kt +++ b/apps/flipcash/shared/contacts/src/main/kotlin/com/flipcash/app/contacts/ContactResolver.kt @@ -36,4 +36,8 @@ class ContactResolver @Inject constructor( fun resolvePhotoBytes(e164: String): ByteArray? = deviceContactLookup.lookupPhotoBytes(e164) + + fun resolveOwnPhotoBytes(ownE164: String?): ByteArray? = + deviceContactLookup.lookupProfilePhoto() + ?: ownE164?.let { deviceContactLookup.lookupPhotoBytes(it) } } \ No newline at end of file diff --git a/apps/flipcash/shared/contacts/src/main/kotlin/com/flipcash/app/contacts/device/DeviceContactLookup.kt b/apps/flipcash/shared/contacts/src/main/kotlin/com/flipcash/app/contacts/device/DeviceContactLookup.kt index 346be554d..f55af5330 100644 --- a/apps/flipcash/shared/contacts/src/main/kotlin/com/flipcash/app/contacts/device/DeviceContactLookup.kt +++ b/apps/flipcash/shared/contacts/src/main/kotlin/com/flipcash/app/contacts/device/DeviceContactLookup.kt @@ -4,4 +4,5 @@ interface DeviceContactLookup { fun lookupDisplayName(e164: String): String? fun lookupPhotoUri(e164: String): String? fun lookupPhotoBytes(e164: String): ByteArray? + fun lookupProfilePhoto(): ByteArray? } \ No newline at end of file diff --git a/apps/flipcash/shared/contacts/src/main/kotlin/com/flipcash/app/contacts/device/internal/AndroidDeviceContactLookup.kt b/apps/flipcash/shared/contacts/src/main/kotlin/com/flipcash/app/contacts/device/internal/AndroidDeviceContactLookup.kt index 0e677fc3a..0d120fc28 100644 --- a/apps/flipcash/shared/contacts/src/main/kotlin/com/flipcash/app/contacts/device/internal/AndroidDeviceContactLookup.kt +++ b/apps/flipcash/shared/contacts/src/main/kotlin/com/flipcash/app/contacts/device/internal/AndroidDeviceContactLookup.kt @@ -36,6 +36,20 @@ internal class AndroidDeviceContactLookup @Inject constructor( } } + override fun lookupProfilePhoto(): ByteArray? { + if (ContextCompat.checkSelfPermission(context, Manifest.permission.READ_CONTACTS) + != PackageManager.PERMISSION_GRANTED + ) return null + + return try { + ContactsContract.Contacts.openContactPhotoInputStream( + context.contentResolver, ContactsContract.Profile.CONTENT_URI, true + )?.use { it.readBytes() } + } catch (e: Exception) { + null + } + } + private fun lookupContactId(e164: String): Long? = lookupField(e164, ContactsContract.PhoneLookup._ID)?.toLongOrNull() diff --git a/apps/flipcash/shared/contacts/src/test/kotlin/com/flipcash/app/contacts/ContactResolverTest.kt b/apps/flipcash/shared/contacts/src/test/kotlin/com/flipcash/app/contacts/ContactResolverTest.kt index fac3d2435..546f63f5b 100644 --- a/apps/flipcash/shared/contacts/src/test/kotlin/com/flipcash/app/contacts/ContactResolverTest.kt +++ b/apps/flipcash/shared/contacts/src/test/kotlin/com/flipcash/app/contacts/ContactResolverTest.kt @@ -33,6 +33,7 @@ class ContactResolverTest { every { lookupDisplayName(e164) } returns deviceName every { lookupPhotoUri(e164) } returns devicePhotoUri every { lookupPhotoBytes(e164) } returns null + every { lookupProfilePhoto() } returns null } val phoneUtils = mockk { if (formatThrows) { diff --git a/apps/flipcash/shared/notifications/src/main/AndroidManifest.xml b/apps/flipcash/shared/notifications/src/main/AndroidManifest.xml index 5ce8b6ce0..7ebc58a8d 100644 --- a/apps/flipcash/shared/notifications/src/main/AndroidManifest.xml +++ b/apps/flipcash/shared/notifications/src/main/AndroidManifest.xml @@ -11,6 +11,10 @@ + + diff --git a/apps/flipcash/shared/notifications/src/main/kotlin/com/flipcash/app/notifications/NotificationActionReceiver.kt b/apps/flipcash/shared/notifications/src/main/kotlin/com/flipcash/app/notifications/NotificationActionReceiver.kt new file mode 100644 index 000000000..0c2933d5d --- /dev/null +++ b/apps/flipcash/shared/notifications/src/main/kotlin/com/flipcash/app/notifications/NotificationActionReceiver.kt @@ -0,0 +1,180 @@ +package com.flipcash.app.notifications + +import android.Manifest +import android.app.PendingIntent +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import android.content.pm.PackageManager +import android.os.Build +import androidx.core.app.ActivityCompat +import androidx.core.app.NotificationCompat +import androidx.core.app.NotificationManagerCompat +import androidx.core.app.RemoteInput +import com.flipcash.app.auth.AuthManager +import com.flipcash.app.contacts.ContactResolver +import com.flipcash.shared.chat.ChatCoordinator +import com.flipcash.services.models.chat.ChatId +import com.flipcash.services.user.UserManager +import com.flipcash.shared.notifications.R +import com.getcode.utils.TraceType +import com.getcode.utils.trace +import dagger.hilt.android.AndroidEntryPoint +import kotlinx.coroutines.GlobalScope +import kotlinx.coroutines.launch +import javax.inject.Inject + +@AndroidEntryPoint +class NotificationActionReceiver : BroadcastReceiver() { + + companion object { + const val ACTION_REPLY = "com.flipcash.app.notifications.ACTION_REPLY" + const val ACTION_MARK_READ = "com.flipcash.app.notifications.ACTION_MARK_READ" + const val KEY_CHAT_ID_HEX = "chat_id_hex" + const val KEY_NOTIFICATION_ID = "notification_id" + const val KEY_GROUP_KEY = "group_key" + const val KEY_TEXT_REPLY = "key_text_reply" + } + + @Inject lateinit var authManager: AuthManager + @Inject lateinit var userManager: UserManager + @Inject lateinit var chatCoordinator: ChatCoordinator + @Inject lateinit var contactResolver: ContactResolver + @Inject lateinit var notificationManager: NotificationManagerCompat + + override fun onReceive(context: Context, intent: Intent) { + val chatIdHex = intent.getStringExtra(KEY_CHAT_ID_HEX) ?: return + val chatId = ChatId(chatIdHex) + val pendingResult = goAsync() + + authenticateIfNeeded { + GlobalScope.launch { + try { + when (intent.action) { + ACTION_REPLY -> handleReply(context, intent, chatId) + ACTION_MARK_READ -> handleMarkAsRead(chatId) + } + } catch (e: Exception) { + trace(tag = "NotificationActionReceiver", message = "Action failed: ${e.message}", type = TraceType.Error) + } finally { + pendingResult.finish() + } + } + } + } + + @OptIn(ExperimentalStdlibApi::class) + private suspend fun handleReply(context: Context, intent: Intent, chatId: ChatId) { + val replyText = RemoteInput.getResultsFromIntent(intent) + ?.getCharSequence(KEY_TEXT_REPLY) + ?.toString() + ?: return + + val groupKey = intent.getStringExtra(KEY_GROUP_KEY) + + chatCoordinator.sendMessage(chatId, replyText) + .onSuccess { + val notificationId = intent.getIntExtra(KEY_NOTIFICATION_ID, chatId.hashCode()) + + val selfPerson = buildSelfPerson(context, userManager.profile, contactResolver) + + val existingStyle = notificationManager.activeNotifications + .firstOrNull { it.id == notificationId } + ?.notification + ?.let { NotificationCompat.MessagingStyle.extractMessagingStyleFromNotification(it) } + ?: NotificationCompat.MessagingStyle(selfPerson) + + existingStyle.addMessage(replyText, System.currentTimeMillis(), selfPerson) + + val channelId = notificationManager.activeNotifications + .firstOrNull { it.id == notificationId } + ?.notification?.channelId + ?: NotificationChannels.CHANNEL_CHAT + + val updated = NotificationCompat.Builder(context, channelId) + .setSmallIcon(R.drawable.flipcash_logo) + .setColor(context.getColor(R.color.notification_color)) + .setStyle(existingStyle) + .setGroupAlertBehavior(NotificationCompat.GROUP_ALERT_CHILDREN) + .addAction(buildReplyAction(context, chatId, notificationId, groupKey)) + .addAction(buildMarkAsReadAction(context, chatId, groupKey)) + .setAutoCancel(true) + .apply { + if (groupKey != null) setGroup(groupKey) + } + .build() + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU && + ActivityCompat.checkSelfPermission(context, Manifest.permission.POST_NOTIFICATIONS) + != PackageManager.PERMISSION_GRANTED + ) return@onSuccess + + notificationManager.notify(notificationId, updated) + } + .onFailure { + trace(tag = "NotificationActionReceiver", message = "Reply send failed: ${it.message}", type = TraceType.Error) + } + } + + private suspend fun handleMarkAsRead(chatId: ChatId) { + chatCoordinator.markAsRead(chatId) + } + + private fun authenticateIfNeeded(block: () -> Unit) { + if (userManager.accountCluster == null) { + authManager.init { block() } + } else { + block() + } + } + + @OptIn(ExperimentalStdlibApi::class) + private fun buildReplyAction(context: Context, chatId: ChatId, notificationId: Int, groupKey: String?): NotificationCompat.Action { + val remoteInput = RemoteInput.Builder(KEY_TEXT_REPLY) + .setLabel(context.getString(R.string.notification_action_reply)) + .build() + + val intent = Intent(context, NotificationActionReceiver::class.java).apply { + action = ACTION_REPLY + putExtra(KEY_CHAT_ID_HEX, chatId.bytes.toHexString()) + putExtra(KEY_NOTIFICATION_ID, notificationId) + if (groupKey != null) putExtra(KEY_GROUP_KEY, groupKey) + } + + val pendingIntent = PendingIntent.getBroadcast( + context, + chatId.hashCode(), + intent, + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_MUTABLE, + ) + + return NotificationCompat.Action.Builder( + R.drawable.ic_reply, + context.getString(R.string.notification_action_reply), + pendingIntent, + ).addRemoteInput(remoteInput).build() + } + + @OptIn(ExperimentalStdlibApi::class) + private fun buildMarkAsReadAction(context: Context, chatId: ChatId, groupKey: String?): NotificationCompat.Action { + val intent = Intent(context, NotificationActionReceiver::class.java).apply { + action = ACTION_MARK_READ + putExtra(KEY_CHAT_ID_HEX, chatId.bytes.toHexString()) + if (groupKey != null) putExtra(KEY_GROUP_KEY, groupKey) + } + + val pendingIntent = PendingIntent.getBroadcast( + context, + chatId.hashCode() + 1, + intent, + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE, + ) + + return NotificationCompat.Action.Builder( + R.drawable.ic_mark_read, + context.getString(R.string.notification_action_mark_as_read), + pendingIntent, + ).build() + } + +} diff --git a/apps/flipcash/shared/notifications/src/main/kotlin/com/flipcash/app/notifications/NotificationPersons.kt b/apps/flipcash/shared/notifications/src/main/kotlin/com/flipcash/app/notifications/NotificationPersons.kt new file mode 100644 index 000000000..a896e58ad --- /dev/null +++ b/apps/flipcash/shared/notifications/src/main/kotlin/com/flipcash/app/notifications/NotificationPersons.kt @@ -0,0 +1,53 @@ +package com.flipcash.app.notifications + +import android.content.Context +import android.graphics.Bitmap +import android.graphics.BitmapFactory +import android.graphics.Canvas +import android.graphics.Paint +import android.graphics.PorterDuff +import android.graphics.PorterDuffXfermode +import androidx.core.app.Person +import androidx.core.graphics.drawable.IconCompat +import com.flipcash.app.contacts.ContactResolver +import com.flipcash.services.models.UserProfile +import com.flipcash.shared.notifications.R +import androidx.core.graphics.createBitmap + +internal fun Bitmap.toCircularBitmap(): Bitmap { + val size = minOf(width, height) + val xOffset = (width - size) / 2 + val yOffset = (height - size) / 2 + + val output = createBitmap(size, size) + val canvas = Canvas(output) + + val paint = Paint(Paint.ANTI_ALIAS_FLAG) + val half = size / 2f + canvas.drawCircle(half, half, half, paint) + + paint.xfermode = PorterDuffXfermode(PorterDuff.Mode.SRC_IN) + canvas.drawBitmap(this, -xOffset.toFloat(), -yOffset.toFloat(), paint) + + return output +} + +internal fun buildSelfPerson( + context: Context, + profile: UserProfile?, + contactResolver: ContactResolver, +): Person { + val selfPhoto = contactResolver.resolveOwnPhotoBytes(profile?.verifiedPhoneNumber) + ?.let { BitmapFactory.decodeByteArray(it, 0, it.size) } + + return Person.Builder() + .setName( + profile?.displayName?.takeIf { it.isNotEmpty() } + ?: context.getString(R.string.notification_self_person_name) + ) + .setKey("self") + .apply { + if (selfPhoto != null) setIcon(IconCompat.createWithBitmap(selfPhoto.toCircularBitmap())) + } + .build() +} diff --git a/apps/flipcash/shared/notifications/src/main/kotlin/com/flipcash/app/notifications/NotificationService.kt b/apps/flipcash/shared/notifications/src/main/kotlin/com/flipcash/app/notifications/NotificationService.kt index 6755dabd5..73be1b469 100644 --- a/apps/flipcash/shared/notifications/src/main/kotlin/com/flipcash/app/notifications/NotificationService.kt +++ b/apps/flipcash/shared/notifications/src/main/kotlin/com/flipcash/app/notifications/NotificationService.kt @@ -4,15 +4,12 @@ import android.Manifest import android.app.PendingIntent import android.content.Context import android.content.Intent +import androidx.core.app.RemoteInput import android.content.pm.PackageManager import android.os.Build import android.graphics.Bitmap import android.graphics.BitmapFactory -import android.graphics.Canvas import android.graphics.ImageDecoder -import android.graphics.Paint -import android.graphics.PorterDuff -import android.graphics.PorterDuffXfermode import android.media.RingtoneManager import androidx.core.app.ActivityCompat import androidx.core.app.NotificationCompat @@ -195,15 +192,7 @@ class NotificationService : FirebaseMessagingService(), val contactPhoto = e164?.let { resolveContactPhoto(it) } val senderName = e164?.let { contactResolver.resolveName(it) } ?: "" - val profile = userManager.profile - val selfPerson = Person.Builder() - .setName( - profile?.displayName?.takeIf { it.isNotEmpty() } - ?: profile?.verifiedPhoneNumber?.takeIf { it.isNotEmpty() } - ?: getString(R.string.notification_self_person_name) - ) - .setKey("self") - .build() + val selfPerson = buildSelfPerson(this@NotificationService, userManager.profile, contactResolver) val senderPerson = Person.Builder() .setName(senderName) @@ -223,6 +212,8 @@ class NotificationService : FirebaseMessagingService(), setStyle(style) .setGroupAlertBehavior(NotificationCompat.GROUP_ALERT_CHILDREN) + .addAction(buildReplyAction(chatId, notificationId, groupKey)) + .addAction(buildMarkAsReadAction(chatId, groupKey)) return notificationId } @@ -270,22 +261,53 @@ class NotificationService : FirebaseMessagingService(), } } - private fun Bitmap.toCircularBitmap(): Bitmap { - val size = minOf(width, height) - val xOffset = (width - size) / 2 - val yOffset = (height - size) / 2 + @OptIn(ExperimentalStdlibApi::class) + private fun buildReplyAction(chatId: ChatId, notificationId: Int, groupKey: String?): NotificationCompat.Action { + val remoteInput = RemoteInput.Builder(NotificationActionReceiver.KEY_TEXT_REPLY) + .setLabel(getString(R.string.notification_action_reply)) + .build() - val output = Bitmap.createBitmap(size, size, Bitmap.Config.ARGB_8888) - val canvas = Canvas(output) + val intent = Intent(this, NotificationActionReceiver::class.java).apply { + action = NotificationActionReceiver.ACTION_REPLY + putExtra(NotificationActionReceiver.KEY_CHAT_ID_HEX, chatId.bytes.toHexString()) + putExtra(NotificationActionReceiver.KEY_NOTIFICATION_ID, notificationId) + if (groupKey != null) putExtra(NotificationActionReceiver.KEY_GROUP_KEY, groupKey) + } - val paint = Paint(Paint.ANTI_ALIAS_FLAG) - val half = size / 2f - canvas.drawCircle(half, half, half, paint) + val pendingIntent = PendingIntent.getBroadcast( + this, + chatId.hashCode(), + intent, + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_MUTABLE, + ) - paint.xfermode = PorterDuffXfermode(PorterDuff.Mode.SRC_IN) - canvas.drawBitmap(this, -xOffset.toFloat(), -yOffset.toFloat(), paint) + return NotificationCompat.Action.Builder( + R.drawable.ic_reply, + getString(R.string.notification_action_reply), + pendingIntent, + ).addRemoteInput(remoteInput).build() + } + + @OptIn(ExperimentalStdlibApi::class) + private fun buildMarkAsReadAction(chatId: ChatId, groupKey: String?): NotificationCompat.Action { + val intent = Intent(this, NotificationActionReceiver::class.java).apply { + action = NotificationActionReceiver.ACTION_MARK_READ + putExtra(NotificationActionReceiver.KEY_CHAT_ID_HEX, chatId.bytes.toHexString()) + if (groupKey != null) putExtra(NotificationActionReceiver.KEY_GROUP_KEY, groupKey) + } + + val pendingIntent = PendingIntent.getBroadcast( + this, + chatId.hashCode() + 1, + intent, + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE, + ) - return output + return NotificationCompat.Action.Builder( + R.drawable.ic_mark_read, + getString(R.string.notification_action_mark_as_read), + pendingIntent, + ).build() } internal fun Context.buildContentIntent(navigation: NavigationTrigger?): PendingIntent { diff --git a/apps/flipcash/shared/notifications/src/main/res/drawable/ic_mark_read.xml b/apps/flipcash/shared/notifications/src/main/res/drawable/ic_mark_read.xml new file mode 100644 index 000000000..3d7959699 --- /dev/null +++ b/apps/flipcash/shared/notifications/src/main/res/drawable/ic_mark_read.xml @@ -0,0 +1,10 @@ + + + diff --git a/apps/flipcash/shared/notifications/src/main/res/drawable/ic_reply.xml b/apps/flipcash/shared/notifications/src/main/res/drawable/ic_reply.xml new file mode 100644 index 000000000..3bedb940c --- /dev/null +++ b/apps/flipcash/shared/notifications/src/main/res/drawable/ic_reply.xml @@ -0,0 +1,10 @@ + + + diff --git a/apps/flipcash/shared/notifications/src/main/res/values/strings.xml b/apps/flipcash/shared/notifications/src/main/res/values/strings.xml index a431e9b47..ef0e23adc 100644 --- a/apps/flipcash/shared/notifications/src/main/res/values/strings.xml +++ b/apps/flipcash/shared/notifications/src/main/res/values/strings.xml @@ -19,6 +19,10 @@ Me + + Reply + Mark as read + Transactions Social