diff --git a/library/src/androidTest/java/org/xmtp/android/library/ConversationTest.kt b/library/src/androidTest/java/org/xmtp/android/library/ConversationTest.kt index 9080cee1f..97465a85e 100644 --- a/library/src/androidTest/java/org/xmtp/android/library/ConversationTest.kt +++ b/library/src/androidTest/java/org/xmtp/android/library/ConversationTest.kt @@ -597,7 +597,7 @@ class ConversationTest { assertEquals(conversation.version, Conversation.Version.V1) val preparedMessage = conversation.prepareMessage(content = "hi") val messageID = preparedMessage.messageId - preparedMessage.send() + conversation.send(prepared = preparedMessage) val messages = conversation.messages() val message = messages[0] assertEquals("hi", message.body) @@ -609,7 +609,23 @@ class ConversationTest { val conversation = aliceClient.conversations.newConversation(bob.walletAddress) val preparedMessage = conversation.prepareMessage(content = "hi") val messageID = preparedMessage.messageId - preparedMessage.send() + conversation.send(prepared = preparedMessage) + val messages = conversation.messages() + val message = messages[0] + assertEquals("hi", message.body) + assertEquals(message.id, messageID) + } + + @Test + fun testCanSendPreparedMessageWithoutConversation() { + val conversation = aliceClient.conversations.newConversation(bob.walletAddress) + val preparedMessage = conversation.prepareMessage(content = "hi") + val messageID = preparedMessage.messageId + + // This does not need the `conversation` to `.publish` the message. + // This simulates a background task publishing all pending messages upon connection. + aliceClient.publish(envelopes = preparedMessage.envelopes) + val messages = conversation.messages() val message = messages[0] assertEquals("hi", message.body) diff --git a/library/src/main/java/org/xmtp/android/library/Conversation.kt b/library/src/main/java/org/xmtp/android/library/Conversation.kt index 48172140f..6a466b358 100644 --- a/library/src/main/java/org/xmtp/android/library/Conversation.kt +++ b/library/src/main/java/org/xmtp/android/library/Conversation.kt @@ -105,6 +105,13 @@ sealed class Conversation { } } + fun send(prepared: PreparedMessage) { + when (this) { + is V1 -> conversationV1.send(prepared = prepared) + is V2 -> conversationV2.send(prepared = prepared) + } + } + fun send(content: T, options: SendOptions? = null) { when (this) { is V1 -> conversationV1.send(content = content, options = options) diff --git a/library/src/main/java/org/xmtp/android/library/ConversationV1.kt b/library/src/main/java/org/xmtp/android/library/ConversationV1.kt index 357b54dfb..9890ef431 100644 --- a/library/src/main/java/org/xmtp/android/library/ConversationV1.kt +++ b/library/src/main/java/org/xmtp/android/library/ConversationV1.kt @@ -2,7 +2,6 @@ package org.xmtp.android.library import android.util.Log import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.collect import kotlinx.coroutines.flow.flow import kotlinx.coroutines.runBlocking import org.web3j.crypto.Hash @@ -89,20 +88,22 @@ data class ConversationV1( sentAt: Date? = null, ): String { val preparedMessage = prepareMessage(content = text, options = sendOptions) - preparedMessage.send() - return preparedMessage.messageId + return send(preparedMessage) } fun send(content: T, options: SendOptions? = null): String { val preparedMessage = prepareMessage(content = content, options = options) - preparedMessage.send() - return preparedMessage.messageId + return send(preparedMessage) } fun send(encodedContent: EncodedContent, options: SendOptions? = null): String { val preparedMessage = prepareMessage(encodedContent = encodedContent, options = options) - preparedMessage.send() - return preparedMessage.messageId + return send(preparedMessage) + } + + fun send(prepared: PreparedMessage): String { + client.publish(envelopes = prepared.envelopes) + return prepared.messageId } fun prepareMessage(content: T, options: SendOptions?): PreparedMessage { @@ -147,36 +148,28 @@ data class ConversationV1( val isEphemeral: Boolean = options != null && options.ephemeral - val messageEnvelope = + val env = EnvelopeBuilder.buildFromString( topic = if (isEphemeral) ephemeralTopic else topic.description, timestamp = date, message = MessageBuilder.buildFromMessageV1(v1 = message).toByteArray() ) - return PreparedMessage( - messageEnvelope = messageEnvelope, - conversation = Conversation.V1(this) - ) { - val envelopes = mutableListOf(messageEnvelope) - if (client.contacts.needsIntroduction(peerAddress) && !isEphemeral) { - envelopes.addAll( - listOf( - EnvelopeBuilder.buildFromTopic( - topic = Topic.userIntro(peerAddress), - timestamp = date, - message = MessageBuilder.buildFromMessageV1(v1 = message).toByteArray() - ), - EnvelopeBuilder.buildFromTopic( - topic = Topic.userIntro(client.address), - timestamp = date, - message = MessageBuilder.buildFromMessageV1(v1 = message).toByteArray() - ) - ) + + val envelopes = mutableListOf(env) + if (client.contacts.needsIntroduction(peerAddress) && !isEphemeral) { + envelopes.addAll( + listOf( + env.toBuilder().apply { + contentTopic = Topic.userIntro(peerAddress).description + }.build(), + env.toBuilder().apply { + contentTopic = Topic.userIntro(client.address).description + }.build(), ) - client.contacts.hasIntroduced[peerAddress] = true - } - client.publish(envelopes = envelopes) + ) + client.contacts.hasIntroduced[peerAddress] = true } + return PreparedMessage(envelopes) } private fun generateId(envelope: Envelope): String = diff --git a/library/src/main/java/org/xmtp/android/library/ConversationV2.kt b/library/src/main/java/org/xmtp/android/library/ConversationV2.kt index 9d4e99c18..170e74760 100644 --- a/library/src/main/java/org/xmtp/android/library/ConversationV2.kt +++ b/library/src/main/java/org/xmtp/android/library/ConversationV2.kt @@ -100,20 +100,22 @@ data class ConversationV2( fun send(content: T, options: SendOptions? = null): String { val preparedMessage = prepareMessage(content = content, options = options) - preparedMessage.send() - return preparedMessage.messageId + return send(preparedMessage) } fun send(text: String, options: SendOptions? = null, sentAt: Date? = null): String { val preparedMessage = prepareMessage(content = text, options = options) - preparedMessage.send() - return preparedMessage.messageId + return send(preparedMessage) } fun send(encodedContent: EncodedContent, options: SendOptions?): String { val preparedMessage = prepareMessage(encodedContent = encodedContent, options = options) - preparedMessage.send() - return preparedMessage.messageId + return send(preparedMessage) + } + + fun send(prepared: PreparedMessage): String { + client.publish(envelopes = prepared.envelopes) + return prepared.messageId } fun , T> encode(codec: Codec, content: T): ByteArray { @@ -170,9 +172,7 @@ data class ConversationV2( timestamp = Date(), message = MessageBuilder.buildFromMessageV2(v2 = message).toByteArray() ) - return PreparedMessage(messageEnvelope = envelope, conversation = Conversation.V2(this)) { - client.publish(envelopes = listOf(envelope)) - } + return PreparedMessage(listOf(envelope)) } private fun generateId(envelope: Envelope): String = diff --git a/library/src/main/java/org/xmtp/android/library/PreparedMessage.kt b/library/src/main/java/org/xmtp/android/library/PreparedMessage.kt index 025cdf8ee..6bfc2cf48 100644 --- a/library/src/main/java/org/xmtp/android/library/PreparedMessage.kt +++ b/library/src/main/java/org/xmtp/android/library/PreparedMessage.kt @@ -2,20 +2,37 @@ package org.xmtp.android.library import org.web3j.crypto.Hash import org.xmtp.android.library.messages.Envelope +import org.xmtp.proto.message.api.v1.MessageApiOuterClass.PublishRequest +// This houses a fully prepared message that can be published +// as soon as the API client has connectivity. +// +// To support persistence layers that queue pending messages (e.g. while offline) +// this struct supports serializing to/from bytes that can be written to disk or elsewhere. +// See toSerializedData() and fromSerializedData() data class PreparedMessage( - var messageEnvelope: Envelope, - var conversation: Conversation, - var onSend: () -> Unit, + // The first envelope should send the message to the conversation itself. + // Any more are for required intros/invites etc. + // A client can just publish these when it has connectivity. + val envelopes: List ) { + companion object { + fun fromSerializedData(data: ByteArray): PreparedMessage { + val req = PublishRequest.parseFrom(data) + return PreparedMessage(req.envelopesList) + } + } - fun decodedMessage(): DecodedMessage = - conversation.decode(messageEnvelope) - - fun send() { - onSend() + fun toSerializedData(): ByteArray { + val req = PublishRequest.newBuilder() + .addAllEnvelopes(envelopes) + .build() + return req.toByteArray() } val messageId: String - get() = Hash.sha256(messageEnvelope.message.toByteArray()).toHex() + get() = Hash.sha256(envelopes.first().message.toByteArray()).toHex() + + val conversationTopic: String + get() = envelopes.first().contentTopic } diff --git a/library/src/test/java/org/xmtp/android/library/PreparedMessageTest.kt b/library/src/test/java/org/xmtp/android/library/PreparedMessageTest.kt new file mode 100644 index 000000000..e2cbd5d2c --- /dev/null +++ b/library/src/test/java/org/xmtp/android/library/PreparedMessageTest.kt @@ -0,0 +1,30 @@ +package org.xmtp.android.library + +import com.google.protobuf.kotlin.toByteStringUtf8 +import org.junit.Assert.assertEquals +import org.junit.Test +import org.xmtp.android.library.messages.Envelope + +class PreparedMessageTest { + + @Test + fun testSerializing() { + val original = PreparedMessage( + listOf( + Envelope.newBuilder().apply { + contentTopic = "topic1" + timestampNs = 1234 + message = "abc123".toByteStringUtf8() + }.build(), + Envelope.newBuilder().apply { + contentTopic = "topic2" + timestampNs = 5678 + message = "def456".toByteStringUtf8() + }.build(), + ) + ) + val serialized = original.toSerializedData() + val unserialized = PreparedMessage.fromSerializedData(serialized) + assertEquals(original, unserialized) + } +}