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