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
2 changes: 2 additions & 0 deletions apps/flipcash/shared/notifications/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@ android {

dependencies {
implementation(project(":apps:flipcash:shared:authentication"))
implementation(project(":apps:flipcash:shared:persistence:sources"))
implementation(project(":apps:flipcash:shared:phone"))
implementation(project(":apps:flipcash:shared:push"))
implementation(project(":apps:flipcash:shared:tokens"))
implementation(project(":services:flipcash"))
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,11 +12,14 @@ import androidx.core.app.NotificationManagerCompat
import androidx.core.net.toUri
import com.flipcash.app.auth.AuthManager
import com.flipcash.app.core.util.Linkify
import com.flipcash.app.persistence.sources.ContactDataSource
import com.flipcash.app.phone.PhoneUtils
import com.flipcash.app.tokens.TokenCoordinator
import com.flipcash.services.controllers.PushController
import com.flipcash.services.models.NavigationTrigger
import com.flipcash.services.models.NotificationCategory
import com.flipcash.services.models.NotificationPayload
import com.flipcash.services.models.Substitution
import com.flipcash.services.user.UserManager
import com.flipcash.shared.notifications.R
import com.getcode.utils.TraceType
Expand All @@ -34,6 +37,12 @@ import javax.inject.Inject
class NotificationService : FirebaseMessagingService(),
CoroutineScope by CoroutineScope(Dispatchers.IO) {

companion object {
private const val KEY_TITLE = "push_notification_title"
private const val KEY_BODY = "push_notification_body"
private const val KEY_PAYLOAD = "flipcash_payload"
}

@Inject
lateinit var authManager: AuthManager

Expand All @@ -49,6 +58,12 @@ class NotificationService : FirebaseMessagingService(),
@Inject
lateinit var tokenCoordinator: TokenCoordinator

@Inject
lateinit var contactDataSource: ContactDataSource

@Inject
lateinit var phoneUtils: PhoneUtils

override fun onNewToken(token: String) {
super.onNewToken(token)
authenticateIfNeeded {
Expand All @@ -69,8 +84,8 @@ class NotificationService : FirebaseMessagingService(),
override fun onMessageReceived(message: RemoteMessage) {
super.onMessageReceived(message)

val title = message.data["push_notification_title"]?.ifEmpty { message.notification?.title }
val body = message.data["push_notification_body"]?.ifEmpty { message.notification?.body }
val title = message.data[KEY_TITLE]?.ifEmpty { message.notification?.title }
val body = message.data[KEY_BODY]?.ifEmpty { message.notification?.body }

trace(
message = "onMessageReceived",
Expand All @@ -81,61 +96,76 @@ class NotificationService : FirebaseMessagingService(),
}
)

if (title == null) {
return
}
if (title == null) return

val payload = message.data.getOrDefault("flipcash_payload", "")
val payload = message.data.getOrDefault(KEY_PAYLOAD, "")
.takeIf { it.isNotEmpty() }
?.let { protoString ->
NotificationPayload.fromEncoded(protoString)
}
?.let { NotificationPayload.fromEncoded(it) }

if (payload?.navigation is NavigationTrigger.CurrencyInfo) {
launch {
tokenCoordinator.update()
}
launch { tokenCoordinator.update() }
}

launch {
val resolvedTitle = applySubstitutions(title, payload?.titleSubstitutions.orEmpty())
val resolvedBody = body?.let { applySubstitutions(it, payload?.bodySubstitutions.orEmpty()) }
postNotification(resolvedTitle, resolvedBody, payload)
}
}

private fun postNotification(title: String, body: String?, payload: NotificationPayload?) {
val category = payload?.category ?: NotificationCategory.DEFAULT
NotificationChannels.ensureChannelGroups(this, notificationManager)
val channel = NotificationChannels.channelFor(this, category)
notificationManager.createNotificationChannel(channel)

val groupKey = payload?.groupKey?.takeIf { it.isNotEmpty() }

val notificationBuilder: NotificationCompat.Builder =
NotificationCompat.Builder(this, channel.id)
.setPriority(NotificationCompat.PRIORITY_HIGH)
.setSound(RingtoneManager.getDefaultUri(RingtoneManager.TYPE_NOTIFICATION))
val notification = NotificationCompat.Builder(this, channel.id)
.setPriority(NotificationCompat.PRIORITY_HIGH)
.setSound(RingtoneManager.getDefaultUri(RingtoneManager.TYPE_NOTIFICATION))
.setSmallIcon(R.drawable.flipcash_logo)
.setColor(getColor(R.color.notification_color))
.setAutoCancel(true)
.setContentTitle(title)
.setContentText(body)
.setContentIntent(buildContentIntent(payload?.navigation))
.apply { if (groupKey != null) setGroup(groupKey) }
.build()

if (ActivityCompat.checkSelfPermission(this, Manifest.permission.POST_NOTIFICATIONS)
!= PackageManager.PERMISSION_GRANTED
) return

val notificationId = SecureRandom().nextInt(Int.MAX_VALUE)
notificationManager.notify(notificationId, notification)

if (groupKey != null) {
val summary = NotificationCompat.Builder(this, channel.id)
.setSmallIcon(R.drawable.flipcash_logo)
.setColor(getColor(R.color.notification_color))
.setGroup(groupKey)
.setGroupSummary(true)
.setAutoCancel(true)
.setContentTitle(title)
.setContentText(body)
.setContentIntent(buildContentIntent(payload?.navigation))
.apply { if (groupKey != null) setGroup(groupKey) }
.build()
notificationManager.notify(groupKey.hashCode(), summary)
}
}

val notificationId = SecureRandom().nextInt(Int.MAX_VALUE)
private suspend fun resolveSubstitution(substitution: Substitution): String {
val phoneNumber = substitution.phoneNumber ?: return substitution.fallback
val displayName = contactDataSource.getDisplayName(phoneNumber)
if (displayName != null) return displayName
return runCatching { phoneUtils.formatNumber(phoneNumber) }.getOrDefault(substitution.fallback)
}

if (ActivityCompat.checkSelfPermission(
this,
Manifest.permission.POST_NOTIFICATIONS
) == PackageManager.PERMISSION_GRANTED
) {
notificationManager.notify(notificationId, notificationBuilder.build())

if (groupKey != null) {
val summary = NotificationCompat.Builder(this, channel.id)
.setSmallIcon(R.drawable.flipcash_logo)
.setColor(getColor(R.color.notification_color))
.setGroup(groupKey)
.setGroupSummary(true)
.setAutoCancel(true)
.build()
notificationManager.notify(groupKey.hashCode(), summary)
}
private suspend fun applySubstitutions(text: String, substitutions: List<Substitution>): String {
var result = text
for ((index, substitution) in substitutions.withIndex()) {
val resolved = resolveSubstitution(substitution)
result = result.replace("{$index}", resolved)
}
return result
}

private fun authenticateIfNeeded(block: () -> Unit) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import com.flipcash.services.internal.extensions.toPublicKey
import com.flipcash.services.models.NavigationTrigger
import com.flipcash.services.models.NotificationCategory
import com.flipcash.services.models.NotificationPayload
import com.flipcash.services.models.Substitution
import com.getcode.opencode.model.core.ID
import com.getcode.solana.keys.Checksum
import com.getcode.solana.keys.Mint
Expand Down Expand Up @@ -45,13 +46,26 @@ internal fun PushModels.Payload.asPayload(): NotificationPayload {
else -> NotificationCategory.DEFAULT
}

val titleSubs = titleSubstitutionsList.map { it.asSubstitution() }
val bodySubs = bodySubstitutionsList.map { it.asSubstitution() }

return NotificationPayload(
navigation = navigationTrigger,
category = notificationCategory,
groupKey = groupKey,
titleSubstitutions = titleSubs,
bodySubstitutions = bodySubs,
)
}

internal fun PushModels.Substitution.asSubstitution(): Substitution {
val phoneNumber = when (kindCase) {
PushModels.Substitution.KindCase.CONTACT -> contact.value
else -> null
}
return Substitution(fallback = fallback, phoneNumber = phoneNumber)
}

internal fun Common.Signature.toSignature(): Signature {
return Signature(value.toByteArray().toList())
}
Original file line number Diff line number Diff line change
Expand Up @@ -14,10 +14,17 @@ enum class NotificationCategory {
CONTACT_JOIN,
}

data class Substitution(
val fallback: String,
val phoneNumber: String?,
)

data class NotificationPayload(
val navigation: NavigationTrigger?,
val category: NotificationCategory = NotificationCategory.DEFAULT,
val groupKey: String = "",
val titleSubstitutions: List<Substitution> = emptyList(),
val bodySubstitutions: List<Substitution> = emptyList(),
) {
companion object {
fun fromEncoded(encoded: String): NotificationPayload? {
Expand Down
Loading