Skip to content

Commit 03f3d22

Browse files
committed
feat: support phone number chat deeplinks from push notifications
Add NavigationTrigger.Chat.ByContact to handle push notifications that target a phone number instead of a chatId. AppRouter now distinguishes phone-number deeplinks (e164 with '+' prefix) from base64-encoded chatId deeplinks, avoiding a URLDecoder double-decode that mangled the '+' into a space. ChatViewModel resolves the contact via in-memory lookup, Room DB fallback, or the raw deeplink stub.
1 parent f4f57d8 commit 03f3d22

10 files changed

Lines changed: 113 additions & 18 deletions

File tree

apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/navigation/DeeplinkType.kt

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ package com.flipcash.app.core.navigation
22

33
import android.net.Uri
44
import android.os.Parcelable
5+
import com.flipcash.app.core.chat.ChatIdentifier
56
import com.flipcash.services.models.chat.ChatId
67
import com.getcode.solana.keys.Mint
78
import kotlinx.parcelize.Parcelize
@@ -16,7 +17,7 @@ sealed interface DeeplinkType: Parcelable {
1617

1718
@Serializable data class TokenInfo(val mint: Mint): DeeplinkType, Navigatable
1819

19-
@Serializable data class Chat(val chatId: ChatId): DeeplinkType, Navigatable
20+
@Serializable data class Chat(val identifier: ChatIdentifier): DeeplinkType, Navigatable
2021

2122
@Serializable
2223
data class EmailVerification(

apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/util/Linkify.kt

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,5 +15,6 @@ object Linkify {
1515
fun tweet(message: String): String = "https://www.twitter.com/intent/tweet?text=${message.urlEncode()}"
1616
fun tokenInfo(token: Token): String = tokenInfo(token.address)
1717
fun tokenInfo(mint: Mint): String = "https://app.flipcash.com/token/${mint.base58()}"
18-
fun chat(chatId: ChatId): String = "https://app.flipcash.com/chat/${chatId.bytes.encodeBase64(urlSafe = true)}"
18+
fun chatById(chatId: ChatId): String = "https://app.flipcash.com/chat/${chatId.bytes.encodeBase64(urlSafe = true)}"
19+
fun chatByPhone(phoneNumber: String): String = "https://app.flipcash.com/chat/${phoneNumber.urlEncode()}"
1920
}

apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/ChatViewModel.kt

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ package com.flipcash.app.messenger.internal
22

33
import androidx.compose.foundation.text.input.TextFieldState
44
import androidx.compose.foundation.text.input.clearText
5+
import androidx.compose.foundation.text.input.setTextAndPlaceCursorAtEnd
56
import androidx.compose.runtime.snapshotFlow
67
import androidx.lifecycle.viewModelScope
78
import androidx.paging.PagingData
@@ -280,7 +281,10 @@ internal class ChatViewModel @Inject constructor(
280281
// 2. Resolve contact
281282
when (identifier) {
282283
is ChatIdentifier.ByContact -> {
283-
dispatchEvent(Event.OnContactFound(identifier.contact))
284+
val resolved = contactCoordinator.lookupContact(identifier.contact.e164).getOrNull()
285+
?: chatId?.let { contactCoordinator.lookupContactByDmChatId(it.toString()) }
286+
?: identifier.contact
287+
dispatchEvent(Event.OnContactFound(resolved))
284288
}
285289
is ChatIdentifier.ByChatId -> {
286290
val contact = contactCoordinator.lookupContactByDmChatId(
@@ -465,7 +469,7 @@ internal class ChatViewModel @Inject constructor(
465469
val chatId = stateFlow.value.chatId ?: return@onEach
466470
if (textToSend.isBlank()) return@onEach
467471

468-
stateFlow.value.chatInputState.clearText()
472+
stateFlow.value.chatInputState.setTextAndPlaceCursorAtEnd("")
469473

470474
viewModelScope.launch {
471475
chatCoordinator.sendMessage(chatId, textToSend)

apps/flipcash/shared/notifications/src/main/kotlin/com/flipcash/app/notifications/NotificationService.kt

Lines changed: 20 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -124,11 +124,14 @@ class NotificationService : FirebaseMessagingService(),
124124
}
125125
}
126126

127-
if (payload?.navigation is NavigationTrigger.Chat) {
128-
launch {
129-
chatCoordinator.refreshFeed()
130-
chatCoordinator.loadMessages(chatId = (payload.navigation as NavigationTrigger.Chat).chatId)
127+
when (val trigger = payload?.navigation) {
128+
is NavigationTrigger.Chat.ById -> {
129+
launch {
130+
chatCoordinator.refreshFeed()
131+
chatCoordinator.loadMessages(chatId = trigger.chatId)
132+
}
131133
}
134+
else -> Unit
132135
}
133136

134137
authenticateIfNeeded {
@@ -158,7 +161,13 @@ class NotificationService : FirebaseMessagingService(),
158161
val channel = NotificationChannels.channelFor(this, category)
159162
notificationManager.createNotificationChannel(channel)
160163

161-
val chatId = (payload?.navigation as? NavigationTrigger.Chat)?.chatId
164+
val chatId = when (val trigger = payload?.navigation) {
165+
is NavigationTrigger.Chat.ByContact -> null
166+
is NavigationTrigger.Chat.ById -> trigger.chatId
167+
is NavigationTrigger.CurrencyInfo -> null
168+
null -> null
169+
}
170+
162171
if (chatId != null && chatCoordinator.isActiveChat(chatId)) return
163172

164173
val groupKey = payload?.groupKey?.takeIf { it.isNotEmpty() }
@@ -339,8 +348,12 @@ class NotificationService : FirebaseMessagingService(),
339348
data = Linkify.tokenInfo(navigation.mint).toUri()
340349
}
341350

342-
is NavigationTrigger.Chat -> Intent(Intent.ACTION_VIEW).apply {
343-
data = Linkify.chat(navigation.chatId).toUri()
351+
is NavigationTrigger.Chat.ById -> Intent(Intent.ACTION_VIEW).apply {
352+
data = Linkify.chatById(navigation.chatId).toUri()
353+
}
354+
355+
is NavigationTrigger.Chat.ByContact -> Intent(Intent.ACTION_VIEW).apply {
356+
data = Linkify.chatByPhone(navigation.phoneNumber).toUri()
344357
}
345358

346359
else -> packageManager.getLaunchIntentForPackage(packageName)

apps/flipcash/shared/router/src/main/kotlin/com/flipcash/app/router/internal/AppRouter.kt

Lines changed: 16 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ package com.flipcash.app.router.internal
33
import androidx.core.net.toUri
44
import com.flipcash.app.core.AppRoute
55
import com.flipcash.app.core.chat.ChatIdentifier
6+
import com.flipcash.app.core.contacts.DeviceContact
67
import com.flipcash.app.core.navigation.DeeplinkAction
78
import com.flipcash.app.core.navigation.DeeplinkType
89
import com.flipcash.services.models.chat.ChatId
@@ -60,7 +61,7 @@ internal class AppRouter(
6061

6162
is DeeplinkType.EmailVerification -> resolveEmailVerification(type)
6263
is DeeplinkType.Chat -> DeeplinkAction.Navigate(
63-
listOf(AppRoute.Sheets.Send(), AppRoute.Messaging.Chat(ChatIdentifier.ByChatId(type.chatId)))
64+
listOf(AppRoute.Sheets.Send(), AppRoute.Messaging.Chat(type.identifier))
6465
)
6566
}
6667
}
@@ -151,11 +152,22 @@ private fun DeepLink.handleTokenLink(): DeeplinkType.TokenInfo? {
151152
return DeeplinkType.TokenInfo(Mint(mint))
152153
}
153154

155+
// https://app.flipcash.com/chat/{url encoded chatId}
156+
// https://app.flipcash.com/chat/{url encoded e164}
154157
private fun DeepLink.handleChat(): DeeplinkType.Chat? {
155158
val uri = data.toUri()
156-
val chatIdBase64 = uri.pathSegments.getOrNull(1) ?: return null
157-
val chatId = ChatId(chatIdBase64.decodeBase64UrlSafe().toList())
158-
return DeeplinkType.Chat(chatId)
159+
// pathSegments already percent-decodes; do NOT urlDecode() again
160+
// because URLDecoder treats '+' as a space, mangling phone numbers.
161+
val chatTarget = uri.pathSegments.getOrNull(1) ?: return null
162+
163+
val identifier = if (chatTarget.startsWith("+")) {
164+
ChatIdentifier.ByContact(DeviceContact.unknownContact(e164 = chatTarget))
165+
} else {
166+
val chatId = ChatId(chatTarget.decodeBase64UrlSafe().toList())
167+
ChatIdentifier.ByChatId(chatId)
168+
}
169+
170+
return DeeplinkType.Chat(identifier)
159171
}
160172

161173
// https://app.flipcash.com/verify?email={email}&code={code}&client_data={data}

definitions/flipcash/protos/src/main/proto/push/v1/model.proto

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,9 @@ message Navigation {
5757

5858
// Chat for the provided ID
5959
common.v1.ChatId chat_id = 2;
60+
61+
// Chat for a contact with the provided phone number
62+
phone.v1.PhoneNumber chat_contact_phone_number = 3;
6063
}
6164
}
6265

scripts/fcm-chat-phone.sh

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
#!/bin/bash
2+
# Sends a chat push notification for a phone number contact.
3+
# Usage: ./scripts/fcm-chat-phone.sh <device_token> <phone_number>
4+
#
5+
# Phone number must be E.164 format, e.g. +15551234567
6+
7+
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
8+
9+
DEVICE_TOKEN=$1
10+
PHONE=$2
11+
12+
if [ -z "$DEVICE_TOKEN" ] || [ -z "$PHONE" ]; then
13+
echo "Usage: $0 <device_token> <phone_number>"
14+
echo " phone_number: E.164 format, e.g. +15551234567"
15+
exit 1
16+
fi
17+
18+
encode_payload() {
19+
local phone="$1"
20+
python3 -c "
21+
import base64
22+
def varint(v):
23+
r = b''
24+
while v > 0x7F:
25+
r += bytes([(v & 0x7F) | 0x80]); v >>= 7
26+
return r + bytes([v & 0x7F])
27+
def field_bytes(num, data):
28+
return varint((num << 3) | 2) + varint(len(data)) + data
29+
def field_varint(num, val):
30+
return varint((num << 3) | 0) + varint(val)
31+
phone = b'$phone'
32+
# Payload.navigation (field 1) > Navigation.chat_contact_phone_number (field 3) > PhoneNumber.value (field 1)
33+
nav = field_bytes(1, field_bytes(3, field_bytes(1, phone)))
34+
# Payload.category = CHAT (enum value 4)
35+
cat = field_varint(4, 4)
36+
# Payload.group_key
37+
gk = field_bytes(5, b'chat_$phone')
38+
print(base64.b64encode(nav + cat + gk).decode())
39+
"
40+
}
41+
42+
PAYLOAD=$(encode_payload "$PHONE")
43+
44+
JSON_PAYLOAD=$(jq -n \
45+
--arg payload "$PAYLOAD" \
46+
--arg title "Contact Joined!" \
47+
--arg body "Tap to send them some cash" \
48+
'{
49+
flipcash_payload: $payload,
50+
push_notification_title: $title,
51+
push_notification_body: $body
52+
}')
53+
54+
"$SCRIPT_DIR/fcm.sh" "$DEVICE_TOKEN" "$JSON_PAYLOAD"

scripts/fcm.sh

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22

33
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
44

5-
source "$SCRIPT_DIR/../.env.local"
5+
source "$SCRIPT_DIR/../.env"
66

77
ACCESS_TOKEN=$(python3 << EOF
88
from google.oauth2 import service_account

services/flipcash/src/main/kotlin/com/flipcash/services/internal/network/extensions/ProtobufToLocal.kt

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -65,7 +65,11 @@ internal fun PushModels.Payload.asPayload(): NotificationPayload {
6565
}
6666
PushModels.Navigation.TypeCase.CHAT_ID -> {
6767
val chatId = navigation.chatId.toChatId()
68-
NavigationTrigger.Chat(chatId)
68+
NavigationTrigger.Chat.ById(chatId)
69+
}
70+
PushModels.Navigation.TypeCase.CHAT_CONTACT_PHONE_NUMBER -> {
71+
val phoneNumber = navigation.chatContactPhoneNumber.value
72+
NavigationTrigger.Chat.ByContact(phoneNumber)
6973
}
7074
PushModels.Navigation.TypeCase.TYPE_NOT_SET -> null
7175
}

services/flipcash/src/main/kotlin/com/flipcash/services/models/NotificationPayload.kt

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -44,5 +44,8 @@ data class NotificationPayload(
4444

4545
sealed interface NavigationTrigger {
4646
data class CurrencyInfo(val mint: Mint) : NavigationTrigger
47-
data class Chat(val chatId: ChatId) : NavigationTrigger
47+
sealed interface Chat: NavigationTrigger {
48+
data class ById(val chatId: ChatId) : Chat
49+
data class ByContact(val phoneNumber: String) : Chat
50+
}
4851
}

0 commit comments

Comments
 (0)