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 024e278d0..f677bf1fd 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 @@ -1,20 +1,39 @@ package org.xmtp.android.example.conversation import android.R.id.home +import android.content.ClipData +import android.content.ClipboardManager import android.content.Context import android.content.Intent import android.os.Bundle +import android.view.Menu import android.view.MenuItem +import android.view.View +import android.widget.Toast import androidx.activity.viewModels import androidx.appcompat.app.AppCompatActivity +import androidx.core.widget.addTextChangedListener +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.lifecycleScope +import androidx.lifecycle.repeatOnLifecycle +import androidx.recyclerview.widget.LinearLayoutManager +import androidx.recyclerview.widget.RecyclerView +import kotlinx.coroutines.launch +import org.xmtp.android.example.R import org.xmtp.android.example.databinding.ActivityConversationDetailBinding +import org.xmtp.android.example.extension.truncatedAddress +import org.xmtp.android.example.message.MessageAdapter class ConversationDetailActivity : AppCompatActivity() { private lateinit var binding: ActivityConversationDetailBinding + private lateinit var adapter: MessageAdapter private val viewModel: ConversationDetailViewModel by viewModels() + private val peerAddress + get() = intent.extras?.getString(EXTRA_PEER_ADDRESS) + companion object { const val EXTRA_CONVERSATION_TOPIC = "EXTRA_CONVERSATION_TOPIC" private const val EXTRA_PEER_ADDRESS = "EXTRA_PEER_ADDRESS" @@ -35,18 +54,107 @@ class ConversationDetailActivity : AppCompatActivity() { setContentView(binding.root) setSupportActionBar(binding.toolbar) supportActionBar?.setDisplayHomeAsUpEnabled(true) - supportActionBar?.subtitle = intent.extras?.getString(EXTRA_PEER_ADDRESS) + supportActionBar?.subtitle = peerAddress?.truncatedAddress() + + adapter = MessageAdapter() + binding.list.layoutManager = + LinearLayoutManager(this, RecyclerView.VERTICAL, true) + binding.list.adapter = adapter + + lifecycleScope.launch { + repeatOnLifecycle(Lifecycle.State.STARTED) { + viewModel.uiState.collect(::ensureUiState) + } + } + + binding.messageEditText.requestFocus() + binding.messageEditText.addTextChangedListener { + val sendEnabled = !binding.messageEditText.text.isNullOrBlank() + binding.sendButton.isEnabled = sendEnabled + } + + binding.sendButton.setOnClickListener { + val flow = viewModel.sendMessage(binding.messageEditText.text.toString()) + lifecycleScope.launch { + repeatOnLifecycle(Lifecycle.State.STARTED) { + flow.collect(::ensureSendState) + } + } + } + + binding.refresh.setOnRefreshListener { + viewModel.fetchMessages() + } viewModel.fetchMessages() } + override fun onCreateOptionsMenu(menu: Menu): Boolean { + menuInflater.inflate(R.menu.menu_main, menu) + return true + } + override fun onOptionsItemSelected(item: MenuItem): Boolean { - when (item.itemId) { + return when (item.itemId) { home -> { finish() - return true + true } + R.id.copy_address -> { + copyWalletAddress() + true + } + else -> super.onOptionsItemSelected(item) } - return super.onOptionsItemSelected(item) + } + + private fun copyWalletAddress() { + val clipboard = getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager + val clip = ClipData.newPlainText("peer_address", peerAddress) + clipboard.setPrimaryClip(clip) + } + + private fun ensureUiState(uiState: ConversationDetailViewModel.UiState) { + binding.progress.visibility = View.GONE + when (uiState) { + is ConversationDetailViewModel.UiState.Loading -> { + if (uiState.listItems.isNullOrEmpty()) { + binding.progress.visibility = View.VISIBLE + } else { + 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) + } + } + } + + private fun ensureSendState(sendState: ConversationDetailViewModel.SendMessageState) { + when (sendState) { + 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 + binding.sendButton.isEnabled = true + viewModel.fetchMessages() + } + } + } + + private fun showError(message: String) { + val error = message.ifBlank { resources.getString(R.string.error) } + Toast.makeText(this, error, Toast.LENGTH_SHORT).show() } } 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 828eeebb1..ba8e43f39 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 @@ -9,6 +9,8 @@ import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.launch import org.xmtp.android.example.ClientManager +import org.xmtp.android.library.Conversation +import org.xmtp.android.library.DecodedMessage class ConversationDetailViewModel(private val savedStateHandle: SavedStateHandle) : ViewModel() { @@ -26,6 +28,8 @@ class ConversationDetailViewModel(private val savedStateHandle: SavedStateHandle private val _uiState = MutableStateFlow(UiState.Loading(null)) val uiState: StateFlow = _uiState + private var conversation: Conversation? = null + @UiThread fun fetchMessages() { when (val uiState = uiState.value) { @@ -35,11 +39,15 @@ class ConversationDetailViewModel(private val savedStateHandle: SavedStateHandle viewModelScope.launch(Dispatchers.IO) { val listItems = mutableListOf() try { - val conversation = ClientManager.client.fetchConversation(conversationTopic) + if (conversation == null) { + conversation = ClientManager.client.fetchConversation(conversationTopic) + } conversation?.let { - it.messages().map { message -> - MessageListItem.Message(message.id, message.body) - } + listItems.addAll( + it.messages().map { message -> + MessageListItem.Message(message.id, message) + } + ) } _uiState.value = UiState.Success(listItems) } catch (e: Exception) { @@ -48,18 +56,38 @@ class ConversationDetailViewModel(private val savedStateHandle: SavedStateHandle } } + @UiThread + fun sendMessage(body: String): StateFlow { + val flow = MutableStateFlow(SendMessageState.Loading) + viewModelScope.launch(Dispatchers.IO) { + try { + conversation?.send(body) + flow.value = SendMessageState.Success + } catch (e: Exception) { + flow.value = SendMessageState.Error(e.localizedMessage.orEmpty()) + } + } + return flow + } + sealed class UiState { data class Loading(val listItems: List?) : UiState() data class Success(val listItems: List) : UiState() data class Error(val message: String) : UiState() } + sealed class SendMessageState { + object Loading : SendMessageState() + object Success : SendMessageState() + data class Error(val message: String) : SendMessageState() + } + sealed class MessageListItem(open val id: String, val itemType: Int) { companion object { const val ITEM_TYPE_MESSAGE = 1 } - data class Message(override val id: String, val body: String) : + data class Message(override val id: String, val message: DecodedMessage) : MessageListItem(id, ITEM_TYPE_MESSAGE) } } diff --git a/example/src/main/java/org/xmtp/android/example/extension/StringExtension.kt b/example/src/main/java/org/xmtp/android/example/extension/StringExtension.kt new file mode 100644 index 000000000..dceb91444 --- /dev/null +++ b/example/src/main/java/org/xmtp/android/example/extension/StringExtension.kt @@ -0,0 +1,10 @@ +package org.xmtp.android.example.extension + +fun String.truncatedAddress(): String { + if (length > 6) { + val start = 6 + val end = lastIndex - 3 + return replaceRange(start, end, "...") + } + return this +} diff --git a/example/src/main/java/org/xmtp/android/example/extension/ViewExtension.kt b/example/src/main/java/org/xmtp/android/example/extension/ViewExtension.kt new file mode 100644 index 000000000..6bcfbc2bf --- /dev/null +++ b/example/src/main/java/org/xmtp/android/example/extension/ViewExtension.kt @@ -0,0 +1,18 @@ +package org.xmtp.android.example.extension + +import android.view.View +import android.view.ViewGroup.MarginLayoutParams + +fun View.margins( + left: Int = 0, + top: Int = 0, + right: Int = 0, + bottom: Int = 0 +) { + val layoutParams = layoutParams as MarginLayoutParams + val marginLeft = left.let { if (it > 0) it else layoutParams.leftMargin } + val marginTop = top.let { if (it > 0) it else layoutParams.topMargin } + val marginRight = right.let { if (it > 0) it else layoutParams.rightMargin } + val marginBottom = bottom.let { if (it > 0) it else layoutParams.bottomMargin } + layoutParams.setMargins(marginLeft, marginTop, marginRight, marginBottom) +} diff --git a/example/src/main/java/org/xmtp/android/example/message/MessageAdapter.kt b/example/src/main/java/org/xmtp/android/example/message/MessageAdapter.kt new file mode 100644 index 000000000..8b4db971f --- /dev/null +++ b/example/src/main/java/org/xmtp/android/example/message/MessageAdapter.kt @@ -0,0 +1,45 @@ +package org.xmtp.android.example.message + +import android.view.LayoutInflater +import android.view.ViewGroup +import androidx.recyclerview.widget.RecyclerView +import org.xmtp.android.example.conversation.ConversationDetailViewModel +import org.xmtp.android.example.databinding.ListItemMessageBinding + +class MessageAdapter : RecyclerView.Adapter() { + + private val listItems = mutableListOf() + + fun setData(newItems: List) { + listItems.clear() + listItems.addAll(newItems) + notifyItemRangeChanged(0, newItems.size) + } + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder { + val inflater = LayoutInflater.from(parent.context) + return when (viewType) { + ConversationDetailViewModel.MessageListItem.ITEM_TYPE_MESSAGE -> { + val binding = ListItemMessageBinding.inflate(inflater, parent, false) + MessageViewHolder(binding) + } + else -> throw IllegalArgumentException("Unsupported view type $viewType") + } + } + + override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) { + val item = listItems[position] + when (holder) { + is MessageViewHolder -> { + holder.bind(item as ConversationDetailViewModel.MessageListItem.Message) + } + else -> throw IllegalArgumentException("Unsupported view holder") + } + } + + override fun getItemViewType(position: Int) = listItems[position].itemType + + override fun getItemCount() = listItems.count() + + override fun getItemId(position: Int) = listItems[position].id.hashCode().toLong() +} 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 new file mode 100644 index 000000000..9f31dce04 --- /dev/null +++ b/example/src/main/java/org/xmtp/android/example/message/MessageViewHolder.kt @@ -0,0 +1,42 @@ +package org.xmtp.android.example.message + +import android.graphics.Color +import androidx.constraintlayout.widget.ConstraintLayout +import androidx.constraintlayout.widget.ConstraintLayout.LayoutParams.PARENT_ID +import androidx.constraintlayout.widget.ConstraintLayout.LayoutParams.UNSET +import androidx.recyclerview.widget.RecyclerView +import org.xmtp.android.example.ClientManager +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 + +class MessageViewHolder( + private val binding: ListItemMessageBinding +) : RecyclerView.ViewHolder(binding.root) { + + private val marginLarge = binding.root.resources.getDimensionPixelSize(R.dimen.message_margin) + private val marginSmall = binding.root.resources.getDimensionPixelSize(R.dimen.padding) + private val backgroundMe = Color.LTGRAY + private val backgroundPeer = + binding.root.resources.getColor(R.color.teal_700, binding.root.context.theme) + + fun bind(item: ConversationDetailViewModel.MessageListItem.Message) { + val isFromMe = ClientManager.client.address == item.message.senderAddress + val params = binding.messageContainer.layoutParams as ConstraintLayout.LayoutParams + if (isFromMe) { + params.rightToRight = PARENT_ID + params.leftToLeft = UNSET + binding.messageRow.margins(left = marginLarge, right = marginSmall) + binding.messageContainer.setCardBackgroundColor(backgroundMe) + binding.messageBody.setTextColor(Color.BLACK) + } else { + params.leftToLeft = PARENT_ID + params.rightToRight = UNSET + binding.messageRow.margins(right = marginLarge, left = marginSmall) + binding.messageContainer.setCardBackgroundColor(backgroundPeer) + binding.messageBody.setTextColor(Color.WHITE) + } + binding.messageBody.text = item.message.body + } +} diff --git a/example/src/main/res/drawable/ic_send_24.xml b/example/src/main/res/drawable/ic_send_24.xml new file mode 100644 index 000000000..3abc6cb33 --- /dev/null +++ b/example/src/main/res/drawable/ic_send_24.xml @@ -0,0 +1,5 @@ + + + diff --git a/example/src/main/res/layout/activity_conversation_detail.xml b/example/src/main/res/layout/activity_conversation_detail.xml index 4a7b73553..2c2eb9fff 100644 --- a/example/src/main/res/layout/activity_conversation_detail.xml +++ b/example/src/main/res/layout/activity_conversation_detail.xml @@ -16,11 +16,48 @@ app:layout_constraintTop_toTopOf="parent" app:popupTheme="@style/ThemeOverlay.AppCompat.Light" /> + + + + + + + + diff --git a/example/src/main/res/layout/list_item_message.xml b/example/src/main/res/layout/list_item_message.xml new file mode 100644 index 000000000..d53ae94cd --- /dev/null +++ b/example/src/main/res/layout/list_item_message.xml @@ -0,0 +1,28 @@ + + + + + + + + + + + diff --git a/example/src/main/res/menu/menu_conversation.xml b/example/src/main/res/menu/menu_conversation.xml new file mode 100644 index 000000000..40e5fc625 --- /dev/null +++ b/example/src/main/res/menu/menu_conversation.xml @@ -0,0 +1,8 @@ + + + + diff --git a/example/src/main/res/values/dimens.xml b/example/src/main/res/values/dimens.xml index 6f40e04ad..f5260bbc1 100644 --- a/example/src/main/res/values/dimens.xml +++ b/example/src/main/res/values/dimens.xml @@ -1,4 +1,5 @@ 16dp + 48dp diff --git a/example/src/main/res/values/strings.xml b/example/src/main/res/values/strings.xml index 0bb4bd03b..edc9f2f5c 100644 --- a/example/src/main/res/values/strings.xml +++ b/example/src/main/res/values/strings.xml @@ -12,4 +12,7 @@ New message Enter Ethereum address Create conversation + + + Type a messageā€¦ 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 f61824687..07b9a067b 100644 --- a/library/src/main/java/org/xmtp/android/library/Client.kt +++ b/library/src/main/java/org/xmtp/android/library/Client.kt @@ -178,8 +178,8 @@ class Client() { } val contactBundle = ContactBundle.newBuilder().also { it.v2Builder.keyBundle = keys.getPublicKeyBundle() + it.v2Builder.keyBundleBuilder.identityKeyBuilder.signature = it.v2.keyBundle.identityKey.signature.ensureWalletSignature() }.build() - contactBundle.v2.keyBundle.identityKey.signature.ensureWalletSignature() val envelope = MessageApiOuterClass.Envelope.newBuilder().apply { contentTopic = Topic.contact(address).description timestampNs = Date().time * 1_000_000 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 b5f67e69f..45ef41830 100644 --- a/library/src/main/java/org/xmtp/android/library/Conversations.kt +++ b/library/src/main/java/org/xmtp/android/library/Conversations.kt @@ -108,7 +108,7 @@ data class Conversations( } fun list(): List { - val conversations: MutableList = mutableListOf() + conversations = mutableListOf() val seenPeers = listIntroductionPeers() for ((peerAddress, sentAt) in seenPeers) { conversations.add( diff --git a/library/src/main/java/org/xmtp/android/library/messages/PrivateKeyBundleV2.kt b/library/src/main/java/org/xmtp/android/library/messages/PrivateKeyBundleV2.kt index 0115481af..290ae6ded 100644 --- a/library/src/main/java/org/xmtp/android/library/messages/PrivateKeyBundleV2.kt +++ b/library/src/main/java/org/xmtp/android/library/messages/PrivateKeyBundleV2.kt @@ -70,8 +70,7 @@ fun PrivateKeyBundleV2.toV1(): PrivateKeyBundleV1 { fun PrivateKeyBundleV2.getPublicKeyBundle(): SignedPublicKeyBundle { return SignedPublicKeyBundle.newBuilder().also { it.identityKey = identityKey.publicKey - it.identityKeyBuilder.signature = identityKey.publicKey.signature - it.identityKey.signature.ensureWalletSignature() + it.identityKeyBuilder.signature = identityKey.publicKey.signature.ensureWalletSignature() it.preKey = preKeysList[0].publicKey }.build() } diff --git a/library/src/main/java/org/xmtp/android/library/messages/Signature.kt b/library/src/main/java/org/xmtp/android/library/messages/Signature.kt index c30a25adf..7c9a27f4a 100644 --- a/library/src/main/java/org/xmtp/android/library/messages/Signature.kt +++ b/library/src/main/java/org/xmtp/android/library/messages/Signature.kt @@ -48,17 +48,17 @@ fun Signature.verify(signedBy: PublicKey, digest: ByteArray): Boolean { return pubKey.contentEquals(signedBy.secp256K1Uncompressed.bytes.toByteArray()) } -fun Signature.ensureWalletSignature() { +fun Signature.ensureWalletSignature(): Signature { when (unionCase) { SignatureOuterClass.Signature.UnionCase.ECDSA_COMPACT -> { val walletEcdsa = SignatureOuterClass.Signature.WalletECDSACompact.newBuilder().also { it.bytes = ecdsaCompact.bytes it.recovery = ecdsaCompact.recovery }.build() - this.toBuilder().apply { - walletEcdsaCompact = walletEcdsa + return this.toBuilder().also { + it.walletEcdsaCompact = walletEcdsa }.build() } - else -> return + else -> return this } }