Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -285,6 +285,16 @@ class ChatCoordinator @Inject constructor(
notificationManager.cancel(chatId.hashCode())
}

suspend fun markAsRead(chatId: ChatId): Result<Unit> {
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<Unit> {
return messagingController.notifyIsTyping(chatId, typingState)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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) }
}
Original file line number Diff line number Diff line change
Expand Up @@ -4,4 +4,5 @@ interface DeviceContactLookup {
fun lookupDisplayName(e164: String): String?
fun lookupPhotoUri(e164: String): String?
fun lookupPhotoBytes(e164: String): ByteArray?
fun lookupProfilePhoto(): ByteArray?
}
Original file line number Diff line number Diff line change
Expand Up @@ -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()

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<PhoneUtils> {
if (formatThrows) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,10 @@
</intent-filter>
</service>

<receiver
android:name="com.flipcash.app.notifications.NotificationActionReceiver"
android:exported="false" />

<meta-data
android:name="com.google.firebase.messaging.default_notification_icon"
android:resource="@drawable/flipcash_logo" />
Expand Down
Original file line number Diff line number Diff line change
@@ -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()
}

}
Original file line number Diff line number Diff line change
@@ -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()
}
Loading
Loading