diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 352f9428a..8cfeff822 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -24,37 +24,35 @@ jobs: uses: gradle/gradle-build-action@v2 - name: Validate Gradle Wrapper uses: gradle/wrapper-validation-action@v1 - - name: Start local test server - run: docker-compose -p xmtp -f dev/local/docker-compose.yml up -d + - name: Start Docker containers + run: dev/up - name: Gradle Run Unit Tests run: ./gradlew library:testDebug - - name: Stop local test server - run: docker-compose -p xmtp -f dev/local/docker-compose.yml down library-integration: name: Library (Integration Tests) - runs-on: macos-latest + runs-on: ubuntu-latest steps: - - name: Checkout project sources - uses: actions/checkout@v3 - - uses: actions/setup-java@v3 + - name: Checkout + uses: actions/checkout@v4 with: - distribution: 'adopt' - java-version: '11' - - name: Setup Gradle + fetch-depth: 0 + - name: Configure JDK + uses: actions/setup-java@v4 + with: + distribution: 'zulu' + java-version: 11 + - name: Enable KVM group perms + run: | + echo 'KERNEL=="kvm", GROUP="kvm", MODE="0666", OPTIONS+="static_node=kvm"' | sudo tee /etc/udev/rules.d/99-kvm4all.rules + sudo udevadm control --reload-rules + sudo udevadm trigger --name-match=kvm + - name: Gradle cache uses: gradle/gradle-build-action@v2 - - name: Validate Gradle Wrapper - uses: gradle/wrapper-validation-action@v1 - - name: Set up Docker - run: brew install docker docker-compose - - name: Start Colima - run: colima start - - name: Start local test server - run: docker-compose -p xmtp -f dev/local/docker-compose.yml up -d + - name: Start Docker containers + run: dev/up - name: Gradle Run Integration Tests uses: reactivecircus/android-emulator-runner@v2 with: api-level: 29 script: ./gradlew connectedCheck - - name: Stop local test server - run: docker-compose -p xmtp -f dev/local/docker-compose.yml down diff --git a/dev/local/compose b/dev/local/compose new file mode 100755 index 000000000..3bf5bb4fc --- /dev/null +++ b/dev/local/compose @@ -0,0 +1,4 @@ +#!/bin/bash +set -eou pipefail + +docker-compose -f dev/local/docker-compose.yml -p "xmtp-android" "$@" diff --git a/dev/local/docker-compose.yml b/dev/local/docker-compose.yml index 262f9d7f3..9a96bb356 100644 --- a/dev/local/docker-compose.yml +++ b/dev/local/docker-compose.yml @@ -1,5 +1,5 @@ services: - waku-node: + node: image: xmtp/node-go:latest platform: linux/amd64 environment: @@ -8,14 +8,27 @@ services: - --store.enable - --store.db-connection-string=postgres://postgres:xmtp@db:5432/postgres?sslmode=disable - --store.reader-db-connection-string=postgres://postgres:xmtp@db:5432/postgres?sslmode=disable + - --mls-store.db-connection-string=postgres://postgres:xmtp@mlsdb:5432/postgres?sslmode=disable + - --mls-validation.grpc-address=validation:50051 + - --api.enable-mls - --wait-for-db=30s - --api.authn.enable ports: - - 9001:9001 - 5555:5555 + - 5556:5556 depends_on: - db + + validation: + image: xmtp/mls-validation-service:latest + platform: linux/amd64 + db: image: postgres:13 environment: - POSTGRES_PASSWORD: xmtp \ No newline at end of file + POSTGRES_PASSWORD: xmtp + + mlsdb: + image: postgres:13 + environment: + POSTGRES_PASSWORD: xmtp diff --git a/dev/local/up b/dev/local/up new file mode 100755 index 000000000..633f0db7b --- /dev/null +++ b/dev/local/up @@ -0,0 +1,6 @@ +#!/bin/bash +set -eou pipefail +script_dir="$( cd -- "$( dirname -- "${BASH_SOURCE[0]}" )" &> /dev/null && pwd )" + +"${script_dir}"/compose pull +"${script_dir}"/compose up -d --build \ No newline at end of file diff --git a/dev/up b/dev/up new file mode 100755 index 000000000..49ea0deb8 --- /dev/null +++ b/dev/up @@ -0,0 +1,18 @@ +#!/bin/bash +set -eou pipefail + +if [[ "${OSTYPE}" == "darwin"* ]]; then + if ! which buf &>/dev/null; then brew install buf; fi + if ! which shellcheck &>/dev/null; then brew install shellcheck; fi + if ! which markdownlint &>/dev/null; then brew install markdownlint-cli; fi + if ! java -version &>/dev/null; then + brew install java + sudo ln -sfn /opt/homebrew/opt/openjdk/libexec/openjdk.jdk \ + /Library/Java/JavaVirtualMachines/ + fi + if ! kotlinc -version &>/dev/null; then brew install kotlin; fi +fi + +rustup update + +dev/local/up diff --git a/example/src/main/java/org/xmtp/android/example/ClientManager.kt b/example/src/main/java/org/xmtp/android/example/ClientManager.kt index 671a66ea5..77f89fc88 100644 --- a/example/src/main/java/org/xmtp/android/example/ClientManager.kt +++ b/example/src/main/java/org/xmtp/android/example/ClientManager.kt @@ -1,5 +1,6 @@ package org.xmtp.android.example +import android.content.Context import androidx.annotation.UiThread import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.GlobalScope @@ -10,10 +11,21 @@ import org.xmtp.android.library.Client import org.xmtp.android.library.ClientOptions import org.xmtp.android.library.XMTPEnvironment import org.xmtp.android.library.messages.PrivateKeyBundleV1Builder +import uniffi.xmtpv3.org.xmtp.android.library.codecs.GroupMembershipChangeCodec object ClientManager { - val CLIENT_OPTIONS = ClientOptions(api = ClientOptions.Api(XMTPEnvironment.DEV, appVersion = "XMTPAndroidExample/v1.0.0")) + fun clientOptions(appContext: Context?): ClientOptions { + return ClientOptions( + api = ClientOptions.Api( + XMTPEnvironment.LOCAL, + appVersion = "XMTPAndroidExample/v1.0.0", + isSecure = false + ), + enableAlphaMls = true, + appContext = appContext + ) + } private val _clientState = MutableStateFlow(ClientState.Unknown) val clientState: StateFlow = _clientState @@ -28,13 +40,14 @@ object ClientManager { } @UiThread - fun createClient(encodedPrivateKeyData: String) { + fun createClient(encodedPrivateKeyData: String, appContext: Context) { if (clientState.value is ClientState.Ready) return GlobalScope.launch(Dispatchers.IO) { try { val v1Bundle = PrivateKeyBundleV1Builder.fromEncodedData(data = encodedPrivateKeyData) - _client = Client().buildFrom(v1Bundle, CLIENT_OPTIONS) + _client = Client().buildFrom(v1Bundle, clientOptions(appContext)) + Client.register(codec = GroupMembershipChangeCodec()) _clientState.value = ClientState.Ready } catch (e: Exception) { _clientState.value = ClientState.Error(e.localizedMessage.orEmpty()) diff --git a/example/src/main/java/org/xmtp/android/example/MainActivity.kt b/example/src/main/java/org/xmtp/android/example/MainActivity.kt index 87eb367d8..5c0460dd7 100644 --- a/example/src/main/java/org/xmtp/android/example/MainActivity.kt +++ b/example/src/main/java/org/xmtp/android/example/MainActivity.kt @@ -22,6 +22,7 @@ import org.xmtp.android.example.conversation.ConversationDetailActivity import org.xmtp.android.example.conversation.ConversationsAdapter import org.xmtp.android.example.conversation.ConversationsClickListener import org.xmtp.android.example.conversation.NewConversationBottomSheet +import org.xmtp.android.example.conversation.NewGroupBottomSheet import org.xmtp.android.example.databinding.ActivityMainBinding import org.xmtp.android.example.pushnotifications.PushNotificationTokenManager import org.xmtp.android.example.utils.KeyUtil @@ -35,6 +36,7 @@ class MainActivity : AppCompatActivity(), private lateinit var accountManager: AccountManager private lateinit var adapter: ConversationsAdapter private var bottomSheet: NewConversationBottomSheet? = null + private var groupBottomSheet: NewGroupBottomSheet? = null override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) @@ -48,7 +50,7 @@ class MainActivity : AppCompatActivity(), return } - ClientManager.createClient(keys) + ClientManager.createClient(keys, this) binding = ActivityMainBinding.inflate(layoutInflater) setContentView(binding.root) @@ -67,6 +69,10 @@ class MainActivity : AppCompatActivity(), openConversationDetail() } + binding.groupFab.setOnClickListener { + openGroupDetail() + } + lifecycleScope.launch { repeatOnLifecycle(Lifecycle.State.STARTED) { ClientManager.clientState.collect(::ensureClientState) @@ -86,6 +92,7 @@ class MainActivity : AppCompatActivity(), override fun onDestroy() { bottomSheet?.dismiss() + groupBottomSheet?.dismiss() super.onDestroy() } @@ -127,6 +134,7 @@ class MainActivity : AppCompatActivity(), is ClientManager.ClientState.Ready -> { viewModel.fetchConversations() binding.fab.visibility = View.VISIBLE + binding.groupFab.visibility = View.VISIBLE } is ClientManager.ClientState.Error -> showError(clientState.message) is ClientManager.ClientState.Unknown -> Unit @@ -193,4 +201,11 @@ class MainActivity : AppCompatActivity(), NewConversationBottomSheet.TAG ) } + private fun openGroupDetail() { + groupBottomSheet = NewGroupBottomSheet.newInstance() + groupBottomSheet?.show( + supportFragmentManager, + NewGroupBottomSheet.TAG + ) + } } diff --git a/example/src/main/java/org/xmtp/android/example/MainViewModel.kt b/example/src/main/java/org/xmtp/android/example/MainViewModel.kt index 9b12419a4..882e3dbb3 100644 --- a/example/src/main/java/org/xmtp/android/example/MainViewModel.kt +++ b/example/src/main/java/org/xmtp/android/example/MainViewModel.kt @@ -42,7 +42,7 @@ class MainViewModel : ViewModel() { viewModelScope.launch(Dispatchers.IO) { val listItems = mutableListOf() try { - val conversations = ClientManager.client.conversations.list() + val conversations = ClientManager.client.conversations.list(includeGroups = true) PushNotificationTokenManager.xmtpPush.subscribe(conversations.map { it.topic }) listItems.addAll( conversations.map { conversation -> diff --git a/example/src/main/java/org/xmtp/android/example/connect/ConnectWalletViewModel.kt b/example/src/main/java/org/xmtp/android/example/connect/ConnectWalletViewModel.kt index 5255f0d21..0d6c899a5 100644 --- a/example/src/main/java/org/xmtp/android/example/connect/ConnectWalletViewModel.kt +++ b/example/src/main/java/org/xmtp/android/example/connect/ConnectWalletViewModel.kt @@ -1,8 +1,9 @@ package org.xmtp.android.example.connect +import android.app.Application import android.net.Uri import androidx.annotation.UiThread -import androidx.lifecycle.ViewModel +import androidx.lifecycle.AndroidViewModel import androidx.lifecycle.viewModelScope import com.walletconnect.wcmodal.client.Modal import kotlinx.coroutines.Dispatchers @@ -20,8 +21,9 @@ import org.xmtp.android.library.Client import org.xmtp.android.library.XMTPException import org.xmtp.android.library.messages.PrivateKeyBuilder import org.xmtp.android.library.messages.PrivateKeyBundleV1Builder +import uniffi.xmtpv3.org.xmtp.android.library.codecs.GroupMembershipChangeCodec -class ConnectWalletViewModel : ViewModel() { +class ConnectWalletViewModel(application: Application) : AndroidViewModel(application) { private val chains: List = Chains.values().map { it.toChainUiState() } @@ -84,7 +86,8 @@ class ConnectWalletViewModel : ViewModel() { _uiState.value = ConnectUiState.Loading try { val wallet = PrivateKeyBuilder() - val client = Client().create(wallet, ClientManager.CLIENT_OPTIONS) + val client = Client().create(wallet, ClientManager.clientOptions(getApplication())) + Client.register(codec = GroupMembershipChangeCodec()) _uiState.value = ConnectUiState.Success( wallet.address, PrivateKeyBundleV1Builder.encodeData(client.privateKeyBundleV1) @@ -108,7 +111,8 @@ class ConnectWalletViewModel : ViewModel() { it.copy(showWallet = true, uri = uri) } } - val client = Client().create(wallet, ClientManager.CLIENT_OPTIONS) + val client = Client().create(wallet, ClientManager.clientOptions(getApplication())) + Client.register(codec = GroupMembershipChangeCodec()) _uiState.value = ConnectUiState.Success( wallet.address, PrivateKeyBundleV1Builder.encodeData(client.privateKeyBundleV1) diff --git a/example/src/main/java/org/xmtp/android/example/conversation/ConversationDetailActivity.kt b/example/src/main/java/org/xmtp/android/example/conversation/ConversationDetailActivity.kt index b7b1c702d..6e0580093 100644 --- a/example/src/main/java/org/xmtp/android/example/conversation/ConversationDetailActivity.kt +++ b/example/src/main/java/org/xmtp/android/example/conversation/ConversationDetailActivity.kt @@ -54,7 +54,14 @@ class ConversationDetailActivity : AppCompatActivity() { setContentView(binding.root) setSupportActionBar(binding.toolbar) supportActionBar?.setDisplayHomeAsUpEnabled(true) - supportActionBar?.subtitle = peerAddress?.truncatedAddress() + supportActionBar?.subtitle = if (peerAddress != null && peerAddress!!.contains(",")) { + val addresses = peerAddress?.split(",")?.toMutableList() + addresses?.joinToString(" & ") { + it.truncatedAddress() + } + } else { + peerAddress?.truncatedAddress() + } adapter = MessageAdapter() binding.list.layoutManager = @@ -106,10 +113,12 @@ class ConversationDetailActivity : AppCompatActivity() { finish() true } + R.id.copy_address -> { copyWalletAddress() true } + else -> super.onOptionsItemSelected(item) } } @@ -130,10 +139,12 @@ class ConversationDetailActivity : AppCompatActivity() { adapter.setData(uiState.listItems) } } + is ConversationDetailViewModel.UiState.Success -> { binding.refresh.isRefreshing = false adapter.setData(uiState.listItems) } + is ConversationDetailViewModel.UiState.Error -> { binding.refresh.isRefreshing = false showError(uiState.message) @@ -146,10 +157,12 @@ class ConversationDetailActivity : AppCompatActivity() { is ConversationDetailViewModel.SendMessageState.Error -> { showError(sendState.message) } + ConversationDetailViewModel.SendMessageState.Loading -> { binding.sendButton.isEnabled = false binding.messageEditText.isEnabled = false } + ConversationDetailViewModel.SendMessageState.Success -> { binding.messageEditText.text.clear() binding.messageEditText.isEnabled = true diff --git a/example/src/main/java/org/xmtp/android/example/conversation/ConversationDetailViewModel.kt b/example/src/main/java/org/xmtp/android/example/conversation/ConversationDetailViewModel.kt index 4fb5374c7..b97390a87 100644 --- a/example/src/main/java/org/xmtp/android/example/conversation/ConversationDetailViewModel.kt +++ b/example/src/main/java/org/xmtp/android/example/conversation/ConversationDetailViewModel.kt @@ -20,6 +20,7 @@ import org.xmtp.android.example.extension.flowWhileShared import org.xmtp.android.example.extension.stateFlow import org.xmtp.android.library.Conversation import org.xmtp.android.library.DecodedMessage +import org.xmtp.android.library.Group class ConversationDetailViewModel(private val savedStateHandle: SavedStateHandle) : ViewModel() { @@ -49,9 +50,15 @@ class ConversationDetailViewModel(private val savedStateHandle: SavedStateHandle val listItems = mutableListOf() try { if (conversation == null) { - conversation = ClientManager.client.fetchConversation(conversationTopic) + conversation = ClientManager.client.fetchConversation( + conversationTopic, + includeGroups = true + ) } conversation?.let { + if (conversation is Conversation.Group) { + (conversation as Conversation.Group).group.sync() + } listItems.addAll( it.messages().map { message -> MessageListItem.Message(message.id, message) @@ -69,7 +76,8 @@ class ConversationDetailViewModel(private val savedStateHandle: SavedStateHandle val streamMessages: StateFlow = stateFlow(viewModelScope, null) { subscriptionCount -> if (conversation == null) { - conversation = ClientManager.client.fetchConversation(conversationTopic) + conversation = + ClientManager.client.fetchConversation(conversationTopic, includeGroups = false) } if (conversation != null) { conversation!!.streamMessages() diff --git a/example/src/main/java/org/xmtp/android/example/conversation/ConversationViewHolder.kt b/example/src/main/java/org/xmtp/android/example/conversation/ConversationViewHolder.kt index 2105d61b7..adea7de41 100644 --- a/example/src/main/java/org/xmtp/android/example/conversation/ConversationViewHolder.kt +++ b/example/src/main/java/org/xmtp/android/example/conversation/ConversationViewHolder.kt @@ -7,10 +7,11 @@ import org.xmtp.android.example.R import org.xmtp.android.example.databinding.ListItemConversationBinding import org.xmtp.android.example.extension.truncatedAddress import org.xmtp.android.library.Conversation +import uniffi.xmtpv3.org.xmtp.android.library.codecs.GroupMembershipChanges class ConversationViewHolder( private val binding: ListItemConversationBinding, - clickListener: ConversationsClickListener + clickListener: ConversationsClickListener, ) : RecyclerView.ViewHolder(binding.root) { private var conversation: Conversation? = null @@ -25,8 +26,25 @@ class ConversationViewHolder( fun bind(item: MainViewModel.MainListItem.ConversationItem) { conversation = item.conversation - binding.peerAddress.text = item.conversation.peerAddress.truncatedAddress() - val messageBody = item.mostRecentMessage?.body.orEmpty() + binding.peerAddress.text = if (item.conversation.peerAddress.contains(",")) { + val addresses = item.conversation.peerAddress.split(",") + addresses.joinToString(" & ") { + it.truncatedAddress() + } + } else { + item.conversation.peerAddress.truncatedAddress() + } + + val messageBody: String = if (item.mostRecentMessage?.content() is String) { + item.mostRecentMessage.body.orEmpty() + } else if (item.mostRecentMessage?.content() is GroupMembershipChanges) { + val changes = item.mostRecentMessage.content() as? GroupMembershipChanges + "Membership Changed ${ + changes?.membersAddedList?.mapNotNull { it.accountAddress }.toString() + }" + } else { + "" + } val isMe = item.mostRecentMessage?.senderAddress == ClientManager.client.address if (messageBody.isNotBlank()) { binding.messageBody.text = if (isMe) binding.root.resources.getString( diff --git a/example/src/main/java/org/xmtp/android/example/conversation/NewConversationViewModel.kt b/example/src/main/java/org/xmtp/android/example/conversation/NewConversationViewModel.kt index 7a2cd3341..bb973f619 100644 --- a/example/src/main/java/org/xmtp/android/example/conversation/NewConversationViewModel.kt +++ b/example/src/main/java/org/xmtp/android/example/conversation/NewConversationViewModel.kt @@ -28,6 +28,19 @@ class NewConversationViewModel : ViewModel() { } } + @UiThread + fun createGroup(addresses: List) { + _uiState.value = UiState.Loading + viewModelScope.launch(Dispatchers.IO) { + try { + val group = ClientManager.client.conversations.newGroup(addresses) + _uiState.value = UiState.Success(Conversation.Group(group)) + } catch (e: Exception) { + _uiState.value = UiState.Error(e.localizedMessage.orEmpty()) + } + } + } + sealed class UiState { object Unknown : UiState() object Loading : UiState() diff --git a/example/src/main/java/org/xmtp/android/example/conversation/NewGroupBottomSheet.kt b/example/src/main/java/org/xmtp/android/example/conversation/NewGroupBottomSheet.kt new file mode 100644 index 000000000..300f00530 --- /dev/null +++ b/example/src/main/java/org/xmtp/android/example/conversation/NewGroupBottomSheet.kt @@ -0,0 +1,113 @@ +package org.xmtp.android.example.conversation + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.View.VISIBLE +import android.view.ViewGroup +import android.widget.Toast +import androidx.core.widget.addTextChangedListener +import androidx.fragment.app.viewModels +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.lifecycleScope +import androidx.lifecycle.repeatOnLifecycle +import com.google.android.material.bottomsheet.BottomSheetDialogFragment +import kotlinx.coroutines.launch +import org.xmtp.android.example.R +import org.xmtp.android.example.databinding.BottomSheetNewConversationBinding +import org.xmtp.android.example.databinding.BottomSheetNewGroupBinding +import java.util.regex.Pattern + +class NewGroupBottomSheet : BottomSheetDialogFragment() { + + private val viewModel: NewConversationViewModel by viewModels() + private var _binding: BottomSheetNewGroupBinding? = null + private val addresses: MutableList = mutableListOf() + private val binding get() = _binding!! + + companion object { + const val TAG = "NewGroupBottomSheet" + + private val ADDRESS_PATTERN = Pattern.compile("^0x[a-fA-F0-9]{40}\$") + + fun newInstance(): NewGroupBottomSheet { + return NewGroupBottomSheet() + } + } + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View { + _binding = BottomSheetNewGroupBinding.inflate(inflater, container, false) + return binding.root + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + + lifecycleScope.launch { + repeatOnLifecycle(Lifecycle.State.STARTED) { + viewModel.uiState.collect(::ensureUiState) + } + } + + binding.addressInput1.addTextChangedListener { + if (viewModel.uiState.value is NewConversationViewModel.UiState.Loading) return@addTextChangedListener + val input = binding.addressInput1.text.trim() + val matcher = ADDRESS_PATTERN.matcher(input) + if (matcher.matches()) { + addresses.add(input.toString()) + binding.addressInput2.visibility = VISIBLE + } + } + + binding.addressInput2.addTextChangedListener { + if (viewModel.uiState.value is NewConversationViewModel.UiState.Loading) return@addTextChangedListener + val input = binding.addressInput2.text.trim() + val matcher = ADDRESS_PATTERN.matcher(input) + if (matcher.matches()) { + addresses.add(input.toString()) + viewModel.createGroup(addresses) + } + } + } + + override fun onDestroyView() { + super.onDestroyView() + _binding = null + } + + private fun ensureUiState(uiState: NewConversationViewModel.UiState) { + when (uiState) { + is NewConversationViewModel.UiState.Error -> { + binding.addressInput1.isEnabled = true + binding.addressInput2.isEnabled = true + binding.progress.visibility = View.GONE + showError(uiState.message) + } + NewConversationViewModel.UiState.Loading -> { + binding.addressInput1.isEnabled = false + binding.addressInput2.isEnabled = false + binding.progress.visibility = View.VISIBLE + } + is NewConversationViewModel.UiState.Success -> { + startActivity( + ConversationDetailActivity.intent( + requireContext(), + topic = uiState.conversation.topic, + peerAddress = uiState.conversation.peerAddress + ) + ) + dismiss() + } + NewConversationViewModel.UiState.Unknown -> Unit + } + } + + private fun showError(message: String) { + val error = message.ifBlank { resources.getString(R.string.error) } + Toast.makeText(requireContext(), error, Toast.LENGTH_SHORT).show() + } +} diff --git a/example/src/main/java/org/xmtp/android/example/message/MessageViewHolder.kt b/example/src/main/java/org/xmtp/android/example/message/MessageViewHolder.kt index 41cfb8912..d88bfbecb 100644 --- a/example/src/main/java/org/xmtp/android/example/message/MessageViewHolder.kt +++ b/example/src/main/java/org/xmtp/android/example/message/MessageViewHolder.kt @@ -1,5 +1,6 @@ package org.xmtp.android.example.message +import android.annotation.SuppressLint import android.graphics.Color import androidx.constraintlayout.widget.ConstraintLayout import androidx.constraintlayout.widget.ConstraintLayout.LayoutParams.PARENT_ID @@ -10,9 +11,11 @@ import org.xmtp.android.example.R import org.xmtp.android.example.conversation.ConversationDetailViewModel import org.xmtp.android.example.databinding.ListItemMessageBinding import org.xmtp.android.example.extension.margins +import org.xmtp.proto.mls.message.contents.TranscriptMessages +import uniffi.xmtpv3.org.xmtp.android.library.codecs.GroupMembershipChanges class MessageViewHolder( - private val binding: ListItemMessageBinding + private val binding: ListItemMessageBinding, ) : RecyclerView.ViewHolder(binding.root) { private val marginLarge = binding.root.resources.getDimensionPixelSize(R.dimen.message_margin) @@ -21,8 +24,10 @@ class MessageViewHolder( private val backgroundPeer = binding.root.resources.getColor(R.color.teal_700, binding.root.context.theme) + @SuppressLint("SetTextI18n") fun bind(item: ConversationDetailViewModel.MessageListItem.Message) { - val isFromMe = ClientManager.client.address == item.message.senderAddress + val isFromMe = + ClientManager.client.address.lowercase() == item.message.senderAddress.lowercase() val params = binding.messageContainer.layoutParams as ConstraintLayout.LayoutParams if (isFromMe) { params.rightToRight = PARENT_ID @@ -38,6 +43,14 @@ class MessageViewHolder( binding.messageBody.setTextColor(Color.WHITE) } binding.messageContainer.layoutParams = params - binding.messageBody.text = item.message.body + if (item.message.content() is String) { + binding.messageBody.text = item.message.body + } else if (item.message.content() is GroupMembershipChanges) { + val changes = item.message.content() as? GroupMembershipChanges + binding.messageBody.text = + "Membership Changed ${ + changes?.membersAddedList?.mapNotNull { it.accountAddress }.toString() + }" + } } } diff --git a/example/src/main/java/org/xmtp/android/example/pushnotifications/PushNotificationsService.kt b/example/src/main/java/org/xmtp/android/example/pushnotifications/PushNotificationsService.kt index f1e39e381..52d4d7074 100644 --- a/example/src/main/java/org/xmtp/android/example/pushnotifications/PushNotificationsService.kt +++ b/example/src/main/java/org/xmtp/android/example/pushnotifications/PushNotificationsService.kt @@ -1,8 +1,11 @@ package org.xmtp.android.example.pushnotifications +import android.Manifest import android.app.PendingIntent +import android.content.pm.PackageManager import android.util.Base64 import android.util.Log +import androidx.core.app.ActivityCompat import androidx.core.app.NotificationCompat import androidx.core.app.NotificationManagerCompat import androidx.core.content.ContextCompat @@ -51,9 +54,9 @@ class PushNotificationsService : FirebaseMessagingService() { } GlobalScope.launch(Dispatchers.Main) { - ClientManager.createClient(keysData) + ClientManager.createClient(keysData, applicationContext) } - val conversation = ClientManager.client.fetchConversation(topic) + val conversation = ClientManager.client.fetchConversation(topic, includeGroups = true) if (conversation == null) { Log.e(TAG, "No keys or conversation persisted") return @@ -88,6 +91,20 @@ class PushNotificationsService : FirebaseMessagingService() { // Use the URL as the ID for now until one is passed back from the server. NotificationManagerCompat.from(this).apply { + if (ActivityCompat.checkSelfPermission( + applicationContext, + Manifest.permission.POST_NOTIFICATIONS + ) != PackageManager.PERMISSION_GRANTED + ) { + // TODO: Consider calling + // ActivityCompat#requestPermissions + // here to request the missing permissions, and then overriding + // public void onRequestPermissionsResult(int requestCode, String[] permissions, + // int[] grantResults) + // to handle the case where the user grants the permission. See the documentation + // for ActivityCompat#requestPermissions for more details. + return + } notify(topic.hashCode(), builder.build()) } } diff --git a/example/src/main/res/drawable/ic_group_add_24.xml b/example/src/main/res/drawable/ic_group_add_24.xml new file mode 100644 index 000000000..8038fe7df --- /dev/null +++ b/example/src/main/res/drawable/ic_group_add_24.xml @@ -0,0 +1,9 @@ + + + + + + + diff --git a/example/src/main/res/layout/activity_main.xml b/example/src/main/res/layout/activity_main.xml index 3594d690f..a970221d3 100644 --- a/example/src/main/res/layout/activity_main.xml +++ b/example/src/main/res/layout/activity_main.xml @@ -44,6 +44,18 @@ app:layout_constraintBottom_toBottomOf="parent" app:layout_constraintEnd_toEndOf="parent" /> + + + + + + + + + + + + + + + + diff --git a/library/build.gradle b/library/build.gradle index 2ebb5482d..fe4aadb9e 100644 --- a/library/build.gradle +++ b/library/build.gradle @@ -86,7 +86,7 @@ dependencies { implementation 'org.web3j:crypto:5.0.0' implementation "net.java.dev.jna:jna:5.13.0@aar" api 'com.google.protobuf:protobuf-kotlin-lite:3.22.3' - api 'org.xmtp:proto-kotlin:3.31.0' + api 'org.xmtp:proto-kotlin:3.40.1' testImplementation 'junit:junit:4.13.2' androidTestImplementation 'app.cash.turbine:turbine:0.12.1' 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 a5df80432..7dde5004c 100644 --- a/library/src/androidTest/java/org/xmtp/android/library/ConversationTest.kt +++ b/library/src/androidTest/java/org/xmtp/android/library/ConversationTest.kt @@ -428,7 +428,7 @@ class ConversationTest { fun testListBatchMessages() { val bobConversation = aliceClient.conversations.newConversation(bob.walletAddress) val steveConversation = - aliceClient.conversations.newConversation(fixtures.steve.walletAddress) + aliceClient.conversations.newConversation(fixtures.caro.walletAddress) bobConversation.send(text = "hey alice 1") bobConversation.send(text = "hey alice 2") @@ -452,7 +452,7 @@ class ConversationTest { fun testListBatchDecryptedMessages() { val bobConversation = aliceClient.conversations.newConversation(bob.walletAddress) val steveConversation = - aliceClient.conversations.newConversation(fixtures.steve.walletAddress) + aliceClient.conversations.newConversation(fixtures.caro.walletAddress) bobConversation.send(text = "hey alice 1") bobConversation.send(text = "hey alice 2") @@ -476,7 +476,7 @@ class ConversationTest { fun testListBatchMessagesWithPagination() { val bobConversation = aliceClient.conversations.newConversation(bob.walletAddress) val steveConversation = - aliceClient.conversations.newConversation(fixtures.steve.walletAddress) + aliceClient.conversations.newConversation(fixtures.caro.walletAddress) bobConversation.send(text = "hey alice 1 bob") steveConversation.send(text = "hey alice 1 steve") diff --git a/library/src/androidTest/java/org/xmtp/android/library/GroupMembershipChangeTest.kt b/library/src/androidTest/java/org/xmtp/android/library/GroupMembershipChangeTest.kt new file mode 100644 index 000000000..96f53d7b0 --- /dev/null +++ b/library/src/androidTest/java/org/xmtp/android/library/GroupMembershipChangeTest.kt @@ -0,0 +1,111 @@ +package org.xmtp.android.library + +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.platform.app.InstrumentationRegistry +import org.junit.Assert.assertEquals +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.xmtp.android.library.messages.PrivateKey +import org.xmtp.android.library.messages.PrivateKeyBuilder +import org.xmtp.android.library.messages.walletAddress +import uniffi.xmtpv3.org.xmtp.android.library.codecs.GroupMembershipChangeCodec +import uniffi.xmtpv3.org.xmtp.android.library.codecs.GroupMembershipChanges + +@RunWith(AndroidJUnit4::class) +class GroupMembershipChangeTest { + lateinit var fakeApiClient: FakeApiClient + lateinit var alixWallet: PrivateKeyBuilder + lateinit var boWallet: PrivateKeyBuilder + lateinit var alix: PrivateKey + lateinit var alixClient: Client + lateinit var bo: PrivateKey + lateinit var boClient: Client + lateinit var caroWallet: PrivateKeyBuilder + lateinit var caro: PrivateKey + lateinit var caroClient: Client + lateinit var fixtures: Fixtures + + @Before + fun setUp() { + val context = InstrumentationRegistry.getInstrumentation().targetContext + fixtures = + fixtures( + clientOptions = ClientOptions( + ClientOptions.Api(XMTPEnvironment.LOCAL, false), + enableAlphaMls = true, + appContext = context + ) + ) + alixWallet = fixtures.aliceAccount + alix = fixtures.alice + boWallet = fixtures.bobAccount + bo = fixtures.bob + caroWallet = fixtures.caroAccount + caro = fixtures.caro + + fakeApiClient = fixtures.fakeApiClient + alixClient = fixtures.aliceClient + boClient = fixtures.bobClient + caroClient = fixtures.caroClient + } + + @Test + fun testCanAddMembers() { + Client.register(codec = GroupMembershipChangeCodec()) + + val group = alixClient.conversations.newGroup( + listOf( + bo.walletAddress, + caro.walletAddress + ) + ) + val messages = group.messages() + assertEquals(messages.size, 1) + val content: GroupMembershipChanges? = messages.first().content() + assertEquals( + listOf(bo.walletAddress.lowercase(), caro.walletAddress.lowercase()).sorted(), + content?.membersAddedList?.map { it.accountAddress.lowercase() }?.sorted() + ) + assert(content?.membersRemovedList.isNullOrEmpty()) + } + + @Test + fun testCanRemoveMembers() { + Client.register(codec = GroupMembershipChangeCodec()) + + val group = alixClient.conversations.newGroup( + listOf( + bo.walletAddress, + caro.walletAddress + ) + ) + val messages = group.messages() + assertEquals(messages.size, 1) + assertEquals(group.memberAddresses().size, 3) + group.removeMembers(listOf(caro.walletAddress)) + val updatedMessages = group.messages() + assertEquals(updatedMessages.size, 2) + assertEquals(group.memberAddresses().size, 2) + val content: GroupMembershipChanges? = updatedMessages.first().content() + + assertEquals( + listOf(caro.walletAddress.lowercase()), + content?.membersRemovedList?.map { it.accountAddress.lowercase() }?.sorted() + ) + assert(content?.membersAddedList.isNullOrEmpty()) + } + + @Test + fun testIfNotRegisteredReturnsFallback() { + val group = alixClient.conversations.newGroup( + listOf( + bo.walletAddress, + caro.walletAddress + ) + ) + val messages = group.messages() + assertEquals(messages.size, 1) + assert(messages.first().fallbackContent.isBlank()) + } +} diff --git a/library/src/androidTest/java/org/xmtp/android/library/GroupTest.kt b/library/src/androidTest/java/org/xmtp/android/library/GroupTest.kt new file mode 100644 index 000000000..6a66565ee --- /dev/null +++ b/library/src/androidTest/java/org/xmtp/android/library/GroupTest.kt @@ -0,0 +1,200 @@ +package org.xmtp.android.library + +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.platform.app.InstrumentationRegistry +import kotlinx.coroutines.runBlocking +import org.junit.Assert.assertEquals +import org.junit.Assert.assertThrows +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.xmtp.android.library.codecs.ContentTypeReaction +import org.xmtp.android.library.codecs.Reaction +import org.xmtp.android.library.codecs.ReactionAction +import org.xmtp.android.library.codecs.ReactionCodec +import org.xmtp.android.library.codecs.ReactionSchema +import org.xmtp.android.library.messages.PrivateKey +import org.xmtp.android.library.messages.PrivateKeyBuilder +import org.xmtp.android.library.messages.walletAddress + +@RunWith(AndroidJUnit4::class) +class GroupTest { + lateinit var fakeApiClient: FakeApiClient + lateinit var alixWallet: PrivateKeyBuilder + lateinit var boWallet: PrivateKeyBuilder + lateinit var alix: PrivateKey + lateinit var alixClient: Client + lateinit var bo: PrivateKey + lateinit var boClient: Client + lateinit var caroWallet: PrivateKeyBuilder + lateinit var caro: PrivateKey + lateinit var caroClient: Client + lateinit var fixtures: Fixtures + + @Before + fun setUp() { + val context = InstrumentationRegistry.getInstrumentation().targetContext + fixtures = + fixtures( + clientOptions = ClientOptions( + ClientOptions.Api(XMTPEnvironment.LOCAL, false), + enableAlphaMls = true, + appContext = context + ) + ) + alixWallet = fixtures.aliceAccount + alix = fixtures.alice + boWallet = fixtures.bobAccount + bo = fixtures.bob + caroWallet = fixtures.caroAccount + caro = fixtures.caro + + fakeApiClient = fixtures.fakeApiClient + alixClient = fixtures.aliceClient + boClient = fixtures.bobClient + caroClient = fixtures.caroClient + } + + @Test + fun testCanCreateAGroup() { + val group = boClient.conversations.newGroup(listOf(alix.walletAddress)) + assert(group.id.isNotEmpty()) + } + + @Test + fun testCanListGroupMembers() { + val group = boClient.conversations.newGroup( + listOf( + alix.walletAddress, + caro.walletAddress + ) + ) + assertEquals( + group.memberAddresses().sorted(), + listOf( + caro.walletAddress.lowercase(), + alix.walletAddress.lowercase(), + bo.walletAddress.lowercase() + ).sorted() + ) + } + + @Test + fun testCanAddGroupMembers() { + val group = boClient.conversations.newGroup(listOf(alix.walletAddress)) + group.addMembers(listOf(caro.walletAddress)) + assertEquals( + group.memberAddresses().sorted(), + listOf( + caro.walletAddress.lowercase(), + alix.walletAddress.lowercase(), + bo.walletAddress.lowercase() + ).sorted() + ) + } + + @Test + fun testCanRemoveGroupMembers() { + val group = boClient.conversations.newGroup( + listOf( + alix.walletAddress, + caro.walletAddress + ) + ) + group.removeMembers(listOf(caro.walletAddress)) + assertEquals( + group.memberAddresses().sorted(), + listOf( + alix.walletAddress.lowercase(), + bo.walletAddress.lowercase() + ).sorted() + ) + } + + @Test + fun testCanListGroups() { + boClient.conversations.newGroup(listOf(alix.walletAddress)) + boClient.conversations.newGroup(listOf(caro.walletAddress)) + val groups = boClient.conversations.listGroups() + assertEquals(groups.size, 2) + } + + @Test + fun testCanListGroupsAndConversations() { + boClient.conversations.newGroup(listOf(alix.walletAddress)) + boClient.conversations.newGroup(listOf(caro.walletAddress)) + boClient.conversations.newConversation(alix.walletAddress) + val convos = boClient.conversations.list(includeGroups = true) + assertEquals(convos.size, 3) + } + + @Test + fun testCannotSendMessageToGroupMemberNotOnV3() { + var fakeApiClient = FakeApiClient() + val chuxAccount = PrivateKeyBuilder() + val chux: PrivateKey = chuxAccount.getPrivateKey() + val chuxClient: Client = Client().create(account = chuxAccount, apiClient = fakeApiClient) + + assertThrows("Recipient not on network", XMTPException::class.java) { + boClient.conversations.newGroup(listOf(chux.walletAddress)) + } + } + + @Test + fun testCannotStartGroupWithSelf() { + assertThrows("Recipient is sender", XMTPException::class.java) { + boClient.conversations.newGroup(listOf(bo.walletAddress)) + } + } + + @Test + fun testCannotStartEmptyGroupChat() { + assertThrows("Cannot start an empty group chat.", XMTPException::class.java) { + boClient.conversations.newGroup(listOf()) + } + } + + @Test + fun testCanSendMessageToGroup() { + val group = boClient.conversations.newGroup(listOf(alix.walletAddress)) + group.send("howdy") + group.send("gm") + runBlocking { group.sync() } + assertEquals(group.messages().first().body, "gm") + assertEquals(group.messages().size, 3) + + runBlocking { alixClient.conversations.syncGroups() } + val sameGroup = alixClient.conversations.listGroups().last() + runBlocking { sameGroup.sync() } + assertEquals(sameGroup.messages().size, 2) + assertEquals(sameGroup.messages().first().body, "gm") + } + + @Test + fun testCanSendContentTypesToGroup() { + Client.register(codec = ReactionCodec()) + + val group = boClient.conversations.newGroup(listOf(alix.walletAddress)) + group.send("gm") + runBlocking { group.sync() } + val messageToReact = group.messages()[0] + + val reaction = Reaction( + reference = messageToReact.id, + action = ReactionAction.Added, + content = "U+1F603", + schema = ReactionSchema.Unicode + ) + + group.send(content = reaction, options = SendOptions(contentType = ContentTypeReaction)) + runBlocking { group.sync() } + + val messages = group.messages() + assertEquals(messages.size, 3) + val content: Reaction? = messages.first().content() + assertEquals("U+1F603", content?.content) + assertEquals(messageToReact.id, content?.reference) + assertEquals(ReactionAction.Added, content?.action) + assertEquals(ReactionSchema.Unicode, content?.schema) + } +} diff --git a/library/src/androidTest/java/org/xmtp/android/library/TestHelpers.kt b/library/src/androidTest/java/org/xmtp/android/library/TestHelpers.kt index 524a731fe..4e72fd9f4 100644 --- a/library/src/androidTest/java/org/xmtp/android/library/TestHelpers.kt +++ b/library/src/androidTest/java/org/xmtp/android/library/TestHelpers.kt @@ -217,20 +217,21 @@ class FakeApiClient : ApiClient { data class Fixtures( val aliceAccount: PrivateKeyBuilder, val bobAccount: PrivateKeyBuilder, - val steveAccount: PrivateKeyBuilder, + val caroAccount: PrivateKeyBuilder, + val clientOptions: ClientOptions? = null ) { var fakeApiClient: FakeApiClient = FakeApiClient() var alice: PrivateKey = aliceAccount.getPrivateKey() - var aliceClient: Client = Client().create(account = aliceAccount, apiClient = fakeApiClient) + var aliceClient: Client = Client().create(account = aliceAccount, apiClient = fakeApiClient, options = clientOptions) var bob: PrivateKey = bobAccount.getPrivateKey() - var bobClient: Client = Client().create(account = bobAccount, apiClient = fakeApiClient) - var steve: PrivateKey = steveAccount.getPrivateKey() - var steveClient: Client = Client().create(account = steveAccount, apiClient = fakeApiClient) - - constructor() : this( + var bobClient: Client = Client().create(account = bobAccount, apiClient = fakeApiClient, options = clientOptions) + var caro: PrivateKey = caroAccount.getPrivateKey() + var caroClient: Client = Client().create(account = caroAccount, apiClient = fakeApiClient, options = clientOptions) + constructor(clientOptions: ClientOptions?) : this( aliceAccount = PrivateKeyBuilder(), bobAccount = PrivateKeyBuilder(), - steveAccount = PrivateKeyBuilder() + caroAccount = PrivateKeyBuilder(), + clientOptions = clientOptions ) fun publishLegacyContact(client: Client) { @@ -249,5 +250,5 @@ data class Fixtures( } } -fun fixtures(): Fixtures = - Fixtures() +fun fixtures(clientOptions: ClientOptions? = null): Fixtures = + Fixtures(clientOptions) diff --git a/library/src/main/java/org/xmtp/android/library/Client.kt b/library/src/main/java/org/xmtp/android/library/Client.kt index ee49ae774..3ee285280 100644 --- a/library/src/main/java/org/xmtp/android/library/Client.kt +++ b/library/src/main/java/org/xmtp/android/library/Client.kt @@ -161,7 +161,8 @@ class Client() { this.apiClient = apiClient this.contacts = Contacts(client = this) this.libXMTPClient = libXMTPClient - this.conversations = Conversations(client = this) + this.conversations = + Conversations(client = this, libXMTPConversations = libXMTPClient?.conversations()) } fun buildFrom( @@ -174,14 +175,14 @@ class Client() { val apiClient = GRPCApiClient(environment = clientOptions.api.env, secure = clientOptions.api.isSecure) val v3Client: FfiXmtpClient? = if (isAlphaMlsEnabled(options)) { - if (account == null) throw XMTPException("Signing Key required to use groups.") runBlocking { ffiXmtpClient( options, account, options?.appContext, bundle, - LegacyIdentitySource.STATIC + LegacyIdentitySource.STATIC, + address ) } } else null @@ -226,14 +227,15 @@ class Client() { account, options?.appContext, privateKeyBundleV1, - legacyIdentityKey + legacyIdentityKey, + account.address ) val client = Client(account.address, privateKeyBundleV1, apiClient, libXMTPClient) client.ensureUserContactPublished() client } catch (e: java.lang.Exception) { - throw XMTPException("Error creating client", e) + throw XMTPException("Error creating client ${e.message}", e) } } } @@ -255,14 +257,14 @@ class Client() { val apiClient = GRPCApiClient(environment = newOptions.api.env, secure = newOptions.api.isSecure) val v3Client: FfiXmtpClient? = if (isAlphaMlsEnabled(options)) { - if (account == null) throw XMTPException("Signing Key required to use groups.") runBlocking { ffiXmtpClient( options, account, options?.appContext, v1Bundle, - LegacyIdentitySource.STATIC + LegacyIdentitySource.STATIC, + address ) } } else null @@ -281,14 +283,15 @@ class Client() { private suspend fun ffiXmtpClient( options: ClientOptions?, - account: SigningKey, + account: SigningKey?, appContext: Context?, privateKeyBundleV1: PrivateKeyBundleV1, legacyIdentitySource: LegacyIdentitySource, + accountAddress: String, ): FfiXmtpClient? { val v3Client: FfiXmtpClient? = if (isAlphaMlsEnabled(options)) { - val alias = "xmtp-${options!!.api.env}-${account.address.lowercase()}" + val alias = "xmtp-${options!!.api.env}-${accountAddress.lowercase()}" val dbDir = File(appContext?.filesDir?.absolutePath, "xmtp_db") dbDir.mkdir() @@ -324,7 +327,7 @@ class Client() { isSecure = false, db = dbPath, encryptionKey = retrievedKey.encoded, - accountAddress = account.address.lowercase(), + accountAddress = accountAddress, legacyIdentitySource = legacyIdentitySource, legacySignedPrivateKeyProto = privateKeyBundleV1.toV2().identityKey.toByteArray() ) @@ -334,10 +337,12 @@ class Client() { if (v3Client?.textToSign() == null) { v3Client?.registerIdentity(null) - } else { + } else if (account != null) { v3Client.textToSign()?.let { v3Client.registerIdentity(account.sign(it)) } + } else { + Log.i(TAG, "No signer passed but signer was required.") } return v3Client @@ -460,9 +465,11 @@ class Client() { return subscribe(topics.map { it.description }) } - fun fetchConversation(topic: String?): Conversation? { + fun fetchConversation(topic: String?, includeGroups: Boolean = false): Conversation? { if (topic.isNullOrBlank()) return null - return conversations.list().firstOrNull { it.topic == topic } + return conversations.list(includeGroups = includeGroups).firstOrNull { + it.topic == topic + } } fun publish(envelopes: List): PublishResponse { @@ -552,6 +559,13 @@ class Client() { return runBlocking { query(Topic.contact(peerAddress)).envelopesList.size > 0 } } + fun canMessage(addresses: List): Boolean { + return runBlocking { + libXMTPClient != null && !libXMTPClient!!.canMessage(addresses.map { it }) + .contains(false) + } + } + val privateKeyBundle: PrivateKeyBundle get() = PrivateKeyBundleBuilder.buildFromV1Key(privateKeyBundleV1) diff --git a/library/src/main/java/org/xmtp/android/library/Contacts.kt b/library/src/main/java/org/xmtp/android/library/Contacts.kt index 498ee4a1b..f433e6e1d 100644 --- a/library/src/main/java/org/xmtp/android/library/Contacts.kt +++ b/library/src/main/java/org/xmtp/android/library/Contacts.kt @@ -188,7 +188,7 @@ data class Contacts( val contactBundle = ContactBundleBuilder.buildFromEnvelope(envelope) knownBundles[peerAddress] = contactBundle val address = contactBundle.walletAddress - if (address == peerAddress) { + if (address?.lowercase() == peerAddress.lowercase()) { return contactBundle } } 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 9a5382f6b..08d8fe8af 100644 --- a/library/src/main/java/org/xmtp/android/library/Conversation.kt +++ b/library/src/main/java/org/xmtp/android/library/Conversation.kt @@ -4,13 +4,14 @@ import android.util.Log import com.google.protobuf.kotlin.toByteString import kotlinx.coroutines.flow.Flow import org.xmtp.android.library.codecs.EncodedContent +import org.xmtp.android.library.libxmtp.Message +import org.xmtp.android.library.messages.DecryptedMessage import org.xmtp.android.library.messages.Envelope import org.xmtp.android.library.messages.PagingInfoSortDirection import org.xmtp.proto.keystore.api.v1.Keystore.TopicMap.TopicData import org.xmtp.proto.message.api.v1.MessageApiOuterClass import org.xmtp.proto.message.contents.Invitation import org.xmtp.proto.message.contents.Invitation.InvitationV1.Aes256gcmHkdfsha256 -import org.xmtp.android.library.messages.DecryptedMessage import java.util.Date /** @@ -24,7 +25,9 @@ sealed class Conversation { data class V1(val conversationV1: ConversationV1) : Conversation() data class V2(val conversationV2: ConversationV2) : Conversation() - enum class Version { V1, V2 } + data class Group(val group: org.xmtp.android.library.Group) : Conversation() + + enum class Version { V1, V2, GROUP } // This indicates whether this a v1 or v2 conversation. val version: Version @@ -32,6 +35,7 @@ sealed class Conversation { return when (this) { is V1 -> Version.V1 is V2 -> Version.V2 + is Group -> Version.GROUP } } @@ -41,6 +45,7 @@ sealed class Conversation { return when (this) { is V1 -> conversationV1.sentAt is V2 -> conversationV2.createdAt + is Group -> group.createdAt } } @@ -50,6 +55,24 @@ sealed class Conversation { return when (this) { is V1 -> conversationV1.peerAddress is V2 -> conversationV2.peerAddress + is Group -> { + val addresses = group.memberAddresses().toMutableList() + addresses.remove(clientAddress) + addresses.joinToString(",") + } + } + } + + val peerAddresses: List + get() { + return when (this) { + is V1 -> listOf(conversationV1.peerAddress) + is V2 -> listOf(conversationV2.peerAddress) + is Group -> { + val addresses = group.memberAddresses().toMutableList() + addresses.remove(clientAddress) + addresses + } } } @@ -60,6 +83,7 @@ sealed class Conversation { return when (this) { is V1 -> null is V2 -> conversationV2.context.conversationId + is Group -> null } } @@ -68,15 +92,16 @@ sealed class Conversation { return when (this) { is V1 -> null is V2 -> conversationV2.keyMaterial + is Group -> null } } fun consentState(): ConsentState { - val client: Client = when (this) { - is V1 -> conversationV1.client - is V2 -> conversationV2.client + return when (this) { + is V1 -> conversationV1.client.contacts.consentList.state(address = peerAddress) + is V2 -> conversationV2.client.contacts.consentList.state(address = peerAddress) + is Group -> ConsentState.UNKNOWN // No such thing as consent for a group } - return client.contacts.consentList.state(address = peerAddress) } /** @@ -99,13 +124,16 @@ sealed class Conversation { .setKeyMaterial(conversationV2.keyMaterial.toByteString()), ), ).build() + + is Group -> throw XMTPException("Groups do not support topics") } } - fun decode(envelope: Envelope): DecodedMessage { + fun decode(envelope: Envelope, message: Message? = null): DecodedMessage { return when (this) { is V1 -> conversationV1.decode(envelope) is V2 -> conversationV2.decodeEnvelope(envelope) + is Group -> message?.decode() ?: throw XMTPException("Groups require message be passed") } } @@ -127,6 +155,8 @@ sealed class Conversation { is V2 -> { conversationV2.prepareMessage(content = content, options = options) } + + is Group -> throw XMTPException("Groups do not support prepared messages") // We return a encoded content not a preparedmessage which requires a envelope } } @@ -142,6 +172,8 @@ sealed class Conversation { is V2 -> { conversationV2.prepareMessage(encodedContent = encodedContent, options = options) } + + is Group -> throw XMTPException("Groups do not support prepared messages") // We return a encoded content not a preparedmessage which requires a envelope } } @@ -149,6 +181,7 @@ sealed class Conversation { return when (this) { is V1 -> conversationV1.send(prepared = prepared) is V2 -> conversationV2.send(prepared = prepared) + is Group -> throw XMTPException("Groups do not support prepared messages") // We return a encoded content not a prepared Message which requires a envelope } } @@ -156,6 +189,7 @@ sealed class Conversation { return when (this) { is V1 -> conversationV1.send(content = content, options = options) is V2 -> conversationV2.send(content = content, options = options) + is Group -> group.send(content = content, options = options) } } @@ -163,6 +197,7 @@ sealed class Conversation { return when (this) { is V1 -> conversationV1.send(text = text, sendOptions, sentAt) is V2 -> conversationV2.send(text = text, sendOptions, sentAt) + is Group -> group.send(text) } } @@ -170,6 +205,7 @@ sealed class Conversation { return when (this) { is V1 -> conversationV1.send(encodedContent = encodedContent, options = options) is V2 -> conversationV2.send(encodedContent = encodedContent, options = options) + is Group -> group.send(encodedContent = encodedContent) } } @@ -184,6 +220,7 @@ sealed class Conversation { return when (this) { is V1 -> conversationV1.topic.description is V2 -> conversationV2.topic + is Group -> group.id.toHex() } } @@ -221,6 +258,15 @@ sealed class Conversation { after = after, direction = direction, ) + + is Group -> { + group.messages( + limit = limit, + before = before, + after = after, + direction = direction, + ) + } } } @@ -233,15 +279,24 @@ sealed class Conversation { return when (this) { is V1 -> conversationV1.decryptedMessages(limit, before, after, direction) is V2 -> conversationV2.decryptedMessages(limit, before, after, direction) + is Group -> group.decryptedMessages(limit, before, after, direction) } } fun decrypt( envelope: Envelope, + message: Message? = null, ): DecryptedMessage { return when (this) { is V1 -> conversationV1.decrypt(envelope) is V2 -> conversationV2.decrypt(envelope) + is Group -> { + if (message == null) { + throw XMTPException("Groups require message be passed") + } else { + group.decrypt(message) + } + } } } @@ -251,6 +306,7 @@ sealed class Conversation { return when (this) { is V1 -> conversationV1.client is V2 -> conversationV2.client + is Group -> group.client } } @@ -262,6 +318,7 @@ sealed class Conversation { return when (this) { is V1 -> conversationV1.streamMessages() is V2 -> conversationV2.streamMessages() + is Group -> throw XMTPException("Coming follow up PR") } } @@ -269,6 +326,7 @@ sealed class Conversation { return when (this) { is V1 -> conversationV1.streamDecryptedMessages() is V2 -> conversationV2.streamDecryptedMessages() + is Group -> throw XMTPException("Coming follow up PR") } } @@ -276,6 +334,7 @@ sealed class Conversation { return when (this) { is V1 -> return conversationV1.streamEphemeral() is V2 -> return conversationV2.streamEphemeral() + is Group -> throw XMTPException("Groups do not support ephemeral messages") } } } diff --git a/library/src/main/java/org/xmtp/android/library/Conversations.kt b/library/src/main/java/org/xmtp/android/library/Conversations.kt index 46fe1582b..9bd572c89 100644 --- a/library/src/main/java/org/xmtp/android/library/Conversations.kt +++ b/library/src/main/java/org/xmtp/android/library/Conversations.kt @@ -9,6 +9,7 @@ import kotlinx.coroutines.flow.flow import kotlinx.coroutines.runBlocking import org.xmtp.android.library.GRPCApiClient.Companion.makeQueryRequest import org.xmtp.android.library.GRPCApiClient.Companion.makeSubscribeRequest +import org.xmtp.android.library.messages.DecryptedMessage import org.xmtp.android.library.messages.Envelope import org.xmtp.android.library.messages.EnvelopeBuilder import org.xmtp.android.library.messages.InvitationV1 @@ -31,12 +32,16 @@ import org.xmtp.android.library.messages.walletAddress import org.xmtp.proto.keystore.api.v1.Keystore.TopicMap.TopicData import org.xmtp.proto.message.contents.Contact import org.xmtp.proto.message.contents.Invitation -import org.xmtp.android.library.messages.DecryptedMessage +import uniffi.xmtpv3.FfiConversations +import uniffi.xmtpv3.FfiListConversationsOptions import java.util.Date +import kotlin.time.Duration.Companion.nanoseconds +import kotlin.time.DurationUnit data class Conversations( var client: Client, var conversationsByTopic: MutableMap = mutableMapOf(), + private val libXMTPConversations: FfiConversations? = null, ) { companion object { @@ -81,6 +86,44 @@ data class Conversations( ) } + fun newGroup(accountAddresses: List): Group { + if (accountAddresses.isEmpty()) { + throw XMTPException("Cannot start an empty group chat.") + } + if (accountAddresses.size == 1 && + accountAddresses.first().lowercase() == client.address.lowercase() + ) { + throw XMTPException("Recipient is sender") + } + if (!client.canMessage(accountAddresses)) { + throw XMTPException("Recipient not on network") + } + + val group = runBlocking { + libXMTPConversations?.createGroup(accountAddresses) + ?: throw XMTPException("Client does not support Groups") + } + return Group(client, group) + } + + suspend fun syncGroups() { + libXMTPConversations?.sync() + } + + fun listGroups(after: Date? = null, before: Date? = null, limit: Int? = null): List { + return runBlocking { + libXMTPConversations?.list( + opts = FfiListConversationsOptions( + after?.time?.nanoseconds?.toLong(DurationUnit.NANOSECONDS), + before?.time?.nanoseconds?.toLong(DurationUnit.NANOSECONDS), + limit?.toLong() + ) + )?.map { + Group(client, it) + } + } ?: emptyList() + } + /** * This creates a new [Conversation] using a specified address * @param peerAddress The address of the client that you want to start a new conversation @@ -174,7 +217,7 @@ data class Conversations( * Get the list of conversations that current user has * @return The list of [Conversation] that the current [Client] has. */ - fun list(): List { + fun list(includeGroups: Boolean = false): List { val newConversations = mutableListOf() val mostRecent = conversationsByTopic.values.maxOfOrNull { it.createdAt } val pagination = Pagination(after = mostRecent) @@ -203,7 +246,13 @@ data class Conversations( it.peerAddress != client.address && Topic.isValidTopic(it.topic) }.map { Pair(it.topic, it) } - // TODO(perf): use DB to persist + sort + if (includeGroups) { + val groups = runBlocking { + syncGroups() + listGroups() + } + conversationsByTopic += groups.map { Pair(it.id.toString(), Conversation.Group(it)) } + } return conversationsByTopic.values.sortedByDescending { it.createdAt } } diff --git a/library/src/main/java/org/xmtp/android/library/Group.kt b/library/src/main/java/org/xmtp/android/library/Group.kt new file mode 100644 index 000000000..e7671af11 --- /dev/null +++ b/library/src/main/java/org/xmtp/android/library/Group.kt @@ -0,0 +1,139 @@ +package org.xmtp.android.library + +import kotlinx.coroutines.runBlocking +import org.xmtp.android.library.codecs.ContentCodec +import org.xmtp.android.library.codecs.EncodedContent +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.proto.message.api.v1.MessageApiOuterClass +import uniffi.xmtpv3.FfiGroup +import uniffi.xmtpv3.FfiListMessagesOptions +import java.util.Date +import kotlin.time.Duration.Companion.nanoseconds +import kotlin.time.DurationUnit + +class Group(val client: Client, private val libXMTPGroup: FfiGroup) { + val id: ByteArray + get() = libXMTPGroup.id() + + val createdAt: Date + get() = Date(libXMTPGroup.createdAtNs() / 1_000_000) + + fun send(text: String): String { + return send(prepareMessage(content = text, options = null)) + } + + fun send(content: T, options: SendOptions? = null): String { + val preparedMessage = prepareMessage(content = content, options = options) + return send(preparedMessage) + } + + fun send(encodedContent: EncodedContent): String { + runBlocking { + libXMTPGroup.send(contentBytes = encodedContent.toByteArray()) + } + return id.toString() + } + + fun prepareMessage(content: T, options: SendOptions?): EncodedContent { + val codec = Client.codecRegistry.find(options?.contentType) + + fun > encode(codec: Codec, content: Any?): EncodedContent { + val contentType = content as? T + if (contentType != null) { + return codec.encode(contentType) + } else { + throw XMTPException("Codec type is not registered") + } + } + + var encoded = encode(codec = codec as ContentCodec, content = content) + val fallback = codec.fallback(content) + if (!fallback.isNullOrBlank()) { + encoded = encoded.toBuilder().also { + it.fallback = fallback + }.build() + } + val compression = options?.compression + if (compression != null) { + encoded = encoded.compress(compression) + } + return encoded + } + + suspend fun sync() { + libXMTPGroup.sync() + } + + fun messages( + limit: Int? = null, + before: Date? = null, + after: Date? = null, + direction: PagingInfoSortDirection = MessageApiOuterClass.SortDirection.SORT_DIRECTION_DESCENDING, + ): List { + return runBlocking { + val messages = libXMTPGroup.findMessages( + opts = FfiListMessagesOptions( + sentBeforeNs = before?.time?.nanoseconds?.toLong(DurationUnit.NANOSECONDS), + sentAfterNs = after?.time?.nanoseconds?.toLong(DurationUnit.NANOSECONDS), + limit = limit?.toLong() + ) + ).map { + Message(client, it).decode() + } + when (direction) { + MessageApiOuterClass.SortDirection.SORT_DIRECTION_ASCENDING -> messages + else -> messages.reversed() + } + } + } + + fun decryptedMessages( + limit: Int? = null, + before: Date? = null, + after: Date? = null, + direction: PagingInfoSortDirection = MessageApiOuterClass.SortDirection.SORT_DIRECTION_DESCENDING, + ): List { + return runBlocking { + val messages = libXMTPGroup.findMessages( + opts = FfiListMessagesOptions( + sentBeforeNs = before?.time?.nanoseconds?.toLong(DurationUnit.NANOSECONDS), + sentAfterNs = after?.time?.nanoseconds?.toLong(DurationUnit.NANOSECONDS), + limit = limit?.toLong() + ) + ).map { + decrypt(Message(client, it)) + } + when (direction) { + MessageApiOuterClass.SortDirection.SORT_DIRECTION_ASCENDING -> messages + else -> messages.reversed() + } + } + } + + fun decrypt(message: Message): DecryptedMessage { + return DecryptedMessage( + id = message.id.toHex(), + topic = message.id.toHex(), + encodedContent = message.decode().encodedContent, + senderAddress = message.senderAddress, + sentAt = Date() + ) + } + + fun addMembers(addresses: List) { + runBlocking { libXMTPGroup.addMembers(addresses) } + } + + fun removeMembers(addresses: List) { + runBlocking { libXMTPGroup.removeMembers(addresses) } + } + + fun memberAddresses(): List { + return runBlocking { + libXMTPGroup.listMembers().map { it.accountAddress } + } + } +} diff --git a/library/src/main/java/org/xmtp/android/library/codecs/GroupMembershipChangeCodec.kt b/library/src/main/java/org/xmtp/android/library/codecs/GroupMembershipChangeCodec.kt new file mode 100644 index 000000000..9d7e855fd --- /dev/null +++ b/library/src/main/java/org/xmtp/android/library/codecs/GroupMembershipChangeCodec.kt @@ -0,0 +1,34 @@ +package uniffi.xmtpv3.org.xmtp.android.library.codecs + +import org.xmtp.android.library.codecs.ContentCodec +import org.xmtp.android.library.codecs.ContentTypeId +import org.xmtp.android.library.codecs.ContentTypeIdBuilder +import org.xmtp.android.library.codecs.EncodedContent + +typealias GroupMembershipChanges = org.xmtp.proto.mls.message.contents.TranscriptMessages.GroupMembershipChanges + +val ContentTypeGroupMembershipChange = ContentTypeIdBuilder.builderFromAuthorityId( + "xmtp.org", + "group_membership_change", + versionMajor = 1, + versionMinor = 0 +) + +data class GroupMembershipChangeCodec(override var contentType: ContentTypeId = ContentTypeGroupMembershipChange) : + ContentCodec { + + override fun encode(content: GroupMembershipChanges): EncodedContent { + return EncodedContent.newBuilder().also { + it.type = ContentTypeGroupMembershipChange + it.content = content.toByteString() + }.build() + } + + override fun decode(content: EncodedContent): GroupMembershipChanges { + return GroupMembershipChanges.parseFrom(content.content) + } + + override fun fallback(content: GroupMembershipChanges): String? { + return null + } +} diff --git a/library/src/main/java/org/xmtp/android/library/libxmtp/Message.kt b/library/src/main/java/org/xmtp/android/library/libxmtp/Message.kt new file mode 100644 index 000000000..0b470c4ae --- /dev/null +++ b/library/src/main/java/org/xmtp/android/library/libxmtp/Message.kt @@ -0,0 +1,35 @@ +package org.xmtp.android.library.libxmtp + +import org.xmtp.android.library.Client +import org.xmtp.android.library.DecodedMessage +import org.xmtp.android.library.XMTPException +import org.xmtp.android.library.codecs.EncodedContent +import org.xmtp.android.library.toHex +import uniffi.xmtpv3.FfiMessage +import java.util.Date + +data class Message(val client: Client, private val libXMTPMessage: FfiMessage) { + val id: ByteArray + get() = libXMTPMessage.id + + val senderAddress: String + get() = libXMTPMessage.addrFrom + + val sentAt: Date + get() = Date(libXMTPMessage.sentAtNs / 1_000_000) + + fun decode(): DecodedMessage { + try { + return DecodedMessage( + id = id.toHex(), + client = client, + topic = id.toHex(), + encodedContent = EncodedContent.parseFrom(libXMTPMessage.content), + senderAddress = senderAddress, + sent = sentAt + ) + } catch (e: Exception) { + throw XMTPException("Error decoding message", e) + } + } +} diff --git a/library/src/main/java/xmtpv3.kt b/library/src/main/java/xmtpv3.kt index 942d23119..67a1e3937 100644 --- a/library/src/main/java/xmtpv3.kt +++ b/library/src/main/java/xmtpv3.kt @@ -384,6 +384,7 @@ internal interface _UniFFILib : Library { .also { lib: _UniFFILib -> uniffiCheckContractApiVersion(lib) uniffiCheckApiChecksums(lib) + FfiConverterTypeFfiConversationCallback.register(lib) FfiConverterTypeFfiInboxOwner.register(lib) FfiConverterTypeFfiLogger.register(lib) FfiConverterTypeFfiMessageCallback.register(lib) @@ -398,6 +399,8 @@ internal interface _UniFFILib : Library { ): Pointer fun uniffi_xmtpv3_fn_method_fficonversations_list(`ptr`: Pointer,`opts`: RustBuffer.ByValue, ): Pointer + fun uniffi_xmtpv3_fn_method_fficonversations_stream(`ptr`: Pointer,`callback`: Long, + ): Pointer fun uniffi_xmtpv3_fn_method_fficonversations_sync(`ptr`: Pointer, ): Pointer fun uniffi_xmtpv3_fn_free_ffigroup(`ptr`: Pointer,_uniffi_out_err: RustCallStatus, @@ -420,9 +423,9 @@ internal interface _UniFFILib : Library { ): Pointer fun uniffi_xmtpv3_fn_method_ffigroup_sync(`ptr`: Pointer, ): Pointer - fun uniffi_xmtpv3_fn_free_ffimessagestreamcloser(`ptr`: Pointer,_uniffi_out_err: RustCallStatus, + fun uniffi_xmtpv3_fn_free_ffistreamcloser(`ptr`: Pointer,_uniffi_out_err: RustCallStatus, ): Unit - fun uniffi_xmtpv3_fn_method_ffimessagestreamcloser_close(`ptr`: Pointer,_uniffi_out_err: RustCallStatus, + fun uniffi_xmtpv3_fn_method_ffistreamcloser_end(`ptr`: Pointer,_uniffi_out_err: RustCallStatus, ): Unit fun uniffi_xmtpv3_fn_free_ffiv2apiclient(`ptr`: Pointer,_uniffi_out_err: RustCallStatus, ): Unit @@ -460,6 +463,8 @@ internal interface _UniFFILib : Library { ): Unit fun uniffi_xmtpv3_fn_init_callback_ffilogger(`callbackStub`: ForeignCallback,_uniffi_out_err: RustCallStatus, ): Unit + fun uniffi_xmtpv3_fn_init_callback_fficonversationcallback(`callbackStub`: ForeignCallback,_uniffi_out_err: RustCallStatus, + ): Unit fun uniffi_xmtpv3_fn_init_callback_ffimessagecallback(`callbackStub`: ForeignCallback,_uniffi_out_err: RustCallStatus, ): Unit fun uniffi_xmtpv3_fn_func_create_client(`logger`: Long,`host`: RustBuffer.ByValue,`isSecure`: Byte,`db`: RustBuffer.ByValue,`encryptionKey`: RustBuffer.ByValue,`accountAddress`: RustBuffer.ByValue,`legacyIdentitySource`: RustBuffer.ByValue,`legacySignedPrivateKeyProto`: RustBuffer.ByValue, @@ -632,6 +637,8 @@ internal interface _UniFFILib : Library { ): Short fun uniffi_xmtpv3_checksum_method_fficonversations_list( ): Short + fun uniffi_xmtpv3_checksum_method_fficonversations_stream( + ): Short fun uniffi_xmtpv3_checksum_method_fficonversations_sync( ): Short fun uniffi_xmtpv3_checksum_method_ffigroup_add_members( @@ -652,7 +659,7 @@ internal interface _UniFFILib : Library { ): Short fun uniffi_xmtpv3_checksum_method_ffigroup_sync( ): Short - fun uniffi_xmtpv3_checksum_method_ffimessagestreamcloser_close( + fun uniffi_xmtpv3_checksum_method_ffistreamcloser_end( ): Short fun uniffi_xmtpv3_checksum_method_ffiv2apiclient_batch_query( ): Short @@ -686,6 +693,8 @@ internal interface _UniFFILib : Library { ): Short fun uniffi_xmtpv3_checksum_method_ffilogger_log( ): Short + fun uniffi_xmtpv3_checksum_method_fficonversationcallback_on_conversation( + ): Short fun uniffi_xmtpv3_checksum_method_ffimessagecallback_on_message( ): Short fun ffi_xmtpv3_uniffi_contract_version( @@ -750,6 +759,9 @@ private fun uniffiCheckApiChecksums(lib: _UniFFILib) { if (lib.uniffi_xmtpv3_checksum_method_fficonversations_list() != 44067.toShort()) { throw RuntimeException("UniFFI API checksum mismatch: try cleaning and rebuilding your project") } + if (lib.uniffi_xmtpv3_checksum_method_fficonversations_stream() != 60583.toShort()) { + throw RuntimeException("UniFFI API checksum mismatch: try cleaning and rebuilding your project") + } if (lib.uniffi_xmtpv3_checksum_method_fficonversations_sync() != 62598.toShort()) { throw RuntimeException("UniFFI API checksum mismatch: try cleaning and rebuilding your project") } @@ -774,13 +786,13 @@ private fun uniffiCheckApiChecksums(lib: _UniFFILib) { if (lib.uniffi_xmtpv3_checksum_method_ffigroup_send() != 55957.toShort()) { throw RuntimeException("UniFFI API checksum mismatch: try cleaning and rebuilding your project") } - if (lib.uniffi_xmtpv3_checksum_method_ffigroup_stream() != 10513.toShort()) { + if (lib.uniffi_xmtpv3_checksum_method_ffigroup_stream() != 7482.toShort()) { throw RuntimeException("UniFFI API checksum mismatch: try cleaning and rebuilding your project") } if (lib.uniffi_xmtpv3_checksum_method_ffigroup_sync() != 9422.toShort()) { throw RuntimeException("UniFFI API checksum mismatch: try cleaning and rebuilding your project") } - if (lib.uniffi_xmtpv3_checksum_method_ffimessagestreamcloser_close() != 46827.toShort()) { + if (lib.uniffi_xmtpv3_checksum_method_ffistreamcloser_end() != 47211.toShort()) { throw RuntimeException("UniFFI API checksum mismatch: try cleaning and rebuilding your project") } if (lib.uniffi_xmtpv3_checksum_method_ffiv2apiclient_batch_query() != 10812.toShort()) { @@ -831,7 +843,10 @@ private fun uniffiCheckApiChecksums(lib: _UniFFILib) { if (lib.uniffi_xmtpv3_checksum_method_ffilogger_log() != 56011.toShort()) { throw RuntimeException("UniFFI API checksum mismatch: try cleaning and rebuilding your project") } - if (lib.uniffi_xmtpv3_checksum_method_ffimessagecallback_on_message() != 59170.toShort()) { + if (lib.uniffi_xmtpv3_checksum_method_fficonversationcallback_on_conversation() != 1220.toShort()) { + throw RuntimeException("UniFFI API checksum mismatch: try cleaning and rebuilding your project") + } + if (lib.uniffi_xmtpv3_checksum_method_ffimessagecallback_on_message() != 13573.toShort()) { throw RuntimeException("UniFFI API checksum mismatch: try cleaning and rebuilding your project") } } @@ -1222,6 +1237,7 @@ public interface FfiConversationsInterface { @Throws(GenericException::class) suspend fun `createGroup`(`accountAddresses`: List): FfiGroup@Throws(GenericException::class) suspend fun `list`(`opts`: FfiListConversationsOptions): List@Throws(GenericException::class) + suspend fun `stream`(`callback`: FfiConversationCallback): FfiStreamCloser@Throws(GenericException::class) suspend fun `sync`() companion object } @@ -1285,6 +1301,26 @@ class FfiConversations( ) } + @Throws(GenericException::class) + @Suppress("ASSIGNED_BUT_NEVER_ACCESSED_VARIABLE") + override suspend fun `stream`(`callback`: FfiConversationCallback) : FfiStreamCloser { + return uniffiRustCallAsync( + callWithPointer { thisPtr -> + _UniFFILib.INSTANCE.uniffi_xmtpv3_fn_method_fficonversations_stream( + thisPtr, + FfiConverterTypeFfiConversationCallback.lower(`callback`), + ) + }, + { future, continuation -> _UniFFILib.INSTANCE.ffi_xmtpv3_rust_future_poll_pointer(future, continuation) }, + { future, continuation -> _UniFFILib.INSTANCE.ffi_xmtpv3_rust_future_complete_pointer(future, continuation) }, + { future -> _UniFFILib.INSTANCE.ffi_xmtpv3_rust_future_free_pointer(future) }, + // lift function + { FfiConverterTypeFfiStreamCloser.lift(it) }, + // Error FFI converter + GenericException.ErrorHandler, + ) + } + @Throws(GenericException::class) @Suppress("ASSIGNED_BUT_NEVER_ACCESSED_VARIABLE") override suspend fun `sync`() { @@ -1346,7 +1382,7 @@ public interface FfiGroupInterface { fun `listMembers`(): List@Throws(GenericException::class) suspend fun `removeMembers`(`accountAddresses`: List)@Throws(GenericException::class) suspend fun `send`(`contentBytes`: ByteArray)@Throws(GenericException::class) - suspend fun `stream`(`messageCallback`: FfiMessageCallback): FfiMessageStreamCloser@Throws(GenericException::class) + suspend fun `stream`(`messageCallback`: FfiMessageCallback): FfiStreamCloser@Throws(GenericException::class) suspend fun `sync`() companion object } @@ -1481,7 +1517,7 @@ class FfiGroup( @Throws(GenericException::class) @Suppress("ASSIGNED_BUT_NEVER_ACCESSED_VARIABLE") - override suspend fun `stream`(`messageCallback`: FfiMessageCallback) : FfiMessageStreamCloser { + override suspend fun `stream`(`messageCallback`: FfiMessageCallback) : FfiStreamCloser { return uniffiRustCallAsync( callWithPointer { thisPtr -> _UniFFILib.INSTANCE.uniffi_xmtpv3_fn_method_ffigroup_stream( @@ -1493,7 +1529,7 @@ class FfiGroup( { future, continuation -> _UniFFILib.INSTANCE.ffi_xmtpv3_rust_future_complete_pointer(future, continuation) }, { future -> _UniFFILib.INSTANCE.ffi_xmtpv3_rust_future_free_pointer(future) }, // lift function - { FfiConverterTypeFfiMessageStreamCloser.lift(it) }, + { FfiConverterTypeFfiStreamCloser.lift(it) }, // Error FFI converter GenericException.ErrorHandler, ) @@ -1551,15 +1587,15 @@ public object FfiConverterTypeFfiGroup: FfiConverter { -public interface FfiMessageStreamCloserInterface { +public interface FfiStreamCloserInterface { - fun `close`() + fun `end`() companion object } -class FfiMessageStreamCloser( +class FfiStreamCloser( pointer: Pointer -) : FFIObject(pointer), FfiMessageStreamCloserInterface { +) : FFIObject(pointer), FfiStreamCloserInterface { /** * Disconnect the object from the underlying Rust object. @@ -1571,14 +1607,14 @@ class FfiMessageStreamCloser( */ override protected fun freeRustArcPtr() { rustCall() { status -> - _UniFFILib.INSTANCE.uniffi_xmtpv3_fn_free_ffimessagestreamcloser(this.pointer, status) + _UniFFILib.INSTANCE.uniffi_xmtpv3_fn_free_ffistreamcloser(this.pointer, status) } } - override fun `close`() = + override fun `end`() = callWithPointer { rustCall() { _status -> - _UniFFILib.INSTANCE.uniffi_xmtpv3_fn_method_ffimessagestreamcloser_close(it, + _UniFFILib.INSTANCE.uniffi_xmtpv3_fn_method_ffistreamcloser_end(it, _status) } @@ -1592,22 +1628,22 @@ class FfiMessageStreamCloser( } -public object FfiConverterTypeFfiMessageStreamCloser: FfiConverter { - override fun lower(value: FfiMessageStreamCloser): Pointer = value.callWithPointer { it } +public object FfiConverterTypeFfiStreamCloser: FfiConverter { + override fun lower(value: FfiStreamCloser): Pointer = value.callWithPointer { it } - override fun lift(value: Pointer): FfiMessageStreamCloser { - return FfiMessageStreamCloser(value) + override fun lift(value: Pointer): FfiStreamCloser { + return FfiStreamCloser(value) } - override fun read(buf: ByteBuffer): FfiMessageStreamCloser { + override fun read(buf: ByteBuffer): FfiStreamCloser { // The Rust code always writes pointers as 8 bytes, and will // fail to compile if they don't fit. return lift(Pointer(buf.getLong())) } - override fun allocationSize(value: FfiMessageStreamCloser) = 8 + override fun allocationSize(value: FfiStreamCloser) = 8 - override fun write(value: FfiMessageStreamCloser, buf: ByteBuffer) { + override fun write(value: FfiStreamCloser, buf: ByteBuffer) { // The Rust code always expects pointers written as 8 bytes, // and will fail to compile if they don't fit. buf.putLong(Pointer.nativeValue(lower(value))) @@ -2674,6 +2710,92 @@ public abstract class FfiConverterCallbackInterface( } } +// Declaration and FfiConverters for FfiConversationCallback Callback Interface + +public interface FfiConversationCallback { + fun `onConversation`(`conversation`: FfiGroup) + + companion object +} + +// The ForeignCallback that is passed to Rust. +internal class ForeignCallbackTypeFfiConversationCallback : ForeignCallback { + @Suppress("TooGenericExceptionCaught") + override fun callback(handle: Handle, method: Int, argsData: Pointer, argsLen: Int, outBuf: RustBufferByReference): Int { + val cb = FfiConverterTypeFfiConversationCallback.lift(handle) + return when (method) { + IDX_CALLBACK_FREE -> { + FfiConverterTypeFfiConversationCallback.drop(handle) + // Successful return + // See docs of ForeignCallback in `uniffi_core/src/ffi/foreigncallbacks.rs` + UNIFFI_CALLBACK_SUCCESS + } + 1 -> { + // Call the method, write to outBuf and return a status code + // See docs of ForeignCallback in `uniffi_core/src/ffi/foreigncallbacks.rs` for info + try { + this.`invokeOnConversation`(cb, argsData, argsLen, outBuf) + } catch (e: Throwable) { + // Unexpected error + try { + // Try to serialize the error into a string + outBuf.setValue(FfiConverterString.lower(e.toString())) + } catch (e: Throwable) { + // If that fails, then it's time to give up and just return + } + UNIFFI_CALLBACK_UNEXPECTED_ERROR + } + } + + else -> { + // An unexpected error happened. + // See docs of ForeignCallback in `uniffi_core/src/ffi/foreigncallbacks.rs` + try { + // Try to serialize the error into a string + outBuf.setValue(FfiConverterString.lower("Invalid Callback index")) + } catch (e: Throwable) { + // If that fails, then it's time to give up and just return + } + UNIFFI_CALLBACK_UNEXPECTED_ERROR + } + } + } + + + @Suppress("UNUSED_PARAMETER") + private fun `invokeOnConversation`(kotlinCallbackInterface: FfiConversationCallback, argsData: Pointer, argsLen: Int, outBuf: RustBufferByReference): Int { + val argsBuf = argsData.getByteBuffer(0, argsLen.toLong()).also { + it.order(ByteOrder.BIG_ENDIAN) + } + fun makeCall() : Int { + kotlinCallbackInterface.`onConversation`( + FfiConverterTypeFfiGroup.read(argsBuf) + ) + return UNIFFI_CALLBACK_SUCCESS + } + fun makeCallAndHandleError() : Int = makeCall() + + return makeCallAndHandleError() + } + +} + +// The ffiConverter which transforms the Callbacks in to Handles to pass to Rust. +public object FfiConverterTypeFfiConversationCallback: FfiConverterCallbackInterface( + foreignCallback = ForeignCallbackTypeFfiConversationCallback() +) { + override fun register(lib: _UniFFILib) { + rustCall() { status -> + lib.uniffi_xmtpv3_fn_init_callback_fficonversationcallback(this.foreignCallback, status) + } + } +} + + + + + + // Declaration and FfiConverters for FfiInboxOwner Callback Interface public interface FfiInboxOwner { diff --git a/library/src/main/jniLibs/arm64-v8a/libuniffi_xmtpv3.so b/library/src/main/jniLibs/arm64-v8a/libuniffi_xmtpv3.so index 88f79a338..54422d4a3 100755 Binary files a/library/src/main/jniLibs/arm64-v8a/libuniffi_xmtpv3.so and b/library/src/main/jniLibs/arm64-v8a/libuniffi_xmtpv3.so differ diff --git a/library/src/main/jniLibs/armeabi-v7a/libuniffi_xmtpv3.so b/library/src/main/jniLibs/armeabi-v7a/libuniffi_xmtpv3.so new file mode 100755 index 000000000..bb407dde9 Binary files /dev/null and b/library/src/main/jniLibs/armeabi-v7a/libuniffi_xmtpv3.so differ diff --git a/library/src/main/jniLibs/x86/libuniffi_xmtpv3.so b/library/src/main/jniLibs/x86/libuniffi_xmtpv3.so new file mode 100755 index 000000000..8486ddb05 Binary files /dev/null and b/library/src/main/jniLibs/x86/libuniffi_xmtpv3.so differ diff --git a/library/src/main/jniLibs/x86_64/libuniffi_xmtpv3.so b/library/src/main/jniLibs/x86_64/libuniffi_xmtpv3.so new file mode 100755 index 000000000..869f60b84 Binary files /dev/null and b/library/src/main/jniLibs/x86_64/libuniffi_xmtpv3.so differ