Skip to content

Commit e1d1450

Browse files
authored
feat: support import/export of conversations using TopicData (#84)
1 parent 9b6acda commit e1d1450

File tree

3 files changed

+97
-0
lines changed

3 files changed

+97
-0
lines changed

library/src/androidTest/java/org/xmtp/android/library/LocalInstrumentedTest.kt

+46
Original file line numberDiff line numberDiff line change
@@ -20,10 +20,12 @@ import org.xmtp.android.library.messages.generate
2020
import org.xmtp.android.library.messages.secp256K1Uncompressed
2121
import org.xmtp.android.library.messages.toPublicKeyBundle
2222
import org.xmtp.android.library.messages.walletAddress
23+
import org.xmtp.proto.keystore.api.v1.Keystore
2324
import org.xmtp.proto.message.api.v1.MessageApiOuterClass.QueryRequest
2425
import org.xmtp.proto.message.contents.Contact
2526
import org.xmtp.proto.message.contents.InvitationV1Kt.context
2627
import org.xmtp.proto.message.contents.PrivateKeyOuterClass
28+
import org.xmtp.proto.message.contents.PrivateKeyOuterClass.PrivateKeyBundle
2729
import java.util.Date
2830

2931
@RunWith(AndroidJUnit4::class)
@@ -186,6 +188,50 @@ class LocalInstrumentedTest {
186188
assertEquals("example.com/alice-bob-1", aliceConvoList[1].conversationId)
187189
}
188190

191+
@Test
192+
fun testUsingSavedCredentialsAndKeyMaterial() {
193+
val options = ClientOptions(ClientOptions.Api(XMTPEnvironment.LOCAL, isSecure = false))
194+
val alice = Client().create(PrivateKeyBuilder(), options)
195+
val bob = Client().create(PrivateKeyBuilder(), options)
196+
197+
// Alice starts a conversation with Bob
198+
val aliceConvo = alice.conversations.newConversation(
199+
bob.address,
200+
context = context {
201+
conversationId = "example.com/alice-bob-1"
202+
metadata["title"] = "Chatting Using Saved Credentials"
203+
}
204+
)
205+
aliceConvo.send("Hello Bob")
206+
delayToPropagate()
207+
208+
// Alice stores her credentials and conversations to her device
209+
val keyBundle = alice.privateKeyBundle.toByteArray()
210+
val topicData = aliceConvo.toTopicData().toByteArray()
211+
212+
// Meanwhile, Bob sends a reply.
213+
val bobConvos = bob.conversations.list()
214+
val bobConvo = bobConvos[0]
215+
bobConvo.send("Oh, hello Alice")
216+
delayToPropagate()
217+
218+
// When Alice's device wakes up, it uses her saved credentials
219+
val alice2 = Client().buildFromBundle(
220+
PrivateKeyBundle.parseFrom(keyBundle),
221+
options
222+
)
223+
// And it uses the saved topic data for the conversation
224+
val aliceConvo2 = alice2.conversations.importTopicData(
225+
Keystore.TopicMap.TopicData.parseFrom(topicData)
226+
)
227+
assertEquals("example.com/alice-bob-1", aliceConvo2.conversationId)
228+
229+
// Now Alice should be able to load message using her saved key material.
230+
val messages = aliceConvo2.messages()
231+
assertEquals("Hello Bob", messages[1].body)
232+
assertEquals("Oh, hello Alice", messages[0].body)
233+
}
234+
189235
@Test
190236
fun testCanPaginateV1Messages() {
191237
val bob = PrivateKeyBuilder()

library/src/main/java/org/xmtp/android/library/Conversation.kt

+22
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,13 @@
11
package org.xmtp.android.library
22

33
import android.util.Log
4+
import com.google.protobuf.kotlin.toByteString
45
import kotlinx.coroutines.flow.Flow
56
import org.xmtp.android.library.codecs.EncodedContent
67
import org.xmtp.android.library.messages.Envelope
8+
import org.xmtp.proto.keystore.api.v1.Keystore.TopicMap.TopicData
9+
import org.xmtp.proto.message.contents.Invitation
10+
import org.xmtp.proto.message.contents.Invitation.InvitationV1.Aes256gcmHkdfsha256
711
import java.util.Date
812

913
sealed class Conversation {
@@ -63,6 +67,24 @@ sealed class Conversation {
6367
}
6468
}
6569

70+
fun toTopicData(): TopicData {
71+
val data = TopicData.newBuilder()
72+
.setCreatedNs(createdAt.time * 1_000_000)
73+
.setPeerAddress(peerAddress)
74+
return when (this) {
75+
is V1 -> data.build()
76+
is V2 -> data.setInvitation(
77+
Invitation.InvitationV1.newBuilder()
78+
.setTopic(topic)
79+
.setContext(conversationV2.context)
80+
.setAes256GcmHkdfSha256(
81+
Aes256gcmHkdfsha256.newBuilder()
82+
.setKeyMaterial(conversationV2.keyMaterial.toByteString())
83+
)
84+
).build()
85+
}
86+
}
87+
6688
fun decode(envelope: Envelope): DecodedMessage {
6789
return when (this) {
6890
is V1 -> conversationV1.decode(envelope)

library/src/main/java/org/xmtp/android/library/Conversations.kt

+29
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ import org.xmtp.android.library.messages.senderAddress
2424
import org.xmtp.android.library.messages.sentAt
2525
import org.xmtp.android.library.messages.toSignedPublicKeyBundle
2626
import org.xmtp.android.library.messages.walletAddress
27+
import org.xmtp.proto.keystore.api.v1.Keystore.TopicMap.TopicData
2728
import org.xmtp.proto.message.contents.Contact
2829
import org.xmtp.proto.message.contents.Invitation
2930
import java.lang.Exception
@@ -183,6 +184,34 @@ data class Conversations(
183184
return conversationsByTopic.values.sortedByDescending { it.createdAt }
184185
}
185186

187+
fun importTopicData(data: TopicData): Conversation {
188+
val conversation: Conversation
189+
if (!data.hasInvitation()) {
190+
val sentAt = Date(data.createdNs / 1_000_000)
191+
conversation = Conversation.V1(
192+
ConversationV1(
193+
client,
194+
data.peerAddress,
195+
sentAt
196+
)
197+
)
198+
} else {
199+
conversation = Conversation.V2(
200+
ConversationV2(
201+
topic = data.invitation.topic,
202+
keyMaterial = data.invitation.aes256GcmHkdfSha256.keyMaterial.toByteArray(),
203+
context = data.invitation.context,
204+
peerAddress = data.peerAddress,
205+
client = client,
206+
isGroup = false,
207+
header = Invitation.SealedInvitationHeaderV1.getDefaultInstance()
208+
)
209+
)
210+
}
211+
conversationsByTopic[conversation.topic] = conversation
212+
return conversation
213+
}
214+
186215
private fun listIntroductionPeers(pagination: Pagination? = null): Map<String, Date> {
187216
val envelopes =
188217
runBlocking {

0 commit comments

Comments
 (0)