Skip to content

Commit e891096

Browse files
committed
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 <git@bmcreations.dev>
1 parent ab85c2b commit e891096

12 files changed

Lines changed: 338 additions & 25 deletions

File tree

apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/ChatCoordinator.kt

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -285,6 +285,16 @@ class ChatCoordinator @Inject constructor(
285285
notificationManager.cancel(chatId.hashCode())
286286
}
287287

288+
suspend fun markAsRead(chatId: ChatId): Result<Unit> {
289+
val messageId = state.value.feed
290+
.firstOrNull { it.chatId == chatId }
291+
?.lastMessage?.messageId
292+
?: messageDataSource.getLatestMessageId(chatId)
293+
?: return Result.success(Unit)
294+
return advanceReadPointer(chatId, messageId)
295+
.also { dismissNotifications(chatId) }
296+
}
297+
288298
suspend fun notifyTyping(chatId: ChatId, typingState: TypingState): Result<Unit> {
289299
return messagingController.notifyIsTyping(chatId, typingState)
290300
}

apps/flipcash/shared/contacts/src/main/kotlin/com/flipcash/app/contacts/ContactResolver.kt

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,4 +36,8 @@ class ContactResolver @Inject constructor(
3636

3737
fun resolvePhotoBytes(e164: String): ByteArray? =
3838
deviceContactLookup.lookupPhotoBytes(e164)
39+
40+
fun resolveOwnPhotoBytes(ownE164: String?): ByteArray? =
41+
deviceContactLookup.lookupProfilePhoto()
42+
?: ownE164?.let { deviceContactLookup.lookupPhotoBytes(it) }
3943
}

apps/flipcash/shared/contacts/src/main/kotlin/com/flipcash/app/contacts/device/DeviceContactLookup.kt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,4 +4,5 @@ interface DeviceContactLookup {
44
fun lookupDisplayName(e164: String): String?
55
fun lookupPhotoUri(e164: String): String?
66
fun lookupPhotoBytes(e164: String): ByteArray?
7+
fun lookupProfilePhoto(): ByteArray?
78
}

apps/flipcash/shared/contacts/src/main/kotlin/com/flipcash/app/contacts/device/internal/AndroidDeviceContactLookup.kt

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,20 @@ internal class AndroidDeviceContactLookup @Inject constructor(
3636
}
3737
}
3838

39+
override fun lookupProfilePhoto(): ByteArray? {
40+
if (ContextCompat.checkSelfPermission(context, Manifest.permission.READ_CONTACTS)
41+
!= PackageManager.PERMISSION_GRANTED
42+
) return null
43+
44+
return try {
45+
ContactsContract.Contacts.openContactPhotoInputStream(
46+
context.contentResolver, ContactsContract.Profile.CONTENT_URI, true
47+
)?.use { it.readBytes() }
48+
} catch (e: Exception) {
49+
null
50+
}
51+
}
52+
3953
private fun lookupContactId(e164: String): Long? =
4054
lookupField(e164, ContactsContract.PhoneLookup._ID)?.toLongOrNull()
4155

apps/flipcash/shared/contacts/src/test/kotlin/com/flipcash/app/contacts/ContactResolverTest.kt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ class ContactResolverTest {
3333
every { lookupDisplayName(e164) } returns deviceName
3434
every { lookupPhotoUri(e164) } returns devicePhotoUri
3535
every { lookupPhotoBytes(e164) } returns null
36+
every { lookupProfilePhoto() } returns null
3637
}
3738
val phoneUtils = mockk<PhoneUtils> {
3839
if (formatThrows) {

apps/flipcash/shared/notifications/src/main/AndroidManifest.xml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,10 @@
1111
</intent-filter>
1212
</service>
1313

14+
<receiver
15+
android:name="com.flipcash.app.notifications.NotificationActionReceiver"
16+
android:exported="false" />
17+
1418
<meta-data
1519
android:name="com.google.firebase.messaging.default_notification_icon"
1620
android:resource="@drawable/flipcash_logo" />
Lines changed: 180 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,180 @@
1+
package com.flipcash.app.notifications
2+
3+
import android.Manifest
4+
import android.app.PendingIntent
5+
import android.content.BroadcastReceiver
6+
import android.content.Context
7+
import android.content.Intent
8+
import android.content.pm.PackageManager
9+
import android.os.Build
10+
import androidx.core.app.ActivityCompat
11+
import androidx.core.app.NotificationCompat
12+
import androidx.core.app.NotificationManagerCompat
13+
import androidx.core.app.RemoteInput
14+
import com.flipcash.app.auth.AuthManager
15+
import com.flipcash.app.contacts.ContactResolver
16+
import com.flipcash.shared.chat.ChatCoordinator
17+
import com.flipcash.services.models.chat.ChatId
18+
import com.flipcash.services.user.UserManager
19+
import com.flipcash.shared.notifications.R
20+
import com.getcode.utils.TraceType
21+
import com.getcode.utils.trace
22+
import dagger.hilt.android.AndroidEntryPoint
23+
import kotlinx.coroutines.GlobalScope
24+
import kotlinx.coroutines.launch
25+
import javax.inject.Inject
26+
27+
@AndroidEntryPoint
28+
class NotificationActionReceiver : BroadcastReceiver() {
29+
30+
companion object {
31+
const val ACTION_REPLY = "com.flipcash.app.notifications.ACTION_REPLY"
32+
const val ACTION_MARK_READ = "com.flipcash.app.notifications.ACTION_MARK_READ"
33+
const val KEY_CHAT_ID_HEX = "chat_id_hex"
34+
const val KEY_NOTIFICATION_ID = "notification_id"
35+
const val KEY_GROUP_KEY = "group_key"
36+
const val KEY_TEXT_REPLY = "key_text_reply"
37+
}
38+
39+
@Inject lateinit var authManager: AuthManager
40+
@Inject lateinit var userManager: UserManager
41+
@Inject lateinit var chatCoordinator: ChatCoordinator
42+
@Inject lateinit var contactResolver: ContactResolver
43+
@Inject lateinit var notificationManager: NotificationManagerCompat
44+
45+
override fun onReceive(context: Context, intent: Intent) {
46+
val chatIdHex = intent.getStringExtra(KEY_CHAT_ID_HEX) ?: return
47+
val chatId = ChatId(chatIdHex)
48+
val pendingResult = goAsync()
49+
50+
authenticateIfNeeded {
51+
GlobalScope.launch {
52+
try {
53+
when (intent.action) {
54+
ACTION_REPLY -> handleReply(context, intent, chatId)
55+
ACTION_MARK_READ -> handleMarkAsRead(chatId)
56+
}
57+
} catch (e: Exception) {
58+
trace(tag = "NotificationActionReceiver", message = "Action failed: ${e.message}", type = TraceType.Error)
59+
} finally {
60+
pendingResult.finish()
61+
}
62+
}
63+
}
64+
}
65+
66+
@OptIn(ExperimentalStdlibApi::class)
67+
private suspend fun handleReply(context: Context, intent: Intent, chatId: ChatId) {
68+
val replyText = RemoteInput.getResultsFromIntent(intent)
69+
?.getCharSequence(KEY_TEXT_REPLY)
70+
?.toString()
71+
?: return
72+
73+
val groupKey = intent.getStringExtra(KEY_GROUP_KEY)
74+
75+
chatCoordinator.sendMessage(chatId, replyText)
76+
.onSuccess {
77+
val notificationId = intent.getIntExtra(KEY_NOTIFICATION_ID, chatId.hashCode())
78+
79+
val selfPerson = buildSelfPerson(context, userManager.profile, contactResolver)
80+
81+
val existingStyle = notificationManager.activeNotifications
82+
.firstOrNull { it.id == notificationId }
83+
?.notification
84+
?.let { NotificationCompat.MessagingStyle.extractMessagingStyleFromNotification(it) }
85+
?: NotificationCompat.MessagingStyle(selfPerson)
86+
87+
existingStyle.addMessage(replyText, System.currentTimeMillis(), selfPerson)
88+
89+
val channelId = notificationManager.activeNotifications
90+
.firstOrNull { it.id == notificationId }
91+
?.notification?.channelId
92+
?: NotificationChannels.CHANNEL_CHAT
93+
94+
val updated = NotificationCompat.Builder(context, channelId)
95+
.setSmallIcon(R.drawable.flipcash_logo)
96+
.setColor(context.getColor(R.color.notification_color))
97+
.setStyle(existingStyle)
98+
.setGroupAlertBehavior(NotificationCompat.GROUP_ALERT_CHILDREN)
99+
.addAction(buildReplyAction(context, chatId, notificationId, groupKey))
100+
.addAction(buildMarkAsReadAction(context, chatId, groupKey))
101+
.setAutoCancel(true)
102+
.apply {
103+
if (groupKey != null) setGroup(groupKey)
104+
}
105+
.build()
106+
107+
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU &&
108+
ActivityCompat.checkSelfPermission(context, Manifest.permission.POST_NOTIFICATIONS)
109+
!= PackageManager.PERMISSION_GRANTED
110+
) return@onSuccess
111+
112+
notificationManager.notify(notificationId, updated)
113+
}
114+
.onFailure {
115+
trace(tag = "NotificationActionReceiver", message = "Reply send failed: ${it.message}", type = TraceType.Error)
116+
}
117+
}
118+
119+
private suspend fun handleMarkAsRead(chatId: ChatId) {
120+
chatCoordinator.markAsRead(chatId)
121+
}
122+
123+
private fun authenticateIfNeeded(block: () -> Unit) {
124+
if (userManager.accountCluster == null) {
125+
authManager.init { block() }
126+
} else {
127+
block()
128+
}
129+
}
130+
131+
@OptIn(ExperimentalStdlibApi::class)
132+
private fun buildReplyAction(context: Context, chatId: ChatId, notificationId: Int, groupKey: String?): NotificationCompat.Action {
133+
val remoteInput = RemoteInput.Builder(KEY_TEXT_REPLY)
134+
.setLabel(context.getString(R.string.notification_action_reply))
135+
.build()
136+
137+
val intent = Intent(context, NotificationActionReceiver::class.java).apply {
138+
action = ACTION_REPLY
139+
putExtra(KEY_CHAT_ID_HEX, chatId.bytes.toHexString())
140+
putExtra(KEY_NOTIFICATION_ID, notificationId)
141+
if (groupKey != null) putExtra(KEY_GROUP_KEY, groupKey)
142+
}
143+
144+
val pendingIntent = PendingIntent.getBroadcast(
145+
context,
146+
chatId.hashCode(),
147+
intent,
148+
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_MUTABLE,
149+
)
150+
151+
return NotificationCompat.Action.Builder(
152+
R.drawable.ic_reply,
153+
context.getString(R.string.notification_action_reply),
154+
pendingIntent,
155+
).addRemoteInput(remoteInput).build()
156+
}
157+
158+
@OptIn(ExperimentalStdlibApi::class)
159+
private fun buildMarkAsReadAction(context: Context, chatId: ChatId, groupKey: String?): NotificationCompat.Action {
160+
val intent = Intent(context, NotificationActionReceiver::class.java).apply {
161+
action = ACTION_MARK_READ
162+
putExtra(KEY_CHAT_ID_HEX, chatId.bytes.toHexString())
163+
if (groupKey != null) putExtra(KEY_GROUP_KEY, groupKey)
164+
}
165+
166+
val pendingIntent = PendingIntent.getBroadcast(
167+
context,
168+
chatId.hashCode() + 1,
169+
intent,
170+
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
171+
)
172+
173+
return NotificationCompat.Action.Builder(
174+
R.drawable.ic_mark_read,
175+
context.getString(R.string.notification_action_mark_as_read),
176+
pendingIntent,
177+
).build()
178+
}
179+
180+
}
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
package com.flipcash.app.notifications
2+
3+
import android.content.Context
4+
import android.graphics.Bitmap
5+
import android.graphics.BitmapFactory
6+
import android.graphics.Canvas
7+
import android.graphics.Paint
8+
import android.graphics.PorterDuff
9+
import android.graphics.PorterDuffXfermode
10+
import androidx.core.app.Person
11+
import androidx.core.graphics.drawable.IconCompat
12+
import com.flipcash.app.contacts.ContactResolver
13+
import com.flipcash.services.models.UserProfile
14+
import com.flipcash.shared.notifications.R
15+
import androidx.core.graphics.createBitmap
16+
17+
internal fun Bitmap.toCircularBitmap(): Bitmap {
18+
val size = minOf(width, height)
19+
val xOffset = (width - size) / 2
20+
val yOffset = (height - size) / 2
21+
22+
val output = createBitmap(size, size)
23+
val canvas = Canvas(output)
24+
25+
val paint = Paint(Paint.ANTI_ALIAS_FLAG)
26+
val half = size / 2f
27+
canvas.drawCircle(half, half, half, paint)
28+
29+
paint.xfermode = PorterDuffXfermode(PorterDuff.Mode.SRC_IN)
30+
canvas.drawBitmap(this, -xOffset.toFloat(), -yOffset.toFloat(), paint)
31+
32+
return output
33+
}
34+
35+
internal fun buildSelfPerson(
36+
context: Context,
37+
profile: UserProfile?,
38+
contactResolver: ContactResolver,
39+
): Person {
40+
val selfPhoto = contactResolver.resolveOwnPhotoBytes(profile?.verifiedPhoneNumber)
41+
?.let { BitmapFactory.decodeByteArray(it, 0, it.size) }
42+
43+
return Person.Builder()
44+
.setName(
45+
profile?.displayName?.takeIf { it.isNotEmpty() }
46+
?: context.getString(R.string.notification_self_person_name)
47+
)
48+
.setKey("self")
49+
.apply {
50+
if (selfPhoto != null) setIcon(IconCompat.createWithBitmap(selfPhoto.toCircularBitmap()))
51+
}
52+
.build()
53+
}

0 commit comments

Comments
 (0)