Skip to content

Commit

Permalink
Group Chat Push Notifications (#210)
Browse files Browse the repository at this point in the history
* bump the libxmtp version to the latest

* add the functions to group and convos

* add topics for mls

* bump to latest libxmtp again

* correctly set the install ids

* change all these places to the topic

* update the notification service to handle groups and welcome messages

* fix up linter issue

* change the installation id work around

* keep that stored

* small rename

* update the readme

* update the bindings

* add handling for different content types

* didnt mean to commit those
  • Loading branch information
nplasterer authored Apr 1, 2024
1 parent 8a39578 commit 6c44637
Show file tree
Hide file tree
Showing 16 changed files with 201 additions and 48 deletions.
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)
}
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

0 comments on commit 6c44637

Please sign in to comment.