diff --git a/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/navigation/DeeplinkType.kt b/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/navigation/DeeplinkType.kt index 70999b9ad..a0bdb4a8e 100644 --- a/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/navigation/DeeplinkType.kt +++ b/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/navigation/DeeplinkType.kt @@ -2,6 +2,7 @@ package com.flipcash.app.core.navigation import android.net.Uri import android.os.Parcelable +import com.flipcash.app.core.chat.ChatIdentifier import com.flipcash.services.models.chat.ChatId import com.getcode.solana.keys.Mint import kotlinx.parcelize.Parcelize @@ -16,7 +17,7 @@ sealed interface DeeplinkType: Parcelable { @Serializable data class TokenInfo(val mint: Mint): DeeplinkType, Navigatable - @Serializable data class Chat(val chatId: ChatId): DeeplinkType, Navigatable + @Serializable data class Chat(val identifier: ChatIdentifier): DeeplinkType, Navigatable @Serializable data class EmailVerification( diff --git a/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/util/Linkify.kt b/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/util/Linkify.kt index 23adbc0d7..7928e1fc9 100644 --- a/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/util/Linkify.kt +++ b/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/util/Linkify.kt @@ -15,5 +15,6 @@ object Linkify { fun tweet(message: String): String = "https://www.twitter.com/intent/tweet?text=${message.urlEncode()}" fun tokenInfo(token: Token): String = tokenInfo(token.address) fun tokenInfo(mint: Mint): String = "https://app.flipcash.com/token/${mint.base58()}" - fun chat(chatId: ChatId): String = "https://app.flipcash.com/chat/${chatId.bytes.encodeBase64(urlSafe = true)}" + fun chatById(chatId: ChatId): String = "https://app.flipcash.com/chat/${chatId.bytes.encodeBase64(urlSafe = true)}" + fun chatByPhone(phoneNumber: String): String = "https://app.flipcash.com/chat/${phoneNumber.urlEncode()}" } \ No newline at end of file diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/ChatViewModel.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/ChatViewModel.kt index 31c54c880..74ccb9f4a 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/ChatViewModel.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/ChatViewModel.kt @@ -281,7 +281,10 @@ internal class ChatViewModel @Inject constructor( // 2. Resolve contact when (identifier) { is ChatIdentifier.ByContact -> { - dispatchEvent(Event.OnContactFound(identifier.contact)) + val resolved = contactCoordinator.lookupContact(identifier.contact.e164).getOrNull() + ?: chatId?.let { contactCoordinator.lookupContactByDmChatId(it.toString()) } + ?: identifier.contact + dispatchEvent(Event.OnContactFound(resolved)) } is ChatIdentifier.ByChatId -> { val contact = contactCoordinator.lookupContactByDmChatId( 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 008a038b9..cb77b340a 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 @@ -124,11 +124,14 @@ class NotificationService : FirebaseMessagingService(), } } - if (payload?.navigation is NavigationTrigger.Chat) { - launch { - chatCoordinator.refreshFeed() - chatCoordinator.loadMessages(chatId = (payload.navigation as NavigationTrigger.Chat).chatId) + when (val trigger = payload?.navigation) { + is NavigationTrigger.Chat.ById -> { + launch { + chatCoordinator.refreshFeed() + chatCoordinator.loadMessages(chatId = trigger.chatId) + } } + else -> Unit } authenticateIfNeeded { @@ -158,7 +161,13 @@ class NotificationService : FirebaseMessagingService(), val channel = NotificationChannels.channelFor(this, category) notificationManager.createNotificationChannel(channel) - val chatId = (payload?.navigation as? NavigationTrigger.Chat)?.chatId + val chatId = when (val trigger = payload?.navigation) { + is NavigationTrigger.Chat.ByContact -> null + is NavigationTrigger.Chat.ById -> trigger.chatId + is NavigationTrigger.CurrencyInfo -> null + null -> null + } + if (chatId != null && chatCoordinator.isActiveChat(chatId)) return val groupKey = payload?.groupKey?.takeIf { it.isNotEmpty() } @@ -339,8 +348,12 @@ class NotificationService : FirebaseMessagingService(), data = Linkify.tokenInfo(navigation.mint).toUri() } - is NavigationTrigger.Chat -> Intent(Intent.ACTION_VIEW).apply { - data = Linkify.chat(navigation.chatId).toUri() + is NavigationTrigger.Chat.ById -> Intent(Intent.ACTION_VIEW).apply { + data = Linkify.chatById(navigation.chatId).toUri() + } + + is NavigationTrigger.Chat.ByContact -> Intent(Intent.ACTION_VIEW).apply { + data = Linkify.chatByPhone(navigation.phoneNumber).toUri() } else -> packageManager.getLaunchIntentForPackage(packageName) diff --git a/apps/flipcash/shared/router/src/main/kotlin/com/flipcash/app/router/internal/AppRouter.kt b/apps/flipcash/shared/router/src/main/kotlin/com/flipcash/app/router/internal/AppRouter.kt index fbc952da6..433d551e1 100644 --- a/apps/flipcash/shared/router/src/main/kotlin/com/flipcash/app/router/internal/AppRouter.kt +++ b/apps/flipcash/shared/router/src/main/kotlin/com/flipcash/app/router/internal/AppRouter.kt @@ -3,6 +3,7 @@ package com.flipcash.app.router.internal import androidx.core.net.toUri import com.flipcash.app.core.AppRoute import com.flipcash.app.core.chat.ChatIdentifier +import com.flipcash.app.core.contacts.DeviceContact import com.flipcash.app.core.navigation.DeeplinkAction import com.flipcash.app.core.navigation.DeeplinkType import com.flipcash.services.models.chat.ChatId @@ -60,7 +61,7 @@ internal class AppRouter( is DeeplinkType.EmailVerification -> resolveEmailVerification(type) is DeeplinkType.Chat -> DeeplinkAction.Navigate( - listOf(AppRoute.Sheets.Send(), AppRoute.Messaging.Chat(ChatIdentifier.ByChatId(type.chatId))) + listOf(AppRoute.Sheets.Send(), AppRoute.Messaging.Chat(type.identifier)) ) } } @@ -151,11 +152,22 @@ private fun DeepLink.handleTokenLink(): DeeplinkType.TokenInfo? { return DeeplinkType.TokenInfo(Mint(mint)) } +// https://app.flipcash.com/chat/{url encoded chatId} +// https://app.flipcash.com/chat/{url encoded e164} private fun DeepLink.handleChat(): DeeplinkType.Chat? { val uri = data.toUri() - val chatIdBase64 = uri.pathSegments.getOrNull(1) ?: return null - val chatId = ChatId(chatIdBase64.decodeBase64UrlSafe().toList()) - return DeeplinkType.Chat(chatId) + // pathSegments already percent-decodes; do NOT urlDecode() again + // because URLDecoder treats '+' as a space, mangling phone numbers. + val chatTarget = uri.pathSegments.getOrNull(1) ?: return null + + val identifier = if (chatTarget.startsWith("+")) { + ChatIdentifier.ByContact(DeviceContact.unknownContact(e164 = chatTarget)) + } else { + val chatId = ChatId(chatTarget.decodeBase64UrlSafe().toList()) + ChatIdentifier.ByChatId(chatId) + } + + return DeeplinkType.Chat(identifier) } // https://app.flipcash.com/verify?email={email}&code={code}&client_data={data} diff --git a/definitions/flipcash/protos/src/main/proto/push/v1/model.proto b/definitions/flipcash/protos/src/main/proto/push/v1/model.proto index 7cf179b50..ee77691fc 100644 --- a/definitions/flipcash/protos/src/main/proto/push/v1/model.proto +++ b/definitions/flipcash/protos/src/main/proto/push/v1/model.proto @@ -57,6 +57,9 @@ message Navigation { // Chat for the provided ID common.v1.ChatId chat_id = 2; + + // Chat for a contact with the provided phone number + phone.v1.PhoneNumber chat_contact_phone_number = 3; } } diff --git a/scripts/fcm-chat-phone.sh b/scripts/fcm-chat-phone.sh new file mode 100755 index 000000000..8561b7969 --- /dev/null +++ b/scripts/fcm-chat-phone.sh @@ -0,0 +1,54 @@ +#!/bin/bash +# Sends a chat push notification for a phone number contact. +# Usage: ./scripts/fcm-chat-phone.sh +# +# Phone number must be E.164 format, e.g. +15551234567 + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + +DEVICE_TOKEN=$1 +PHONE=$2 + +if [ -z "$DEVICE_TOKEN" ] || [ -z "$PHONE" ]; then + echo "Usage: $0 " + echo " phone_number: E.164 format, e.g. +15551234567" + exit 1 +fi + +encode_payload() { + local phone="$1" + python3 -c " +import base64 +def varint(v): + r = b'' + while v > 0x7F: + r += bytes([(v & 0x7F) | 0x80]); v >>= 7 + return r + bytes([v & 0x7F]) +def field_bytes(num, data): + return varint((num << 3) | 2) + varint(len(data)) + data +def field_varint(num, val): + return varint((num << 3) | 0) + varint(val) +phone = b'$phone' +# Payload.navigation (field 1) > Navigation.chat_contact_phone_number (field 3) > PhoneNumber.value (field 1) +nav = field_bytes(1, field_bytes(3, field_bytes(1, phone))) +# Payload.category = CHAT (enum value 4) +cat = field_varint(4, 4) +# Payload.group_key +gk = field_bytes(5, b'chat_$phone') +print(base64.b64encode(nav + cat + gk).decode()) +" +} + +PAYLOAD=$(encode_payload "$PHONE") + +JSON_PAYLOAD=$(jq -n \ + --arg payload "$PAYLOAD" \ + --arg title "Contact Joined!" \ + --arg body "Tap to send them some cash" \ + '{ + flipcash_payload: $payload, + push_notification_title: $title, + push_notification_body: $body + }') + +"$SCRIPT_DIR/fcm.sh" "$DEVICE_TOKEN" "$JSON_PAYLOAD" diff --git a/scripts/fcm.sh b/scripts/fcm.sh index 9df9ed542..1f416f3a8 100755 --- a/scripts/fcm.sh +++ b/scripts/fcm.sh @@ -2,7 +2,7 @@ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -source "$SCRIPT_DIR/../.env.local" +source "$SCRIPT_DIR/../.env" ACCESS_TOKEN=$(python3 << EOF from google.oauth2 import service_account diff --git a/services/flipcash/src/main/kotlin/com/flipcash/services/internal/network/extensions/ProtobufToLocal.kt b/services/flipcash/src/main/kotlin/com/flipcash/services/internal/network/extensions/ProtobufToLocal.kt index 39f0d6b32..61969dfc3 100644 --- a/services/flipcash/src/main/kotlin/com/flipcash/services/internal/network/extensions/ProtobufToLocal.kt +++ b/services/flipcash/src/main/kotlin/com/flipcash/services/internal/network/extensions/ProtobufToLocal.kt @@ -65,7 +65,11 @@ internal fun PushModels.Payload.asPayload(): NotificationPayload { } PushModels.Navigation.TypeCase.CHAT_ID -> { val chatId = navigation.chatId.toChatId() - NavigationTrigger.Chat(chatId) + NavigationTrigger.Chat.ById(chatId) + } + PushModels.Navigation.TypeCase.CHAT_CONTACT_PHONE_NUMBER -> { + val phoneNumber = navigation.chatContactPhoneNumber.value + NavigationTrigger.Chat.ByContact(phoneNumber) } PushModels.Navigation.TypeCase.TYPE_NOT_SET -> null } diff --git a/services/flipcash/src/main/kotlin/com/flipcash/services/models/NotificationPayload.kt b/services/flipcash/src/main/kotlin/com/flipcash/services/models/NotificationPayload.kt index e5b4f46c5..2291de0a6 100644 --- a/services/flipcash/src/main/kotlin/com/flipcash/services/models/NotificationPayload.kt +++ b/services/flipcash/src/main/kotlin/com/flipcash/services/models/NotificationPayload.kt @@ -44,5 +44,8 @@ data class NotificationPayload( sealed interface NavigationTrigger { data class CurrencyInfo(val mint: Mint) : NavigationTrigger - data class Chat(val chatId: ChatId) : NavigationTrigger + sealed interface Chat: NavigationTrigger { + data class ById(val chatId: ChatId) : Chat + data class ByContact(val phoneNumber: String) : Chat + } } \ No newline at end of file