Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Group Chat Push Notifications #210

Merged
merged 15 commits into from
Apr 1, 2024
15 changes: 12 additions & 3 deletions example/src/main/java/org/xmtp/android/example/MainViewModel.kt
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import org.xmtp.android.example.extension.stateFlow
import org.xmtp.android.example.pushnotifications.PushNotificationTokenManager
import org.xmtp.android.library.Conversation
import org.xmtp.android.library.DecodedMessage
import org.xmtp.android.library.messages.Topic
import org.xmtp.android.library.push.Service

class MainViewModel : ViewModel() {
Expand All @@ -46,7 +47,7 @@ class MainViewModel : ViewModel() {
try {
val conversations = ClientManager.client.conversations.list(includeGroups = true)
val hmacKeysResult = ClientManager.client.conversations.getHmacKeys()
val subscriptions = conversations.map {
val subscriptions: MutableList<Service.Subscription> = conversations.map {
val hmacKeys = hmacKeysResult.hmacKeysMap
val result = hmacKeys[it.topic]?.valuesList?.map { hmacKey ->
Service.Subscription.HmacKey.newBuilder().also { sub_key ->
Expand All @@ -56,11 +57,19 @@ class MainViewModel : ViewModel() {
}

Service.Subscription.newBuilder().also { sub ->
sub.addAllHmacKeys(result)
if (!result.isNullOrEmpty()) {
sub.addAllHmacKeys(result)
}
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The hmac key work for groups will come in a follow up.

sub.topic = it.topic
sub.isSilent = it.version == Conversation.Version.V1
}.build()
}
}.toMutableList()

val welcomeTopic = Service.Subscription.newBuilder().also { sub ->
sub.topic = Topic.userWelcome(ClientManager.client.installationId).description
sub.isSilent = false
}.build()
subscriptions.add(welcomeTopic)

PushNotificationTokenManager.xmtpPush.subscribeWithMetadata(subscriptions)
listItems.addAll(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,10 @@ import org.xmtp.android.example.R
import org.xmtp.android.example.conversation.ConversationDetailActivity
import org.xmtp.android.example.extension.truncatedAddress
import org.xmtp.android.example.utils.KeyUtil
import org.xmtp.android.library.Conversation
import org.xmtp.android.library.messages.EnvelopeBuilder
import org.xmtp.android.library.messages.Topic
import uniffi.xmtpv3.org.xmtp.android.library.codecs.GroupMembershipChanges
import java.util.Date

class PushNotificationsService : FirebaseMessagingService() {
Expand Down Expand Up @@ -57,40 +60,78 @@ class PushNotificationsService : FirebaseMessagingService() {
GlobalScope.launch(Dispatchers.Main) {
ClientManager.createClient(keysData, applicationContext)
}
val conversation =
runBlocking { ClientManager.client.fetchConversation(topic, includeGroups = true) }
if (conversation == null) {
Log.e(TAG, "No keys or conversation persisted")
return
}
val envelope = EnvelopeBuilder.buildFromString(topic, Date(), encryptedMessageData)
val peerAddress = conversation.peerAddress
val decodedMessage = conversation.decode(envelope)
val welcomeTopic = Topic.userWelcome(ClientManager.client.installationId).description
val builder = if (welcomeTopic == topic) {
val group = ClientManager.client.conversations.fromWelcome(encryptedMessageData)
val pendingIntent = PendingIntent.getActivity(
this,
0,
ConversationDetailActivity.intent(
this,
topic = group.topic,
peerAddress = Conversation.Group(group).peerAddress
),
(PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT)
)

NotificationCompat.Builder(this, CHANNEL_ID)
.setSmallIcon(R.drawable.ic_xmtp_white)
.setContentTitle(Conversation.Group(group).peerAddress.truncatedAddress())
.setContentText("New Group Chat")
.setAutoCancel(true)
.setColor(ContextCompat.getColor(this, R.color.black))
.setPriority(NotificationCompat.PRIORITY_DEFAULT)
.setStyle(NotificationCompat.BigTextStyle().bigText("New Group Chat"))
.setContentIntent(pendingIntent)
} else {
val conversation =
runBlocking { ClientManager.client.fetchConversation(topic, includeGroups = true) }
if (conversation == null) {
Log.e(TAG, topic)
Log.e(TAG, "No keys or conversation persisted")
return
}
val decodedMessage = if (conversation is Conversation.Group) {
runBlocking { conversation.group.processMessage(encryptedMessageData).decode() }
} else {
val envelope = EnvelopeBuilder.buildFromString(topic, Date(), encryptedMessageData)
conversation.decode(envelope)
}
val peerAddress = conversation.peerAddress

val body = decodedMessage.body
val title = peerAddress.truncatedAddress()
val body: String = if (decodedMessage.content<Any>() is String) {
decodedMessage.body
} else if (decodedMessage.content<Any>() is GroupMembershipChanges) {
val changes = decodedMessage.content() as? GroupMembershipChanges
"Membership Changed ${
changes?.membersAddedList?.mapNotNull { it.accountAddress }.toString()
}"
} else {
""
}
val title = peerAddress.truncatedAddress()

val pendingIntent = PendingIntent.getActivity(
this,
0,
ConversationDetailActivity.intent(
val pendingIntent = PendingIntent.getActivity(
this,
topic = topic,
peerAddress = peerAddress
),
(PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT)
)

val builder = NotificationCompat.Builder(this, CHANNEL_ID)
.setSmallIcon(R.drawable.ic_xmtp_white)
.setContentTitle(title)
.setContentText(body)
.setAutoCancel(true)
.setColor(ContextCompat.getColor(this, R.color.black))
.setPriority(NotificationCompat.PRIORITY_DEFAULT)
.setStyle(NotificationCompat.BigTextStyle().bigText(body))
.setContentIntent(pendingIntent)
0,
ConversationDetailActivity.intent(
this,
topic = topic,
peerAddress = peerAddress
),
(PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT)
)

NotificationCompat.Builder(this, CHANNEL_ID)
.setSmallIcon(R.drawable.ic_xmtp_white)
.setContentTitle(title)
.setContentText(body)
.setAutoCancel(true)
.setColor(ContextCompat.getColor(this, R.color.black))
.setPriority(NotificationCompat.PRIORITY_DEFAULT)
.setStyle(NotificationCompat.BigTextStyle().bigText(body))
.setContentIntent(pendingIntent)
}
// Use the URL as the ID for now until one is passed back from the server.
NotificationManagerCompat.from(this).apply {
if (ActivityCompat.checkSelfPermission(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,7 @@ class ClientTest {
)
)
assert(client.canMessageV3(listOf(client.address)))
assert(client.installationId.isNotEmpty())
}

@Test
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -494,7 +494,7 @@ class GroupTest {
boClient.conversations.streamAll().test {
val group =
caroClient.conversations.newGroup(listOf(bo.walletAddress))
assertEquals(group.id.toHex(), awaitItem().topic)
assertEquals(group.topic, awaitItem().topic)
val conversation =
alixClient.conversations.newConversation(bo.walletAddress)
assertEquals(conversation.topic, awaitItem().topic)
Expand Down
15 changes: 10 additions & 5 deletions library/src/main/java/org/xmtp/android/library/Client.kt
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,6 @@ import org.xmtp.android.library.messages.walletAddress
import org.xmtp.proto.message.api.v1.MessageApiOuterClass
import org.xmtp.proto.message.api.v1.MessageApiOuterClass.BatchQueryResponse
import org.xmtp.proto.message.api.v1.MessageApiOuterClass.QueryRequest
import uniffi.xmtpv3.FfiV2ApiClient
import uniffi.xmtpv3.FfiXmtpClient
import uniffi.xmtpv3.LegacyIdentitySource
import uniffi.xmtpv3.createClient
Expand Down Expand Up @@ -87,9 +86,9 @@ class Client() {
lateinit var conversations: Conversations
var logger: XMTPLogger = XMTPLogger()
val libXMTPVersion: String = getVersionInfo()
var installationId: String = ""
private var libXMTPClient: FfiXmtpClient? = null
private var dbPath: String = ""
private lateinit var v2RustClient: FfiV2ApiClient

companion object {
private const val TAG = "Client"
Expand Down Expand Up @@ -166,6 +165,7 @@ class Client() {
apiClient: ApiClient,
libXMTPClient: FfiXmtpClient? = null,
dbPath: String = "",
installationId: String = "",
) : this() {
this.address = address
this.privateKeyBundleV1 = privateKeyBundleV1
Expand All @@ -175,6 +175,7 @@ class Client() {
this.conversations =
Conversations(client = this, libXMTPConversations = libXMTPClient?.conversations())
this.dbPath = dbPath
this.installationId = installationId
}

fun buildFrom(
Expand Down Expand Up @@ -207,7 +208,8 @@ class Client() {
privateKeyBundleV1 = bundle,
apiClient = apiClient,
libXMTPClient = v3Client,
dbPath = dbPath
dbPath = dbPath,
installationId = v3Client?.installationId()?.toHex() ?: ""
)
}

Expand Down Expand Up @@ -257,7 +259,8 @@ class Client() {
privateKeyBundleV1,
apiClient,
libXMTPClient,
dbPath
dbPath,
libXMTPClient?.installationId()?.toHex() ?: ""
)
client.ensureUserContactPublished()
client
Expand Down Expand Up @@ -304,7 +307,8 @@ class Client() {
privateKeyBundleV1 = v1Bundle,
apiClient = apiClient,
libXMTPClient = v3Client,
dbPath = dbPath
dbPath = dbPath,
installationId = v3Client?.installationId()?.toHex() ?: ""
)
}

Expand Down Expand Up @@ -502,6 +506,7 @@ class Client() {
suspend fun subscribe(topics: List<String>): Flow<Envelope> {
return subscribe2(flowOf(makeSubscribeRequest(topics)))
}

suspend fun subscribe2(request: Flow<MessageApiOuterClass.SubscribeRequest>): Flow<Envelope> {
return apiClient.subscribe(request = request)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -212,7 +212,7 @@ sealed class Conversation {
return when (this) {
is V1 -> conversationV1.topic.description
is V2 -> conversationV2.topic
is Group -> group.id.toHex()
is Group -> group.topic
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,12 @@ data class Conversations(
)
}

fun fromWelcome(envelopeBytes: ByteArray): Group {
val group = libXMTPConversations?.processStreamedWelcomeMessage(envelopeBytes)
?: throw XMTPException("Client does not support Groups")
return Group(client, group)
}

suspend fun newGroup(
accountAddresses: List<String>,
permissions: GroupPermissions = GroupPermissions.EVERYONE_IS_ADMIN,
Expand Down
9 changes: 9 additions & 0 deletions library/src/main/java/org/xmtp/android/library/Group.kt
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import org.xmtp.android.library.codecs.compress
import org.xmtp.android.library.libxmtp.Message
import org.xmtp.android.library.messages.DecryptedMessage
import org.xmtp.android.library.messages.PagingInfoSortDirection
import org.xmtp.android.library.messages.Topic
import org.xmtp.proto.message.api.v1.MessageApiOuterClass
import uniffi.xmtpv3.FfiGroup
import uniffi.xmtpv3.FfiGroupMetadata
Expand All @@ -24,6 +25,9 @@ class Group(val client: Client, private val libXMTPGroup: FfiGroup) {
val id: ByteArray
get() = libXMTPGroup.id()

val topic: String
get() = Topic.groupMessage(id.toHex()).description

val createdAt: Date
get() = Date(libXMTPGroup.createdAtNs() / 1_000_000)

Expand Down Expand Up @@ -119,6 +123,11 @@ class Group(val client: Client, private val libXMTPGroup: FfiGroup) {
}
}

suspend fun processMessage(envelopeBytes: ByteArray): Message {
val message = libXMTPGroup.processStreamedGroupMessage(envelopeBytes)
return Message(client, message)
}

fun isActive(): Boolean {
return libXMTPGroup.isActive()
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import org.xmtp.android.library.DecodedMessage
import org.xmtp.android.library.XMTPException
import org.xmtp.android.library.codecs.EncodedContent
import org.xmtp.android.library.messages.DecryptedMessage
import org.xmtp.android.library.messages.Topic
import org.xmtp.android.library.toHex
import uniffi.xmtpv3.FfiMessage
import java.util.Date
Expand All @@ -27,7 +28,7 @@ data class Message(val client: Client, private val libXMTPMessage: FfiMessage) {
return DecodedMessage(
id = id.toHex(),
client = client,
topic = id.toHex(),
topic = Topic.groupMessage(convoId.toHex()).description,
encodedContent = EncodedContent.parseFrom(libXMTPMessage.content),
senderAddress = senderAddress,
sent = sentAt
Expand All @@ -40,7 +41,7 @@ data class Message(val client: Client, private val libXMTPMessage: FfiMessage) {
fun decrypt(): DecryptedMessage {
return DecryptedMessage(
id = id.toHex(),
topic = convoId.toHex(),
topic = Topic.groupMessage(convoId.toHex()).description,
encodedContent = decode().encodedContent,
senderAddress = senderAddress,
sentAt = Date()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ sealed class Topic {
data class directMessageV1(val address1: String?, val address2: String?) : Topic()
data class directMessageV2(val addresses: String?) : Topic()
data class preferenceList(val identifier: String?) : Topic()
data class userWelcome(val installationId: String?) : Topic()
data class groupMessage(val groupId: String?) : Topic()

/**
* Getting the [Topic] structured depending if is [userPrivateStoreKeyBundle], [contact],
Expand All @@ -29,10 +31,13 @@ sealed class Topic {

is directMessageV2 -> wrap("m-$addresses")
is preferenceList -> wrap("userpreferences-$identifier")
is groupMessage -> wrapMls("g-$groupId")
is userWelcome -> wrapMls("w-$installationId")
}
}

private fun wrap(value: String): String = "/xmtp/0/$value/proto"
private fun wrapMls(value: String): String = "/xmtp/mls/1/$value/proto"

companion object {
/**
Expand Down
19 changes: 14 additions & 5 deletions library/src/main/java/org/xmtp/android/library/push/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -82,7 +82,7 @@ These files can serve as the basis for what you might want to provide for your o

```kotlin
val hmacKeysResult = ClientManager.client.conversations.getHmacKeys()
val subscriptions = conversations.map {
val subscriptions: MutableList<Service.Subscription> = conversations.map {
val hmacKeys = hmacKeysResult.hmacKeysMap
val result = hmacKeys[it.topic]?.valuesList?.map { hmacKey ->
Service.Subscription.HmacKey.newBuilder().also { sub_key ->
Expand All @@ -96,11 +96,20 @@ These files can serve as the basis for what you might want to provide for your o
sub.topic = it.topic
sub.isSilent = it.version == Conversation.Version.V1
}.build()
}

XMTPPush(context, "10.0.2.2:8080").subscribeWithMetadata(subscriptions)
```
}.toMutableList()

// To get pushes for New Group (WelcomeMessages)
val welcomeTopic = Service.Subscription.newBuilder().also { sub ->
sub.topic = Topic.userWelcome(ClientManager.client.installationId).description
sub.isSilent = false
}.build()
subscriptions.add(welcomeTopic)

XMTPPush(context, "10.0.2.2:8080").subscribeWithMetadata(subscriptions)
```

```kotlin
XMTPPush(context, "10.0.2.2:8080").unsubscribe(conversations.map { it.topic })
```

8. See example in [PushNotificationsService](https://github.com/xmtp/xmtp-android/blob/main/example/src/main/java/org/xmtp/android/example/pushnotifications/PushNotificationsService.kt) for how to decrypt the different messages.
Loading
Loading