diff --git a/build.gradle b/build.gradle
index ee4b95d2f..829a69abd 100644
--- a/build.gradle
+++ b/build.gradle
@@ -12,6 +12,7 @@ plugins {
id 'com.android.application' version '8.9.1' apply false
id 'com.android.library' version '8.9.1' apply false
id 'org.jetbrains.kotlin.android' version '2.0.0' apply false
+ id 'org.jetbrains.kotlin.plugin.compose' version '2.0.0' apply false
id 'io.github.gradle-nexus.publish-plugin' version "1.2.0"
id "org.jetbrains.dokka" version "1.8.10"
id 'com.diffplug.spotless' version '8.0.0' apply false
@@ -29,6 +30,9 @@ subprojects {
target '**/*.kt'
targetExclude '**/build/**', '**/generated/**', '**/xmtpv3.kt'
ktlint('1.7.1')
+ suppressLintsFor {
+ shortCode = 'standard:max-line-length'
+ }
}
kotlinGradle {
diff --git a/example/build.gradle b/example/build.gradle
index 35f4b8a02..85c24dda3 100644
--- a/example/build.gradle
+++ b/example/build.gradle
@@ -8,6 +8,7 @@ buildscript {
plugins {
id 'com.android.application'
id 'org.jetbrains.kotlin.android'
+ id 'org.jetbrains.kotlin.plugin.compose'
// id 'com.google.gms.google-services'
}
@@ -43,11 +44,15 @@ android {
jvmTarget = '17'
}
buildFeatures {
- viewBinding true
buildConfig true
+ compose true
}
}
+composeCompiler {
+ reportsDestination = layout.buildDirectory.dir("compose_compiler")
+}
+
dependencies {
implementation project(':library')
implementation 'androidx.core:core-ktx:1.12.0'
@@ -64,6 +69,28 @@ dependencies {
implementation 'androidx.recyclerview:recyclerview:1.3.2'
implementation 'org.web3j:crypto:5.0.0'
+ // Security - Encrypted SharedPreferences
+ implementation 'androidx.security:security-crypto:1.1.0-alpha06'
+
+ // Glide for GIF support
+ implementation 'com.github.bumptech.glide:glide:4.16.0'
+
+ // Jetpack Compose
+ def composeBom = platform('androidx.compose:compose-bom:2024.02.00')
+ implementation composeBom
+ androidTestImplementation composeBom
+ implementation 'androidx.compose.ui:ui'
+ implementation 'androidx.compose.ui:ui-graphics'
+ implementation 'androidx.compose.ui:ui-tooling-preview'
+ implementation 'androidx.compose.material3:material3'
+ implementation 'androidx.compose.material:material-icons-extended'
+ implementation 'androidx.activity:activity-compose:1.8.2'
+ implementation 'androidx.lifecycle:lifecycle-viewmodel-compose:2.7.0'
+ implementation 'androidx.lifecycle:lifecycle-runtime-compose:2.7.0'
+ implementation 'androidx.navigation:navigation-compose:2.7.6'
+ debugImplementation 'androidx.compose.ui:ui-tooling'
+ debugImplementation 'androidx.compose.ui:ui-test-manifest'
+
// WalletConnect V2: core library + WalletConnectModal
implementation(platform("com.walletconnect:android-bom:1.19.1"))
implementation("com.walletconnect:android-core")
diff --git a/example/src/main/AndroidManifest.xml b/example/src/main/AndroidManifest.xml
index f09ae12de..bded0e098 100644
--- a/example/src/main/AndroidManifest.xml
+++ b/example/src/main/AndroidManifest.xml
@@ -63,6 +63,19 @@
+
+
+
+
(null)
+ var selectedLogLevel: FfiLogLevel?
+ get() = _selectedLogLevel.get()
+ set(value) = _selectedLogLevel.set(value)
+
+ // Lock for thread-safe operations
+ private val lock = Any()
+
+ // Application-scoped coroutine scope for client operations
+ private var managerScope: CoroutineScope? = null
+
+ private fun getOrCreateScope(): CoroutineScope =
+ synchronized(lock) {
+ managerScope ?: CoroutineScope(SupervisorJob() + Dispatchers.IO).also {
+ managerScope = it
+ }
+ }
+
fun clientOptions(
appContext: Context,
address: String,
+ environment: XMTPEnvironment = selectedEnvironment,
): ClientOptions {
val keyUtil = KeyUtil(appContext)
val encryptionKey =
@@ -29,7 +62,7 @@ object ClientManager {
return ClientOptions(
api =
ClientOptions.Api(
- XMTPEnvironment.DEV,
+ environment,
isSecure = true,
),
appContext = appContext,
@@ -55,32 +88,93 @@ object ClientManager {
address: String,
appContext: Context,
) {
- if (clientState.value is ClientState.Ready) return
- GlobalScope.launch(Dispatchers.IO) {
+ // Use compareAndSet to atomically transition from Unknown/Error to Creating
+ // This prevents race conditions with concurrent createClient() calls
+ synchronized(lock) {
+ val currentState = _clientState.value
+ if (currentState is ClientState.Ready || currentState is ClientState.Creating) return
+ _clientState.value = ClientState.Creating
+ }
+ getOrCreateScope().launch {
try {
+ val keyUtil = KeyUtil(appContext)
+ val privateKeyBytes = keyUtil.retrievePrivateKey(address)
+
+ // Restore the saved environment, default to DEV if not found
+ val savedEnvironment = keyUtil.retrieveEnvironment()
+ selectedEnvironment = savedEnvironment?.let {
+ try {
+ XMTPEnvironment.valueOf(it)
+ } catch (e: IllegalArgumentException) {
+ XMTPEnvironment.DEV
+ }
+ } ?: XMTPEnvironment.DEV
+
+ Timber.d(
+ "createClient: address=$address, hasPrivateKey=${privateKeyBytes != null}, environment=$selectedEnvironment",
+ )
+
_client =
- Client.build(
- PublicIdentity(IdentityKind.ETHEREUM, address),
- clientOptions(appContext, address),
- )
+ if (privateKeyBytes != null) {
+ // Rebuild the wallet from stored private key and use Client.create()
+ // This allows signing if identity registration is needed
+ val privateKey = PrivateKeyBuilder.buildFromPrivateKeyData(privateKeyBytes)
+ val wallet = PrivateKeyBuilder(privateKey)
+ val rebuiltAddress = wallet.publicIdentity.identifier
+ Timber.d("createClient: rebuiltAddress=$rebuiltAddress")
+
+ Client.create(
+ wallet,
+ clientOptions(appContext, rebuiltAddress),
+ )
+ } else {
+ // No private key stored - this could happen on old installations
+ // Signal that we need to re-authenticate
+ throw IllegalStateException("No wallet key found. Please sign in again.")
+ }
Client.register(codec = GroupUpdatedCodec())
+ Client.register(codec = ReplyCodec())
+ Client.register(codec = ReactionCodec())
+ Client.register(codec = AttachmentCodec())
+ Client.register(codec = RemoteAttachmentCodec())
_clientState.value = ClientState.Ready
} catch (e: Exception) {
+ Timber.e(e, "createClient failed")
_clientState.value = ClientState.Error(e.localizedMessage.orEmpty())
}
}
}
+ // Set a client that was created externally (e.g., during wallet generation)
+ fun setClient(client: Client) {
+ synchronized(lock) {
+ _client = client
+ Client.register(codec = GroupUpdatedCodec())
+ Client.register(codec = ReplyCodec())
+ Client.register(codec = ReactionCodec())
+ Client.register(codec = AttachmentCodec())
+ Client.register(codec = RemoteAttachmentCodec())
+ _clientState.value = ClientState.Ready
+ }
+ }
+
@UiThread
fun clearClient() {
- _clientState.value = ClientState.Unknown
- _client = null
+ synchronized(lock) {
+ _clientState.value = ClientState.Unknown
+ _client = null
+ // Cancel the scope to prevent memory leaks from pending coroutines
+ managerScope?.cancel()
+ managerScope = null
+ }
}
sealed class ClientState {
- object Unknown : ClientState()
+ data object Unknown : ClientState()
+
+ data object Creating : ClientState()
- object Ready : ClientState()
+ data object Ready : ClientState()
data class Error(
val message: String,
diff --git a/example/src/main/java/org/xmtp/android/example/ExampleApp.kt b/example/src/main/java/org/xmtp/android/example/ExampleApp.kt
index a44348c4b..f65c95f4a 100644
--- a/example/src/main/java/org/xmtp/android/example/ExampleApp.kt
+++ b/example/src/main/java/org/xmtp/android/example/ExampleApp.kt
@@ -13,6 +13,12 @@ const val BASE_LOG_TAG = "WC2"
class ExampleApp : Application() {
override fun onCreate() {
super.onCreate()
+
+ // Initialize Timber for logging
+ if (BuildConfig.DEBUG) {
+ Timber.plant(Timber.DebugTree())
+ }
+
val connectionType = ConnectionType.AUTOMATIC
val relayUrl = "relay.walletconnect.com"
val serverUrl = "wss://$relayUrl?projectId=${BuildConfig.PROJECT_ID}"
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 dac0315b3..39703b258 100644
--- a/example/src/main/java/org/xmtp/android/example/MainActivity.kt
+++ b/example/src/main/java/org/xmtp/android/example/MainActivity.kt
@@ -9,18 +9,24 @@ import android.content.Intent
import android.content.pm.PackageManager
import android.os.Bundle
import android.util.Log
-import android.view.Menu
-import android.view.MenuItem
-import android.view.View
import android.widget.Toast
+import androidx.activity.ComponentActivity
+import androidx.activity.compose.setContent
+import androidx.activity.enableEdgeToEdge
import androidx.activity.viewModels
-import androidx.appcompat.app.AppCompatActivity
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.collectAsState
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.platform.LocalContext
import androidx.core.app.ActivityCompat
import androidx.core.content.ContextCompat
+import androidx.core.content.FileProvider
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.lifecycleScope
import androidx.lifecycle.repeatOnLifecycle
-import androidx.recyclerview.widget.LinearLayoutManager
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
@@ -28,42 +34,53 @@ import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import org.xmtp.android.example.connect.ConnectWalletActivity
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.logs.LogViewerBottomSheet
+import org.xmtp.android.example.conversation.NewConversationActivity
import org.xmtp.android.example.pushnotifications.PushNotificationTokenManager
+import org.xmtp.android.example.ui.components.DrawerState
+import org.xmtp.android.example.ui.screens.ConversationItem
+import org.xmtp.android.example.ui.screens.MainScreen
+import org.xmtp.android.example.ui.sheets.LogFileInfo
+import org.xmtp.android.example.ui.sheets.LogViewerSheet
+import org.xmtp.android.example.ui.sheets.WalletInfo
+import org.xmtp.android.example.ui.sheets.WalletInfoSheet
+import org.xmtp.android.example.ui.sheets.formatFileSize
+import org.xmtp.android.example.ui.theme.XMTPTheme
import org.xmtp.android.example.utils.KeyUtil
import org.xmtp.android.library.Client
-import org.xmtp.android.library.Conversation
+import timber.log.Timber
import uniffi.xmtpv3.FfiLogLevel
import uniffi.xmtpv3.FfiLogRotation
-
-class MainActivity :
- AppCompatActivity(),
- ConversationsClickListener {
+import org.xmtp.android.example.extension.truncatedAddress
+import org.xmtp.android.library.Conversation
+import org.xmtp.android.library.codecs.Attachment
+import org.xmtp.android.library.codecs.DeletedMessage
+import org.xmtp.android.library.libxmtp.Reply
+import org.xmtp.proto.mls.message.contents.TranscriptMessages.GroupUpdated
+import java.io.File
+import java.text.SimpleDateFormat
+import java.util.Calendar
+import java.util.Date
+import java.util.Locale
+
+class MainActivity : ComponentActivity() {
private val viewModel: MainViewModel by viewModels()
- private lateinit var binding: ActivityMainBinding
private lateinit var accountManager: AccountManager
- private lateinit var adapter: ConversationsAdapter
- private var bottomSheet: NewConversationBottomSheet? = null
- private var groupBottomSheet: NewGroupBottomSheet? = null
- private var logsBottomSheet: LogViewerBottomSheet? = null
private val REQUEST_CODE_POST_NOTIFICATIONS = 101
- // Add constant for SharedPreferences
companion object {
private const val PREFS_NAME = "XMTPPreferences"
private const val KEY_LOGS_ACTIVATED = "logs_activated"
+
+ // Push notification server - configured via build variants in production
+ private const val DEFAULT_PUSH_SERVER = "10.0.2.2:8080"
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
+ enableEdgeToEdge()
accountManager = AccountManager.get(this)
checkAndRequestPermissions()
- PushNotificationTokenManager.init(this, "10.0.2.2:8080")
+ PushNotificationTokenManager.init(this, DEFAULT_PUSH_SERVER)
viewModel.setupPush()
val keys = KeyUtil(this).loadKeys()
@@ -74,46 +91,30 @@ class MainActivity :
ClientManager.createClient(keys, this)
- binding = ActivityMainBinding.inflate(layoutInflater)
- setContentView(binding.root)
- setSupportActionBar(binding.toolbar)
-
- adapter = ConversationsAdapter(clickListener = this)
- binding.list.layoutManager = LinearLayoutManager(this)
- binding.list.adapter = adapter
- binding.refresh.setOnRefreshListener {
- if (ClientManager.clientState.value is ClientManager.ClientState.Ready) {
- viewModel.fetchConversations()
+ setContent {
+ XMTPTheme {
+ MainActivityContent(
+ viewModel = viewModel,
+ onConversationClick = { conversationId, topic ->
+ openConversation(topic, conversationId)
+ },
+ onNewConversationClick = { openNewConversation() },
+ onNewGroupClick = { openNewConversation() },
+ onHideDeletedMessagesToggle = { enabled -> onHideDeletedMessagesToggled(enabled) },
+ onToggleLogsClick = { enabled -> onLogsToggled(enabled) },
+ onCopyAddressClick = { copyWalletAddress() },
+ onDisconnectClick = { disconnectWallet() },
+ isLogsEnabled = isLogsActivated(),
+ hideDeletedMessages = KeyUtil(this).getHideDeletedMessages(),
+ )
}
}
- binding.fab.setOnClickListener {
- openConversationDetail()
- }
-
- binding.groupFab.setOnClickListener {
- openGroupDetail()
- }
-
- binding.logsFab.setOnClickListener {
- openLogsViewer()
- }
-
lifecycleScope.launch {
repeatOnLifecycle(Lifecycle.State.STARTED) {
ClientManager.clientState.collect(::ensureClientState)
}
}
- lifecycleScope.launch {
- repeatOnLifecycle(Lifecycle.State.STARTED) {
- viewModel.uiState.collect(::ensureUiState)
- }
- }
- lifecycleScope.launch {
- repeatOnLifecycle(Lifecycle.State.STARTED) {
- viewModel.stream.collect(::addStreamedItem)
- }
- }
lifecycleScope.launch {
repeatOnLifecycle(Lifecycle.State.STARTED) {
@@ -122,10 +123,12 @@ class MainActivity :
is ClientManager.ClientState.Error -> {
retryCreateClientWithBackoff()
}
+
is ClientManager.ClientState.Ready -> {
retryJob?.cancel()
retryJob = null
}
+
else -> Unit
}
}
@@ -180,97 +183,50 @@ class MainActivity :
}
override fun onDestroy() {
- bottomSheet?.dismiss()
- groupBottomSheet?.dismiss()
- logsBottomSheet?.dismiss()
+ // Cancel the retry job to prevent memory leaks
+ retryJob?.cancel()
+ retryJob = null
super.onDestroy()
}
- override fun onCreateOptionsMenu(menu: Menu): Boolean {
- menuInflater.inflate(R.menu.menu_main, menu)
- return true
- }
-
- override fun onOptionsItemSelected(item: MenuItem): Boolean =
- when (item.itemId) {
- R.id.disconnect -> {
- disconnectWallet()
- true
- }
- R.id.copy_address -> {
- copyWalletAddress()
- true
- }
- R.id.activate_logs -> {
- Client.activatePersistentLibXMTPLogWriter(
- applicationContext,
- FfiLogLevel.DEBUG,
- FfiLogRotation.MINUTELY,
- 3,
- )
- setLogsActivated(true)
- Toast.makeText(this, "Persistent logs activated", Toast.LENGTH_SHORT).show()
- true
- }
- R.id.deactivate_logs -> {
- Client.deactivatePersistentLibXMTPLogWriter()
- setLogsActivated(false)
- Toast.makeText(this, "Persistent logs deactivated", Toast.LENGTH_SHORT).show()
- true
- }
- else -> super.onOptionsItemSelected(item)
- }
-
- override fun onConversationClick(conversation: Conversation) {
+ private fun openConversation(topic: String, peerAddress: String) {
startActivity(
ConversationDetailActivity.intent(
this,
- topic = conversation.topic,
- peerAddress = conversation.id,
+ topic = topic,
+ peerAddress = peerAddress,
),
)
}
- override fun onFooterClick(address: String) {
- copyWalletAddress()
- }
-
private fun ensureClientState(clientState: ClientManager.ClientState) {
+ Timber.d("ensureClientState: $clientState")
when (clientState) {
is ClientManager.ClientState.Ready -> {
+ Timber.d("ensureClientState: Ready, fetching conversations...")
viewModel.fetchConversations()
- binding.fab.visibility = View.VISIBLE
- binding.groupFab.visibility = View.VISIBLE
- binding.logsFab.visibility = View.VISIBLE
}
- is ClientManager.ClientState.Error -> showError(clientState.message)
- is ClientManager.ClientState.Unknown -> Unit
- }
- }
-
- private fun addStreamedItem(item: MainViewModel.MainListItem?) {
- item?.let {
- adapter.addItem(item)
- }
- }
- private fun ensureUiState(uiState: MainViewModel.UiState) {
- binding.progress.visibility = View.GONE
- when (uiState) {
- is MainViewModel.UiState.Loading -> {
- if (uiState.listItems.isNullOrEmpty()) {
- binding.progress.visibility = View.VISIBLE
+ is ClientManager.ClientState.Error -> {
+ Timber.e("ensureClientState: Error - ${clientState.message}")
+ // If there's no wallet key, clear the account and redirect to sign-in
+ if (clientState.message.contains("No wallet key found")) {
+ val accounts = accountManager.getAccountsByType(resources.getString(R.string.account_type))
+ accounts.forEach { account ->
+ accountManager.removeAccount(account, null, null, null)
+ }
+ showSignIn()
} else {
- adapter.setData(uiState.listItems)
+ showError(clientState.message)
}
}
- is MainViewModel.UiState.Success -> {
- binding.refresh.isRefreshing = false
- adapter.setData(uiState.listItems)
+
+ is ClientManager.ClientState.Unknown -> {
+ Timber.d("ensureClientState: Unknown")
}
- is MainViewModel.UiState.Error -> {
- binding.refresh.isRefreshing = false
- showError(uiState.message)
+
+ is ClientManager.ClientState.Creating -> {
+ Timber.d("ensureClientState: Creating client...")
}
}
}
@@ -287,6 +243,17 @@ class MainActivity :
}
private fun disconnectWallet() {
+ // Clear the stored private key and environment before clearing the client
+ val keyUtil = KeyUtil(this)
+ // Safely get address only if client is ready
+ val address = if (ClientManager.clientState.value is ClientManager.ClientState.Ready) {
+ ClientManager.client.publicIdentity.identifier
+ } else {
+ null
+ }
+ address?.let { keyUtil.clearPrivateKey(it) }
+ keyUtil.clearEnvironment()
+
ClientManager.clearClient()
PushNotificationTokenManager.clearXMTPPush()
val accounts = accountManager.getAccountsByType(resources.getString(R.string.account_type))
@@ -297,33 +264,19 @@ class MainActivity :
}
private fun copyWalletAddress() {
+ if (ClientManager.clientState.value !is ClientManager.ClientState.Ready) {
+ Toast.makeText(this, "Client not ready", Toast.LENGTH_SHORT).show()
+ return
+ }
val clipboard = getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
- val clip = ClipData.newPlainText("inboxId", ClientManager.client.inboxId)
+ val walletAddress = ClientManager.client.publicIdentity.identifier
+ val clip = ClipData.newPlainText("wallet_address", walletAddress)
clipboard.setPrimaryClip(clip)
+ Toast.makeText(this, "Wallet address copied", Toast.LENGTH_SHORT).show()
}
- private fun openConversationDetail() {
- bottomSheet = NewConversationBottomSheet.newInstance()
- bottomSheet?.show(
- supportFragmentManager,
- NewConversationBottomSheet.TAG,
- )
- }
-
- private fun openGroupDetail() {
- groupBottomSheet = NewGroupBottomSheet.newInstance()
- groupBottomSheet?.show(
- supportFragmentManager,
- NewGroupBottomSheet.TAG,
- )
- }
-
- private fun openLogsViewer() {
- logsBottomSheet = LogViewerBottomSheet.newInstance()
- logsBottomSheet?.show(
- supportFragmentManager,
- LogViewerBottomSheet.TAG,
- )
+ private fun openNewConversation() {
+ startActivity(NewConversationActivity.intent(this))
}
private fun checkAndRequestPermissions() {
@@ -350,4 +303,294 @@ class MainActivity :
val prefs = getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
prefs.edit().putBoolean(KEY_LOGS_ACTIVATED, activated).apply()
}
+
+ private fun onLogsToggled(enabled: Boolean) {
+ if (enabled) {
+ Client.activatePersistentLibXMTPLogWriter(
+ applicationContext,
+ FfiLogLevel.DEBUG,
+ FfiLogRotation.MINUTELY,
+ 3,
+ )
+ setLogsActivated(true)
+ Toast.makeText(this, "Persistent logs activated", Toast.LENGTH_SHORT).show()
+ } else {
+ Client.deactivatePersistentLibXMTPLogWriter()
+ setLogsActivated(false)
+ Toast.makeText(this, "Persistent logs deactivated", Toast.LENGTH_SHORT).show()
+ }
+ }
+
+ private fun onHideDeletedMessagesToggled(enabled: Boolean) {
+ val keyUtil = KeyUtil(this)
+ keyUtil.setHideDeletedMessages(enabled)
+ val message = if (enabled) "Deleted messages will be hidden" else "Deleted messages will be shown"
+ Toast.makeText(this, message, Toast.LENGTH_SHORT).show()
+ }
+}
+
+@Composable
+private fun MainActivityContent(
+ viewModel: MainViewModel,
+ onConversationClick: (String, String) -> Unit,
+ onNewConversationClick: () -> Unit,
+ onNewGroupClick: () -> Unit,
+ onHideDeletedMessagesToggle: (Boolean) -> Unit,
+ onToggleLogsClick: (Boolean) -> Unit,
+ onCopyAddressClick: () -> Unit,
+ onDisconnectClick: () -> Unit,
+ isLogsEnabled: Boolean,
+ hideDeletedMessages: Boolean,
+) {
+ val context = LocalContext.current
+ val uiState by viewModel.uiState.collectAsState()
+ val clientState by ClientManager.clientState.collectAsState()
+
+ // Sheet states
+ var showWalletInfoSheet by remember { mutableStateOf(false) }
+ var showLogViewerSheet by remember { mutableStateOf(false) }
+
+ // Track log and hide deleted states for UI updates
+ var logsEnabled by remember { mutableStateOf(isLogsEnabled) }
+ var hideDeleted by remember { mutableStateOf(hideDeletedMessages) }
+
+ // Convert ViewModel state to UI state
+ val conversations = remember(uiState, clientState) {
+ val items = when (uiState) {
+ is MainViewModel.UiState.Success -> (uiState as MainViewModel.UiState.Success).listItems
+ is MainViewModel.UiState.Loading -> (uiState as MainViewModel.UiState.Loading).listItems ?: emptyList()
+ is MainViewModel.UiState.Error -> emptyList()
+ }
+
+ items.filterIsInstance().map { item ->
+ val isGroup = item.conversation.type == Conversation.Type.GROUP
+ val displayName = when (item.conversation.type) {
+ Conversation.Type.GROUP -> item.displayName
+ Conversation.Type.DM -> item.displayName.truncatedAddress()
+ }
+
+ // Format message time
+ val formattedTime = item.mostRecentMessage?.let { message ->
+ formatMessageTime(message.sentAtNs / 1_000_000)
+ } ?: ""
+
+ // Get message preview
+ val myInboxId = if (clientState is ClientManager.ClientState.Ready) {
+ ClientManager.client.inboxId
+ } else ""
+
+ val messageBody = when (val content = item.mostRecentMessage?.content()) {
+ is String -> content
+ is Reply -> when (val replyContent = content.content) {
+ is String -> replyContent
+ else -> "Message"
+ }
+ is Attachment -> if (content.mimeType.startsWith("image/")) {
+ if (content.mimeType == "image/gif") "GIF" else "Photo"
+ } else "Attachment"
+ is GroupUpdated -> {
+ val added = content.addedInboxesList?.size ?: 0
+ val removed = content.removedInboxesList?.size ?: 0
+ when {
+ added > 0 && removed > 0 -> "$added added, $removed removed"
+ added > 0 -> "$added member${if (added > 1) "s" else ""} added"
+ removed > 0 -> "$removed member${if (removed > 1) "s" else ""} removed"
+ else -> "Group updated"
+ }
+ }
+ is DeletedMessage -> "Message deleted"
+ else -> "Message"
+ }
+
+ val isMe = item.mostRecentMessage?.senderInboxId == myInboxId
+ val lastMessage = if (messageBody.isNotBlank()) {
+ if (isMe) "You: $messageBody" else messageBody
+ } else ""
+
+ ConversationItem(
+ id = item.conversation.topic,
+ name = displayName,
+ lastMessage = lastMessage,
+ timestamp = formattedTime,
+ isGroup = isGroup,
+ memberCount = null, // Would require async call to get member count
+ unreadCount = 0,
+ )
+ }
+ }
+
+ val isLoading = uiState is MainViewModel.UiState.Loading &&
+ (uiState as MainViewModel.UiState.Loading).listItems.isNullOrEmpty()
+
+ // Get wallet info from client state
+ val walletAddress = remember(clientState) {
+ if (clientState is ClientManager.ClientState.Ready) {
+ ClientManager.client.publicIdentity.identifier
+ } else {
+ ""
+ }
+ }
+
+ val environment = remember(clientState) {
+ if (clientState is ClientManager.ClientState.Ready) {
+ ClientManager.client.environment.name
+ } else {
+ ""
+ }
+ }
+
+ val inboxId = remember(clientState) {
+ if (clientState is ClientManager.ClientState.Ready) {
+ ClientManager.client.inboxId
+ } else {
+ ""
+ }
+ }
+
+ val installationId = remember(clientState) {
+ if (clientState is ClientManager.ClientState.Ready) {
+ ClientManager.client.installationId
+ } else {
+ ""
+ }
+ }
+
+ val drawerState = DrawerState(
+ walletAddress = walletAddress,
+ environment = environment,
+ isLogsEnabled = logsEnabled,
+ hideDeletedMessages = hideDeleted,
+ )
+
+ // Find topic for conversation navigation (topic is the conversation id)
+ val conversationTopics = remember(uiState) {
+ val items = when (uiState) {
+ is MainViewModel.UiState.Success -> (uiState as MainViewModel.UiState.Success).listItems
+ is MainViewModel.UiState.Loading -> (uiState as MainViewModel.UiState.Loading).listItems ?: emptyList()
+ else -> emptyList()
+ }
+ items.filterIsInstance()
+ .associate { it.conversation.topic to it.conversation.topic }
+ }
+
+ MainScreen(
+ conversations = conversations,
+ drawerState = drawerState,
+ isLoading = isLoading,
+ onConversationClick = { conversationId ->
+ val topic = conversationTopics[conversationId] ?: ""
+ onConversationClick(conversationId, topic)
+ },
+ onNewConversationClick = onNewConversationClick,
+ onSearchClick = { /* TODO: Implement search */ },
+ onWalletInfoClick = { showWalletInfoSheet = true },
+ onNewGroupClick = onNewGroupClick,
+ onHideDeletedMessagesToggle = { enabled ->
+ hideDeleted = enabled
+ onHideDeletedMessagesToggle(enabled)
+ },
+ onViewLogsClick = { showLogViewerSheet = true },
+ onToggleLogsClick = { enabled ->
+ logsEnabled = enabled
+ onToggleLogsClick(enabled)
+ },
+ onCopyAddressClick = onCopyAddressClick,
+ onDisconnectClick = onDisconnectClick,
+ )
+
+ // Wallet Info Sheet
+ if (showWalletInfoSheet) {
+ val walletInfo = WalletInfo(
+ walletAddress = walletAddress,
+ inboxId = inboxId,
+ installationId = installationId,
+ environment = environment,
+ libXmtpVersion = if (clientState is ClientManager.ClientState.Ready) ClientManager.client.libXMTPVersion else "",
+ )
+
+ WalletInfoSheet(
+ walletInfo = walletInfo,
+ isLogsEnabled = logsEnabled,
+ onDismiss = { showWalletInfoSheet = false },
+ onCopyValue = { label, value ->
+ val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
+ val clip = ClipData.newPlainText(label, value)
+ clipboard.setPrimaryClip(clip)
+ Toast.makeText(context, "$label copied", Toast.LENGTH_SHORT).show()
+ },
+ onLogsToggled = { enabled ->
+ logsEnabled = enabled
+ onToggleLogsClick(enabled)
+ },
+ onDisconnect = {
+ showWalletInfoSheet = false
+ onDisconnectClick()
+ },
+ )
+ }
+
+ // Log Viewer Sheet
+ if (showLogViewerSheet) {
+ val logDir = File(context.filesDir, "xmtp_logs")
+ val logFiles = if (logDir.exists()) {
+ logDir.listFiles()
+ ?.filter { it.isFile && it.name.endsWith(".log") }
+ ?.sortedByDescending { it.lastModified() }
+ ?.map { file ->
+ LogFileInfo(
+ file = file,
+ name = file.name,
+ size = formatFileSize(file.length()),
+ )
+ } ?: emptyList()
+ } else {
+ emptyList()
+ }
+
+ LogViewerSheet(
+ logFiles = logFiles,
+ onDismiss = { showLogViewerSheet = false },
+ onShareFile = { file ->
+ val uri = FileProvider.getUriForFile(
+ context,
+ "${context.packageName}.fileprovider",
+ file,
+ )
+ val shareIntent = Intent(Intent.ACTION_SEND).apply {
+ type = "text/plain"
+ putExtra(Intent.EXTRA_STREAM, uri)
+ addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
+ }
+ context.startActivity(Intent.createChooser(shareIntent, "Share Log File"))
+ },
+ )
+ }
+}
+
+private fun formatMessageTime(timestampMs: Long): String {
+ val messageDate = Date(timestampMs)
+ val now = Calendar.getInstance()
+ val messageCalendar = Calendar.getInstance().apply { time = messageDate }
+
+ return when {
+ // Today - show time
+ now.get(Calendar.YEAR) == messageCalendar.get(Calendar.YEAR) &&
+ now.get(Calendar.DAY_OF_YEAR) == messageCalendar.get(Calendar.DAY_OF_YEAR) -> {
+ SimpleDateFormat("HH:mm", Locale.getDefault()).format(messageDate)
+ }
+ // Yesterday
+ now.get(Calendar.YEAR) == messageCalendar.get(Calendar.YEAR) &&
+ now.get(Calendar.DAY_OF_YEAR) - messageCalendar.get(Calendar.DAY_OF_YEAR) == 1 -> {
+ "Yesterday"
+ }
+ // Within the last week - show day name
+ now.get(Calendar.YEAR) == messageCalendar.get(Calendar.YEAR) &&
+ now.get(Calendar.DAY_OF_YEAR) - messageCalendar.get(Calendar.DAY_OF_YEAR) < 7 -> {
+ SimpleDateFormat("EEE", Locale.getDefault()).format(messageDate)
+ }
+ // Older - show date
+ else -> {
+ SimpleDateFormat("MMM d", Locale.getDefault()).format(messageDate)
+ }
+ }
}
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 640ba9511..52abaec52 100644
--- a/example/src/main/java/org/xmtp/android/example/MainViewModel.kt
+++ b/example/src/main/java/org/xmtp/android/example/MainViewModel.kt
@@ -1,7 +1,6 @@
package org.xmtp.android.example
import androidx.annotation.UiThread
-import androidx.annotation.WorkerThread
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.Dispatchers
@@ -12,17 +11,19 @@ import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.emptyFlow
+import kotlinx.coroutines.flow.filterIsInstance
+import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.flowOn
+import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.mapLatest
+import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
-import kotlinx.coroutines.runBlocking
-import org.xmtp.android.example.extension.flowWhileShared
-import org.xmtp.android.example.extension.stateFlow
import org.xmtp.android.example.pushnotifications.PushNotificationTokenManager
import org.xmtp.android.library.Conversation
import org.xmtp.android.library.Topic
import org.xmtp.android.library.libxmtp.DecodedMessage
import org.xmtp.android.library.push.Service
+import timber.log.Timber
class MainViewModel : ViewModel() {
private val _uiState = MutableStateFlow(UiState.Loading(null))
@@ -44,9 +45,12 @@ class MainViewModel : ViewModel() {
viewModelScope.launch(Dispatchers.IO) {
val listItems = mutableListOf()
try {
+ Timber.d("fetchConversations: starting, clientState=${ClientManager.clientState.value}")
val conversations = ClientManager.client.conversations
// Ensure we fetch the latest conversations from the network before listing
+ Timber.d("fetchConversations: syncing conversations...")
conversations.sync()
+ Timber.d("fetchConversations: sync complete")
val subscriptions =
conversations
.allPushTopics()
@@ -82,53 +86,98 @@ class MainViewModel : ViewModel() {
subscriptions.add(welcomeTopic)
PushNotificationTokenManager.xmtpPush.subscribeWithMetadata(subscriptions)
+ val conversationList = conversations.list()
+ Timber.d("fetchConversations: found ${conversationList.size} conversations")
listItems.addAll(
- conversations.list().map { conversation ->
+ conversationList.map { conversation ->
val lastMessage = fetchMostRecentMessage(conversation)
+ val (displayName, peerAddress) = getConversationDisplayInfo(conversation)
MainListItem.ConversationItem(
id = conversation.topic,
- conversation,
- lastMessage,
+ conversation = conversation,
+ mostRecentMessage = lastMessage,
+ displayName = displayName,
+ peerAddress = peerAddress,
)
},
)
- listItems.add(
- MainListItem.Footer(
- id = "footer",
- ClientManager.client.inboxId,
- ClientManager.client.environment.name,
- ),
- )
+ Timber.d("fetchConversations: success, total items=${listItems.size}")
_uiState.value = UiState.Success(listItems)
} catch (e: Exception) {
+ Timber.e(e, "fetchConversations: error")
_uiState.value = UiState.Error(e.localizedMessage.orEmpty())
}
}
}
- @WorkerThread
- private fun fetchMostRecentMessage(conversation: Conversation): DecodedMessage? =
- runBlocking { conversation.lastMessage() }
+ private suspend fun fetchMostRecentMessage(conversation: Conversation): DecodedMessage? = conversation.lastMessage()
+
+ private suspend fun getConversationDisplayInfo(conversation: Conversation): Pair =
+ when (conversation) {
+ is Conversation.Group -> {
+ val groupName = conversation.group.name()
+ val displayName = if (groupName.isNotBlank()) groupName else conversation.id
+ Pair(displayName, null)
+ }
+ is Conversation.Dm -> {
+ val peerInboxId = conversation.dm.peerInboxId
+ val members = conversation.dm.members()
+ val peerMember = members.find { it.inboxId == peerInboxId }
+ val peerAddress = peerMember?.identities?.firstOrNull()?.identifier
+ val displayName = peerAddress ?: conversation.id
+ Pair(displayName, peerAddress)
+ }
+ }
+
+ // Stream for new conversations - reacts to client state changes
@OptIn(ExperimentalCoroutinesApi::class)
val stream: StateFlow =
- stateFlow(viewModelScope, null) { subscriptionCount ->
- if (ClientManager.clientState.value is ClientManager.ClientState.Ready) {
+ ClientManager.clientState
+ .filterIsInstance()
+ .flatMapLatest {
ClientManager.client.conversations
.stream()
- .flowWhileShared(
- subscriptionCount,
- SharingStarted.WhileSubscribed(1000L),
- ).flowOn(Dispatchers.IO)
.distinctUntilChanged()
- .mapLatest { conversation ->
+ .mapLatest { conversation ->
val lastMessage = fetchMostRecentMessage(conversation)
- MainListItem.ConversationItem(conversation.topic, conversation, lastMessage)
- }.catch { emptyFlow() }
- } else {
- emptyFlow()
- }
- }
+ val (displayName, peerAddress) = getConversationDisplayInfo(conversation)
+ MainListItem.ConversationItem(
+ id = conversation.topic,
+ conversation = conversation,
+ mostRecentMessage = lastMessage,
+ displayName = displayName,
+ peerAddress = peerAddress,
+ )
+ }.catch { e ->
+ Timber.e(e, "Error in conversation stream")
+ emit(null)
+ }
+ }.flowOn(Dispatchers.IO)
+ .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000L), null)
+
+ // Stream for message updates - triggers when any conversation receives a new message
+ // Uses flatMapLatest to react to client state changes
+ @OptIn(ExperimentalCoroutinesApi::class)
+ val messageStream: StateFlow =
+ ClientManager.clientState
+ .filterIsInstance()
+ .flatMapLatest {
+ ClientManager.client.conversations
+ .streamAllMessages()
+ .map { message ->
+ MessageUpdate(message.topic, message)
+ }.catch { e ->
+ Timber.e(e, "Error in message stream")
+ emit(null)
+ }
+ }.flowOn(Dispatchers.IO)
+ .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000L), null)
+
+ data class MessageUpdate(
+ val topic: String,
+ val message: DecodedMessage,
+ )
sealed class UiState {
data class Loading(
@@ -150,19 +199,14 @@ class MainViewModel : ViewModel() {
) {
companion object {
const val ITEM_TYPE_CONVERSATION = 1
- const val ITEM_TYPE_FOOTER = 2
}
data class ConversationItem(
override val id: String,
val conversation: Conversation,
val mostRecentMessage: DecodedMessage?,
+ val displayName: String,
+ val peerAddress: String? = null,
) : MainListItem(id, ITEM_TYPE_CONVERSATION)
-
- data class Footer(
- override val id: String,
- val address: String,
- val environment: String,
- ) : MainListItem(id, ITEM_TYPE_FOOTER)
}
}
diff --git a/example/src/main/java/org/xmtp/android/example/connect/ConnectWalletActivity.kt b/example/src/main/java/org/xmtp/android/example/connect/ConnectWalletActivity.kt
index a4bac181a..eabdb5154 100644
--- a/example/src/main/java/org/xmtp/android/example/connect/ConnectWalletActivity.kt
+++ b/example/src/main/java/org/xmtp/android/example/connect/ConnectWalletActivity.kt
@@ -3,28 +3,48 @@ package org.xmtp.android.example.connect
import android.accounts.Account
import android.accounts.AccountManager
import android.content.Intent
-import android.net.Uri
import android.os.Bundle
-import android.view.View
-import android.widget.Toast
+import androidx.activity.ComponentActivity
+import androidx.activity.compose.setContent
+import androidx.activity.enableEdgeToEdge
import androidx.activity.viewModels
-import androidx.appcompat.app.AppCompatActivity
-import androidx.core.view.isVisible
-import androidx.lifecycle.Lifecycle
-import androidx.lifecycle.lifecycleScope
-import androidx.lifecycle.repeatOnLifecycle
-import kotlinx.coroutines.launch
+import androidx.compose.runtime.collectAsState
+import androidx.compose.runtime.getValue
import org.xmtp.android.example.MainActivity
import org.xmtp.android.example.R
-import org.xmtp.android.example.databinding.ActivityConnectWalletBinding
+import org.xmtp.android.example.ui.screens.ConnectWalletScreen
+import org.xmtp.android.example.ui.theme.XMTPTheme
-class ConnectWalletActivity : AppCompatActivity() {
- private lateinit var binding: ActivityConnectWalletBinding
+class ConnectWalletActivity : ComponentActivity() {
+ private val viewModel: ConnectWalletViewModel by viewModels()
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
+ enableEdgeToEdge()
- binding = ActivityConnectWalletBinding.inflate(layoutInflater)
- setContentView(binding.root)
+ setContent {
+ XMTPTheme {
+ val uiState by viewModel.uiState.collectAsState()
+
+ ConnectWalletScreen(
+ uiState = uiState,
+ onGenerateWallet = { environment, logLevel ->
+ viewModel.generateWallet(environment, logLevel)
+ },
+ onConnectSuccess = { address ->
+ signIn(address)
+ },
+ )
+ }
+ }
+ }
+
+ private fun signIn(address: String) {
+ val accountManager = AccountManager.get(this)
+ Account(address, resources.getString(R.string.account_type)).also { account ->
+ accountManager.addAccountExplicitly(account, address, null)
+ }
+ startActivity(Intent(this, MainActivity::class.java))
+ finish()
}
}
diff --git a/example/src/main/java/org/xmtp/android/example/connect/ConnectWalletFragment.kt b/example/src/main/java/org/xmtp/android/example/connect/ConnectWalletFragment.kt
deleted file mode 100644
index 2c0e360b1..000000000
--- a/example/src/main/java/org/xmtp/android/example/connect/ConnectWalletFragment.kt
+++ /dev/null
@@ -1,119 +0,0 @@
-package org.xmtp.android.example.connect
-
-import android.accounts.Account
-import android.accounts.AccountManager
-import android.content.Intent
-import android.net.Uri
-import android.os.Bundle
-import android.view.LayoutInflater
-import android.view.View
-import android.view.ViewGroup
-import android.widget.Toast
-import androidx.core.view.isVisible
-import androidx.fragment.app.Fragment
-import androidx.fragment.app.viewModels
-import androidx.lifecycle.Lifecycle
-import androidx.lifecycle.lifecycleScope
-import androidx.lifecycle.repeatOnLifecycle
-import androidx.navigation.fragment.findNavController
-import com.walletconnect.wcmodal.client.WalletConnectModal
-import com.walletconnect.wcmodal.ui.openWalletConnectModal
-import kotlinx.coroutines.launch
-import org.xmtp.android.example.MainActivity
-import org.xmtp.android.example.R
-import org.xmtp.android.example.databinding.FragmentConnectWalletBinding
-import timber.log.Timber
-
-class ConnectWalletFragment : Fragment() {
- private val viewModel: ConnectWalletViewModel by viewModels()
-
- private var _binding: FragmentConnectWalletBinding? = null
- private val binding get() = _binding!!
-
- override fun onCreateView(
- inflater: LayoutInflater,
- container: ViewGroup?,
- savedInstanceState: Bundle?,
- ): View {
- _binding = FragmentConnectWalletBinding.inflate(inflater, container, false)
- return binding.root
- }
-
- override fun onViewCreated(
- view: View,
- savedInstanceState: Bundle?,
- ) {
- super.onViewCreated(view, savedInstanceState)
-
- viewLifecycleOwner.lifecycleScope.launch {
- repeatOnLifecycle(Lifecycle.State.STARTED) {
- viewModel.uiState.collect(::ensureUiState)
- }
- }
-
- viewLifecycleOwner.lifecycleScope.launch {
- repeatOnLifecycle(Lifecycle.State.STARTED) {
- viewModel.showWalletState.collect(::showWalletState)
- }
- }
-
- binding.generateButton.setOnClickListener {
- viewModel.generateWallet()
- }
- }
-
- private fun ensureUiState(uiState: ConnectWalletViewModel.ConnectUiState) {
- when (uiState) {
- is ConnectWalletViewModel.ConnectUiState.Error -> showError(uiState.message)
- ConnectWalletViewModel.ConnectUiState.Loading -> showLoading()
- is ConnectWalletViewModel.ConnectUiState.Success -> signIn(uiState.address)
-
- ConnectWalletViewModel.ConnectUiState.Unknown -> Unit
- }
- }
-
- private fun showWalletState(walletState: ConnectWalletViewModel.ShowWalletForSigningState) {
- if (walletState.showWallet) {
- try {
- val intent = Intent(Intent.ACTION_VIEW, walletState.uri)
- intent.flags = Intent.FLAG_ACTIVITY_NEW_TASK
- requireActivity().startActivity(intent)
- viewModel.clearShowWalletState()
- } catch (e: Exception) {
- Timber.tag(FRAGMENT_LOG_TAG).e("Activity not found: $e")
- }
- }
- }
-
- private fun signIn(address: String) {
- val accountManager = AccountManager.get(requireContext())
- Account(address, resources.getString(R.string.account_type)).also { account ->
- accountManager.addAccountExplicitly(account, address, null)
- }
- requireActivity().startActivity(Intent(requireActivity(), MainActivity::class.java))
- requireActivity().finish()
- }
-
- private fun showError(message: String) {
- binding.progress.visibility = View.GONE
- binding.generateButton.visibility = View.VISIBLE
- Toast.makeText(requireContext(), message, Toast.LENGTH_SHORT).show()
- }
-
- private fun showLoading() {
- binding.progress.visibility = View.VISIBLE
- binding.generateButton.visibility = View.GONE
- binding.connectButton.visibility = View.GONE
- binding.connectError.visibility = View.GONE
- }
-
- override fun onDestroyView() {
- super.onDestroyView()
- _binding = null
- }
-
- companion object {
- private const val WC_URI_SCHEME = "wc://wc?uri="
- private const val FRAGMENT_LOG_TAG = "ConnectWalletFragment"
- }
-}
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 35c88e24e..5f89b9ba5 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
@@ -16,10 +16,12 @@ import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import org.xmtp.android.example.ClientManager
+import org.xmtp.android.example.utils.KeyUtil
import org.xmtp.android.library.Client
-import org.xmtp.android.library.XMTPException
-import org.xmtp.android.library.codecs.GroupUpdatedCodec
+import org.xmtp.android.library.XMTPEnvironment
import org.xmtp.android.library.messages.PrivateKeyBuilder
+import uniffi.xmtpv3.FfiLogLevel
+import uniffi.xmtpv3.FfiLogRotation
class ConnectWalletViewModel(
application: Application,
@@ -32,23 +34,57 @@ class ConnectWalletViewModel(
val uiState: StateFlow = _uiState
@UiThread
- fun generateWallet() {
+ fun generateWallet(
+ environment: XMTPEnvironment,
+ logLevel: FfiLogLevel?,
+ ) {
viewModelScope.launch(Dispatchers.IO) {
_uiState.value = ConnectUiState.Loading
try {
+ // Store the selected environment and log level
+ ClientManager.selectedEnvironment = environment
+ ClientManager.selectedLogLevel = logLevel
+
+ // Activate logging if a log level is selected
+ if (logLevel != null) {
+ Client.activatePersistentLibXMTPLogWriter(
+ getApplication(),
+ logLevel,
+ FfiLogRotation.MINUTELY,
+ 3,
+ )
+ }
+
val wallet = PrivateKeyBuilder()
+ val address = wallet.publicIdentity.identifier
+
+ // Store the private key and environment for later use on app restart
+ val keyUtil = KeyUtil(getApplication())
+ val privateKeyBytes =
+ wallet
+ .getPrivateKey()
+ .secp256K1.bytes
+ .toByteArray()
+ keyUtil.storePrivateKey(address, privateKeyBytes)
+ keyUtil.storeEnvironment(environment.name)
+
val client =
Client.create(
wallet,
- ClientManager.clientOptions(getApplication(), wallet.publicIdentity.identifier),
+ ClientManager.clientOptions(
+ getApplication(),
+ address,
+ environment,
+ ),
)
- Client.register(codec = GroupUpdatedCodec())
+ // Store the client in ClientManager so it can be used by MainActivity
+ ClientManager.setClient(client)
_uiState.value =
ConnectUiState.Success(
- wallet.publicIdentity.identifier,
+ address,
)
- } catch (e: XMTPException) {
- _uiState.value = ConnectUiState.Error(e.message.orEmpty())
+ } catch (e: Exception) {
+ _uiState.value = ConnectUiState.Error(e.message ?: "Unknown error occurred")
}
}
}
diff --git a/example/src/main/java/org/xmtp/android/example/conversation/AttachmentPreviewActivity.kt b/example/src/main/java/org/xmtp/android/example/conversation/AttachmentPreviewActivity.kt
new file mode 100644
index 000000000..292a63b68
--- /dev/null
+++ b/example/src/main/java/org/xmtp/android/example/conversation/AttachmentPreviewActivity.kt
@@ -0,0 +1,185 @@
+package org.xmtp.android.example.conversation
+
+import android.app.Activity
+import android.content.Context
+import android.content.Intent
+import android.graphics.Bitmap
+import android.graphics.BitmapFactory
+import android.net.Uri
+import android.os.Build
+import android.os.Bundle
+import android.text.format.Formatter
+import android.widget.Toast
+import androidx.activity.ComponentActivity
+import androidx.activity.compose.setContent
+import androidx.activity.enableEdgeToEdge
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableIntStateOf
+import androidx.compose.runtime.mutableStateListOf
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import org.xmtp.android.example.ui.screens.AttachmentPreviewItem
+import org.xmtp.android.example.ui.screens.AttachmentPreviewScreen
+import org.xmtp.android.example.ui.theme.XMTPTheme
+
+class AttachmentPreviewActivity : ComponentActivity() {
+ companion object {
+ private const val EXTRA_ATTACHMENT_URIS = "attachment_uris"
+ const val RESULT_ATTACHMENTS = "result_attachments"
+ const val RESULT_CAPTIONS = "result_captions"
+
+ fun intent(
+ context: Context,
+ uris: List,
+ ): Intent =
+ Intent(context, AttachmentPreviewActivity::class.java).apply {
+ putParcelableArrayListExtra(EXTRA_ATTACHMENT_URIS, ArrayList(uris))
+ // Grant read permission for URIs so they can be accessed in the preview activity
+ addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
+ }
+ }
+
+ private val attachmentUris = mutableListOf()
+ private val captions = mutableMapOf()
+ private val previewBitmaps = mutableMapOf()
+
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+ enableEdgeToEdge()
+
+ // Get attachment URIs from intent (API 33+ compatible)
+ val uris = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
+ intent.getParcelableArrayListExtra(EXTRA_ATTACHMENT_URIS, Uri::class.java)
+ } else {
+ @Suppress("DEPRECATION")
+ intent.getParcelableArrayListExtra(EXTRA_ATTACHMENT_URIS)
+ }
+
+ if (uris.isNullOrEmpty()) {
+ finish()
+ return
+ }
+
+ attachmentUris.addAll(uris)
+
+ setContent {
+ XMTPTheme {
+ var currentCaption by remember { mutableStateOf("") }
+ var currentIndex by remember { mutableIntStateOf(0) }
+
+ // Build attachment preview items
+ val attachments = remember {
+ mutableStateListOf().apply {
+ attachmentUris.forEachIndexed { index, uri ->
+ val mimeType = contentResolver.getType(uri) ?: "application/octet-stream"
+ val isImage = mimeType.startsWith("image/")
+ val filename = getFilename(uri)
+ val fileSize = Formatter.formatFileSize(
+ this@AttachmentPreviewActivity,
+ getFileSize(uri),
+ )
+
+ // Load bitmap for images
+ val bitmap = if (isImage) {
+ loadBitmap(uri)?.also { previewBitmaps[index] = it }
+ } else {
+ null
+ }
+
+ add(
+ AttachmentPreviewItem(
+ uri = uri,
+ filename = filename,
+ fileSize = fileSize,
+ mimeType = mimeType,
+ thumbnail = bitmap,
+ isImage = isImage,
+ ),
+ )
+ }
+ }
+ }
+
+ AttachmentPreviewScreen(
+ attachments = attachments,
+ caption = currentCaption,
+ selectedIndex = currentIndex,
+ onCaptionChange = { caption ->
+ currentCaption = caption
+ captions[currentIndex] = caption
+ },
+ onSelectedIndexChange = { newIndex ->
+ // Save current caption before switching
+ captions[currentIndex] = currentCaption
+ currentIndex = newIndex
+ // Load caption for new index
+ currentCaption = captions[newIndex] ?: ""
+ },
+ onClose = {
+ setResult(Activity.RESULT_CANCELED)
+ finish()
+ },
+ onSend = {
+ // Save current caption before sending
+ captions[currentIndex] = currentCaption
+
+ val resultIntent = Intent().apply {
+ putParcelableArrayListExtra(RESULT_ATTACHMENTS, ArrayList(attachmentUris))
+ putStringArrayListExtra(
+ RESULT_CAPTIONS,
+ ArrayList(attachmentUris.indices.map { idx -> captions[idx] ?: "" }),
+ )
+ }
+ setResult(Activity.RESULT_OK, resultIntent)
+ finish()
+ },
+ )
+ }
+ }
+ }
+
+ private fun loadBitmap(uri: Uri): Bitmap? {
+ return try {
+ contentResolver.openInputStream(uri)?.use { inputStream ->
+ BitmapFactory.decodeStream(inputStream)
+ }
+ } catch (e: Exception) {
+ Toast.makeText(this, "Failed to load image", Toast.LENGTH_SHORT).show()
+ null
+ }
+ }
+
+ private fun getFilename(uri: Uri): String {
+ var filename = "attachment"
+ contentResolver.query(uri, null, null, null, null)?.use { cursor ->
+ if (cursor.moveToFirst()) {
+ val nameIndex = cursor.getColumnIndex(android.provider.OpenableColumns.DISPLAY_NAME)
+ if (nameIndex >= 0) {
+ filename = cursor.getString(nameIndex) ?: "attachment"
+ }
+ }
+ }
+ return filename
+ }
+
+ private fun getFileSize(uri: Uri): Long {
+ var size = 0L
+ contentResolver.query(uri, null, null, null, null)?.use { cursor ->
+ if (cursor.moveToFirst()) {
+ val sizeIndex = cursor.getColumnIndex(android.provider.OpenableColumns.SIZE)
+ if (sizeIndex >= 0) {
+ size = cursor.getLong(sizeIndex)
+ }
+ }
+ }
+ return size
+ }
+
+ override fun onDestroy() {
+ super.onDestroy()
+ // Recycle bitmaps to prevent memory leaks
+ previewBitmaps.values.forEach { it.recycle() }
+ previewBitmaps.clear()
+ }
+}
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 a442f8355..3a260a813 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,37 +1,116 @@
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.net.Uri
import android.os.Bundle
-import android.view.Menu
-import android.view.MenuItem
-import android.view.View
import android.widget.Toast
+import androidx.activity.ComponentActivity
+import androidx.activity.compose.rememberLauncherForActivityResult
+import androidx.activity.compose.setContent
+import androidx.activity.enableEdgeToEdge
+import androidx.activity.result.contract.ActivityResultContracts
import androidx.activity.viewModels
-import androidx.appcompat.app.AppCompatActivity
-import androidx.core.widget.addTextChangedListener
+import androidx.compose.foundation.background
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.PaddingValues
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.imePadding
+import androidx.compose.foundation.layout.navigationBarsPadding
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.layout.statusBarsPadding
+import androidx.compose.foundation.layout.width
+import androidx.compose.foundation.lazy.LazyColumn
+import androidx.compose.foundation.lazy.items
+import androidx.compose.foundation.lazy.rememberLazyListState
+import androidx.compose.foundation.shape.CircleShape
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.automirrored.filled.ArrowBack
+import androidx.compose.material.icons.filled.CameraAlt
+import androidx.compose.material.icons.filled.Description
+import androidx.compose.material.icons.filled.Gif
+import androidx.compose.material.icons.filled.Photo
+import androidx.compose.material3.CircularProgressIndicator
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.Icon
+import androidx.compose.material3.IconButton
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.ModalBottomSheet
+import androidx.compose.material3.Scaffold
+import androidx.compose.material3.Surface
+import androidx.compose.material3.Text
+import androidx.compose.material3.rememberModalBottomSheetState
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.collectAsState
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.rememberCoroutineScope
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.graphics.vector.ImageVector
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.text.style.TextOverflow
+import androidx.compose.ui.unit.dp
+import androidx.core.content.FileProvider
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.Dispatchers
import kotlinx.coroutines.launch
-import org.xmtp.android.example.R
-import org.xmtp.android.example.databinding.ActivityConversationDetailBinding
+import kotlinx.coroutines.withContext
+import org.xmtp.android.example.ClientManager
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
+import org.xmtp.android.example.ui.components.EmojiPicker
+import org.xmtp.android.example.ui.components.MessageBubble
+import org.xmtp.android.example.ui.components.MessageComposer
+import org.xmtp.android.example.ui.components.ReplyPreviewBar
+import org.xmtp.android.example.ui.components.ReplyPreviewData
+import org.xmtp.android.example.ui.components.SystemMessage
+import org.xmtp.android.example.ui.dialogs.DeleteConfirmationDialog
+import org.xmtp.android.example.ui.sheets.MessageOptionsSheet
+import org.xmtp.android.example.ui.sheets.MessageOptionsState
+import org.xmtp.android.example.ui.theme.XMTPTheme
+import org.xmtp.android.library.Conversation
+import org.xmtp.android.library.codecs.Attachment
+import org.xmtp.android.library.codecs.DeletedMessage
+import org.xmtp.android.library.codecs.Reaction
+import org.xmtp.android.library.codecs.ReactionAction
+import org.xmtp.android.library.libxmtp.DecodedMessageV2
+import org.xmtp.android.library.libxmtp.Reply
+import java.io.File
+import java.text.SimpleDateFormat
+import java.util.Calendar
+import java.util.Date
+import java.util.Locale
+import kotlin.math.abs
+class ConversationDetailActivity : ComponentActivity() {
private val viewModel: ConversationDetailViewModel by viewModels()
- private val peerAddress
- get() = intent.extras?.getString(EXTRA_PEER_ADDRESS)
+ private val avatarColors = listOf(
+ Color(0xFFFC4F37),
+ Color(0xFF5856D6),
+ Color(0xFF34C759),
+ Color(0xFFFF9500),
+ Color(0xFF007AFF),
+ Color(0xFFAF52DE),
+ Color(0xFF00C7BE),
+ Color(0xFFFF2D55),
+ )
companion object {
const val EXTRA_CONVERSATION_TOPIC = "EXTRA_CONVERSATION_TOPIC"
@@ -50,138 +129,905 @@ class ConversationDetailActivity : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
+ enableEdgeToEdge()
+
viewModel.setConversationTopic(intent.extras?.getString(EXTRA_CONVERSATION_TOPIC))
- binding = ActivityConversationDetailBinding.inflate(layoutInflater)
- setContentView(binding.root)
- setSupportActionBar(binding.toolbar)
- supportActionBar?.setDisplayHomeAsUpEnabled(true)
- supportActionBar?.subtitle =
- if (peerAddress != null && peerAddress!!.contains(",")) {
- val addresses = peerAddress?.split(",")?.toMutableList()
- addresses?.joinToString(" & ") {
- it.truncatedAddress()
- }
- } else {
- peerAddress?.truncatedAddress()
- }
+ // Load and apply user preferences for hide deleted messages
+ val keyUtil = org.xmtp.android.example.utils.KeyUtil(this)
+ viewModel.setHideDeletedMessages(keyUtil.getHideDeletedMessages())
- adapter = MessageAdapter()
- binding.list.layoutManager =
- LinearLayoutManager(this, RecyclerView.VERTICAL, true)
- binding.list.adapter = adapter
+ viewModel.fetchMessages()
+ // Observe streamed messages
lifecycleScope.launch {
repeatOnLifecycle(Lifecycle.State.STARTED) {
- viewModel.uiState.collect(::ensureUiState)
+ viewModel.streamMessages.collect { result ->
+ when (result) {
+ is ConversationDetailViewModel.StreamedMessageResult.RefreshNeeded -> {
+ viewModel.fetchMessages()
+ }
+ is ConversationDetailViewModel.StreamedMessageResult.NewMessage -> {
+ viewModel.fetchMessages()
+ }
+ null -> {}
+ }
+ }
}
}
- binding.messageEditText.requestFocus()
- binding.messageEditText.addTextChangedListener {
- val sendEnabled = !binding.messageEditText.text.isNullOrBlank()
- binding.sendButton.isEnabled = sendEnabled
+ setContent {
+ XMTPTheme {
+ ConversationDetailScreen(
+ viewModel = viewModel,
+ peerAddress = intent.extras?.getString(EXTRA_PEER_ADDRESS) ?: "",
+ conversationTopic = intent.extras?.getString(EXTRA_CONVERSATION_TOPIC) ?: "",
+ avatarColors = avatarColors,
+ onBackClick = { finish() },
+ onNavigateToGroupManagement = { topic ->
+ startActivity(GroupManagementActivity.intent(this, topic))
+ },
+ onNavigateToUserProfile = { address, inboxId ->
+ startActivity(UserProfileActivity.intent(this, address, inboxId))
+ },
+ onNavigateToAttachmentPreview = { uris ->
+ startActivity(AttachmentPreviewActivity.intent(this, uris))
+ },
+ )
+ }
}
+ }
+
+ override fun onResume() {
+ super.onResume()
+ viewModel.fetchMessages()
+ }
+}
+
+// Data class for message display
+private data class MessageDisplayItem(
+ val id: String,
+ val message: String,
+ val timestamp: String,
+ val isMe: Boolean,
+ val senderName: String?,
+ val senderColor: Color?,
+ val isSystemMessage: Boolean,
+ val reactions: List>?,
+ val replyPreview: String?,
+ val replyAuthor: String?,
+ val isDeleted: Boolean,
+ val originalMessage: DecodedMessageV2?,
+)
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+private fun ConversationDetailScreen(
+ viewModel: ConversationDetailViewModel,
+ peerAddress: String,
+ conversationTopic: String,
+ avatarColors: List,
+ onBackClick: () -> Unit,
+ onNavigateToGroupManagement: (String) -> Unit,
+ onNavigateToUserProfile: (String, String?) -> Unit,
+ onNavigateToAttachmentPreview: (List) -> Unit,
+) {
+ val context = LocalContext.current
+ val scope = rememberCoroutineScope()
+ val uiState by viewModel.uiState.collectAsState()
+ val replyToMessage by viewModel.replyToMessage.collectAsState()
+
+ // Message composer state
+ var messageText by remember { mutableStateOf("") }
+ var isSending by remember { mutableStateOf(false) }
+
+ // Conversation info state
+ var conversationType by remember { mutableStateOf(null) }
+ var headerTitle by remember { mutableStateOf(peerAddress.truncatedAddress()) }
+ var headerSubtitle by remember { mutableStateOf("Loading...") }
+ var peerWalletAddress by remember { mutableStateOf(null) }
+ var peerInboxId by remember { mutableStateOf(null) }
+ var isSuperAdmin by remember { mutableStateOf(false) }
+
+ // Sheet states
+ var showAttachmentPicker by remember { mutableStateOf(false) }
+ var showMessageOptions by remember { mutableStateOf(false) }
+ var showEmojiPicker by remember { mutableStateOf(false) }
+ var selectedMessage by remember { mutableStateOf(null) }
+ var showDeleteConfirmation by remember { mutableStateOf(false) }
+
+ // Camera image URI state
+ var cameraImageUri by remember { mutableStateOf(null) }
+
+ // Pending attachments to send after preview
+ var pendingAttachmentUris by remember { mutableStateOf>(emptyList()) }
+ var pendingCaptions by remember { mutableStateOf>(emptyList()) }
+
+ // Attachment preview launcher - handles result when user confirms sending
+ val attachmentPreviewLauncher = rememberLauncherForActivityResult(
+ ActivityResultContracts.StartActivityForResult()
+ ) { result ->
+ timber.log.Timber.d("AttachmentPreviewLauncher: resultCode=${result.resultCode}, data=${result.data}")
+ if (result.resultCode == android.app.Activity.RESULT_OK) {
+ val data = result.data
+ val uris = if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.TIRAMISU) {
+ data?.getParcelableArrayListExtra(AttachmentPreviewActivity.RESULT_ATTACHMENTS, Uri::class.java)
+ } else {
+ @Suppress("DEPRECATION")
+ data?.getParcelableArrayListExtra(AttachmentPreviewActivity.RESULT_ATTACHMENTS)
+ }
+ val captions = data?.getStringArrayListExtra(AttachmentPreviewActivity.RESULT_CAPTIONS)
+ timber.log.Timber.d("AttachmentPreviewLauncher: uris=$uris, captions=$captions")
+
+ if (!uris.isNullOrEmpty()) {
+ // Send each attachment
+ isSending = true
+ scope.launch {
+ try {
+ uris.forEachIndexed { index, uri ->
+ val caption = captions?.getOrNull(index) ?: ""
+
+ // Read file info from URI
+ val mimeType = context.contentResolver.getType(uri) ?: "application/octet-stream"
+ var filename = "attachment"
+ context.contentResolver.query(uri, null, null, null, null)?.use { cursor ->
+ if (cursor.moveToFirst()) {
+ val nameIndex = cursor.getColumnIndex(android.provider.OpenableColumns.DISPLAY_NAME)
+ if (nameIndex >= 0) {
+ filename = cursor.getString(nameIndex) ?: "attachment"
+ }
+ }
+ }
+
+ // Read file data
+ val fileData = withContext(Dispatchers.IO) {
+ try {
+ context.contentResolver.openInputStream(uri)?.use { it.readBytes() }
+ } catch (e: Exception) {
+ timber.log.Timber.e(e, "Failed to read file: $uri")
+ null
+ }
+ }
+
+ if (fileData != null) {
+ timber.log.Timber.d("Sending attachment: $filename, type: $mimeType, size: ${fileData.size}")
- binding.sendButton.setOnClickListener {
- val flow = viewModel.sendMessage(binding.messageEditText.text.toString())
- lifecycleScope.launch {
- repeatOnLifecycle(Lifecycle.State.STARTED) {
- flow.collect(::ensureSendState)
+ // Send caption first if present
+ if (caption.isNotBlank()) {
+ viewModel.sendMessage(caption).collect { /* ignore */ }
+ }
+
+ // Send attachment
+ val sendResult = viewModel.sendAttachment(filename, mimeType, fileData)
+ when (sendResult) {
+ is ConversationDetailViewModel.SendAttachmentState.Success -> {
+ timber.log.Timber.d("Attachment sent successfully: $filename")
+ }
+ is ConversationDetailViewModel.SendAttachmentState.Error -> {
+ timber.log.Timber.e("Failed to send attachment: ${sendResult.message}")
+ withContext(Dispatchers.Main) {
+ Toast.makeText(context, "Failed: ${sendResult.message}", Toast.LENGTH_LONG).show()
+ }
+ }
+ else -> {}
+ }
+ } else {
+ timber.log.Timber.e("Failed to read file data for: $uri")
+ withContext(Dispatchers.Main) {
+ Toast.makeText(context, "Failed to read file", Toast.LENGTH_SHORT).show()
+ }
+ }
+ }
+ viewModel.fetchMessages()
+ } finally {
+ isSending = false
+ }
}
}
}
+ }
- lifecycleScope.launch {
- repeatOnLifecycle(Lifecycle.State.STARTED) {
- viewModel.streamMessages.collect(::addStreamedItem)
+ // Activity result launchers
+ val galleryLauncher = rememberLauncherForActivityResult(
+ ActivityResultContracts.GetMultipleContents()
+ ) { uris ->
+ if (uris.isNotEmpty()) {
+ // Take persistable permission so URIs remain readable after preview returns
+ uris.forEach { uri ->
+ try {
+ context.contentResolver.takePersistableUriPermission(
+ uri,
+ Intent.FLAG_GRANT_READ_URI_PERMISSION,
+ )
+ } catch (_: SecurityException) {
+ // Some URIs may not support persistable permissions
+ }
}
+ attachmentPreviewLauncher.launch(AttachmentPreviewActivity.intent(context, uris))
}
+ }
- binding.refresh.setOnRefreshListener {
- viewModel.fetchMessages()
+ val cameraLauncher = rememberLauncherForActivityResult(
+ ActivityResultContracts.TakePicture()
+ ) { success ->
+ if (success && cameraImageUri != null) {
+ attachmentPreviewLauncher.launch(AttachmentPreviewActivity.intent(context, listOf(cameraImageUri!!)))
}
+ }
- viewModel.fetchMessages()
+ val fileLauncher = rememberLauncherForActivityResult(
+ ActivityResultContracts.OpenMultipleDocuments()
+ ) { uris ->
+ if (uris.isNotEmpty()) {
+ // Take persistable permission so URIs remain readable after preview returns
+ uris.forEach { uri ->
+ try {
+ context.contentResolver.takePersistableUriPermission(
+ uri,
+ Intent.FLAG_GRANT_READ_URI_PERMISSION,
+ )
+ } catch (_: SecurityException) {
+ // Some URIs may not support persistable permissions
+ }
+ }
+ attachmentPreviewLauncher.launch(AttachmentPreviewActivity.intent(context, uris))
+ }
}
- override fun onCreateOptionsMenu(menu: Menu): Boolean {
- menuInflater.inflate(R.menu.menu_main, menu)
- return true
+ // Load conversation info
+ LaunchedEffect(conversationTopic) {
+ withContext(Dispatchers.IO) {
+ try {
+ val conversation = ClientManager.client.conversations.findConversationByTopic(conversationTopic)
+ conversation?.let { conv ->
+ conversationType = conv.type
+
+ when (conv) {
+ is Conversation.Group -> {
+ val groupName = conv.group.name()
+ val members = conv.group.members()
+ val memberCount = members.size
+ isSuperAdmin = conv.group.isSuperAdmin(ClientManager.client.inboxId)
+
+ withContext(Dispatchers.Main) {
+ headerTitle = if (groupName.isNullOrBlank()) {
+ conv.id.truncatedAddress()
+ } else {
+ groupName
+ }
+ headerSubtitle = "$memberCount members"
+ }
+ }
+ is Conversation.Dm -> {
+ val members = conv.dm.members()
+ val peerMember = members.find { it.inboxId != ClientManager.client.inboxId }
+ peerMember?.let { member ->
+ peerInboxId = member.inboxId
+ peerWalletAddress = member.identities.firstOrNull()?.identifier
+
+ withContext(Dispatchers.Main) {
+ val displayAddress = peerWalletAddress ?: conv.id
+ headerTitle = displayAddress.truncatedAddress()
+ headerSubtitle = "Direct message"
+ }
+ }
+ }
+ }
+ }
+ } catch (e: Exception) {
+ withContext(Dispatchers.Main) {
+ headerSubtitle = "Direct message"
+ }
+ }
+ }
}
- override fun onOptionsItemSelected(item: MenuItem): Boolean =
- when (item.itemId) {
- home -> {
- finish()
- true
+ // Convert ViewModel state to UI messages
+ val messages = remember(uiState) {
+ val items = when (uiState) {
+ is ConversationDetailViewModel.UiState.Success ->
+ (uiState as ConversationDetailViewModel.UiState.Success).listItems
+ is ConversationDetailViewModel.UiState.Loading ->
+ (uiState as ConversationDetailViewModel.UiState.Loading).listItems ?: emptyList()
+ is ConversationDetailViewModel.UiState.Error -> emptyList()
+ }
+
+ items.map { item ->
+ when (item) {
+ is ConversationDetailViewModel.MessageListItem.SentMessage -> {
+ convertToDisplayItem(item.message, isFromMe = true, avatarColors = avatarColors)
+ }
+ is ConversationDetailViewModel.MessageListItem.ReceivedMessage -> {
+ convertToDisplayItem(item.message, isFromMe = false, avatarColors = avatarColors)
+ }
+ is ConversationDetailViewModel.MessageListItem.SystemMessage -> {
+ MessageDisplayItem(
+ id = item.id,
+ message = item.text,
+ timestamp = formatMessageTime(item.message.sentAtNs / 1_000_000),
+ isMe = false,
+ senderName = null,
+ senderColor = null,
+ isSystemMessage = true,
+ reactions = null,
+ replyPreview = null,
+ replyAuthor = null,
+ isDeleted = false,
+ originalMessage = item.message,
+ )
+ }
}
+ }
+ }
+
+ val isLoading = uiState is ConversationDetailViewModel.UiState.Loading &&
+ (uiState as ConversationDetailViewModel.UiState.Loading).listItems.isNullOrEmpty()
+
+ val listState = rememberLazyListState()
- R.id.copy_address -> {
- copyWalletAddress()
- true
+ // Scroll to bottom when new messages arrive
+ LaunchedEffect(messages.size) {
+ if (messages.isNotEmpty()) {
+ listState.animateScrollToItem(0)
+ }
+ }
+
+ Scaffold(
+ modifier = Modifier
+ .fillMaxSize()
+ .statusBarsPadding()
+ .navigationBarsPadding()
+ .imePadding(),
+ topBar = {
+ ConversationHeader(
+ title = headerTitle,
+ subtitle = headerSubtitle,
+ avatarText = headerTitle.removePrefix("0x").take(2).uppercase(),
+ avatarColor = avatarColors[abs(headerTitle.hashCode()) % avatarColors.size],
+ onBackClick = onBackClick,
+ onHeaderClick = {
+ when (conversationType) {
+ Conversation.Type.GROUP -> onNavigateToGroupManagement(conversationTopic)
+ Conversation.Type.DM -> peerWalletAddress?.let { address ->
+ onNavigateToUserProfile(address, peerInboxId)
+ }
+ null -> {}
+ }
+ },
+ )
+ },
+ bottomBar = {
+ Column {
+ // Reply preview
+ replyToMessage?.let { message ->
+ val content = message.content()
+ val replyText = when (content) {
+ is String -> content
+ is Reply -> (content.content as? String) ?: "Message"
+ is Attachment -> if (content.mimeType.startsWith("image/")) "Photo" else "Attachment"
+ else -> "Message"
+ }
+ ReplyPreviewBar(
+ replyData = ReplyPreviewData(
+ authorName = message.senderInboxId.take(8) + "...",
+ messageText = replyText,
+ ),
+ onClose = { viewModel.clearReply() },
+ )
+ }
+
+ MessageComposer(
+ value = messageText,
+ onValueChange = { messageText = it },
+ onSendClick = {
+ if (messageText.isNotBlank()) {
+ isSending = true
+ val textToSend = messageText
+ messageText = ""
+ scope.launch {
+ viewModel.sendMessage(textToSend).collect { state ->
+ when (state) {
+ is ConversationDetailViewModel.SendMessageState.Success -> {
+ isSending = false
+ viewModel.fetchMessages()
+ }
+ is ConversationDetailViewModel.SendMessageState.Error -> {
+ isSending = false
+ Toast.makeText(context, state.message, Toast.LENGTH_SHORT).show()
+ }
+ else -> {}
+ }
+ }
+ }
+ }
+ },
+ isSending = isSending,
+ onAttachmentClick = { showAttachmentPicker = true },
+ onEmojiClick = { showEmojiPicker = true },
+ modifier = Modifier.fillMaxWidth(),
+ )
+ }
+ },
+ ) { paddingValues ->
+ Box(
+ modifier = Modifier
+ .fillMaxSize()
+ .padding(paddingValues),
+ ) {
+ if (isLoading) {
+ CircularProgressIndicator(
+ modifier = Modifier.align(Alignment.Center),
+ )
+ } else {
+ LazyColumn(
+ state = listState,
+ modifier = Modifier.fillMaxSize(),
+ reverseLayout = true,
+ contentPadding = PaddingValues(horizontal = 8.dp, vertical = 8.dp),
+ verticalArrangement = Arrangement.spacedBy(4.dp),
+ ) {
+ items(messages, key = { it.id }) { displayItem ->
+ if (displayItem.isSystemMessage) {
+ SystemMessage(text = displayItem.message)
+ } else {
+ MessageBubble(
+ message = displayItem.message,
+ timestamp = displayItem.timestamp,
+ isMe = displayItem.isMe,
+ senderName = displayItem.senderName,
+ senderColor = displayItem.senderColor,
+ reactions = displayItem.reactions,
+ replyPreview = displayItem.replyPreview,
+ replyAuthor = displayItem.replyAuthor,
+ isDeleted = displayItem.isDeleted,
+ onLongClick = {
+ displayItem.originalMessage?.let {
+ selectedMessage = it
+ showMessageOptions = true
+ }
+ },
+ onReplyClick = displayItem.replyPreview?.let {
+ { /* Could scroll to original message */ }
+ },
+ )
+ }
+ }
+ }
}
+ }
+ }
- else -> super.onOptionsItemSelected(item)
+ // Attachment Picker Sheet
+ if (showAttachmentPicker) {
+ ModalBottomSheet(
+ onDismissRequest = { showAttachmentPicker = false },
+ sheetState = rememberModalBottomSheetState(),
+ ) {
+ AttachmentPickerContent(
+ onCameraClick = {
+ showAttachmentPicker = false
+ val photoFile = File(context.cacheDir, "camera_${System.currentTimeMillis()}.jpg")
+ cameraImageUri = FileProvider.getUriForFile(
+ context,
+ "${context.packageName}.fileprovider",
+ photoFile,
+ )
+ cameraLauncher.launch(cameraImageUri)
+ },
+ onGalleryClick = {
+ showAttachmentPicker = false
+ galleryLauncher.launch("image/*")
+ },
+ onFileClick = {
+ showAttachmentPicker = false
+ fileLauncher.launch(arrayOf("*/*"))
+ },
+ onGifClick = {
+ showAttachmentPicker = false
+ galleryLauncher.launch("image/gif")
+ },
+ )
}
+ }
- private fun copyWalletAddress() {
- val clipboard = getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
- val clip = ClipData.newPlainText("peer_address", peerAddress)
- clipboard.setPrimaryClip(clip)
+ // Emoji Picker Sheet
+ if (showEmojiPicker) {
+ ModalBottomSheet(
+ onDismissRequest = { showEmojiPicker = false },
+ sheetState = rememberModalBottomSheetState(),
+ ) {
+ Column(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(16.dp),
+ ) {
+ Text(
+ text = "Choose emoji",
+ style = MaterialTheme.typography.titleLarge,
+ fontWeight = FontWeight.SemiBold,
+ modifier = Modifier.padding(bottom = 16.dp),
+ )
+ EmojiPicker(
+ onEmojiSelected = { emoji ->
+ messageText += emoji
+ showEmojiPicker = false
+ },
+ )
+ Spacer(modifier = Modifier.height(16.dp))
+ }
+ }
}
- 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)
+ // Message Options Sheet
+ if (showMessageOptions && selectedMessage != null) {
+ val message = selectedMessage!!
+ val isFromMe = ClientManager.client.inboxId == message.senderInboxId
+ val content = message.content()
+ val canEdit = isFromMe && (content is String || content is Reply)
+ val canDelete = isFromMe || (conversationType == Conversation.Type.GROUP && isSuperAdmin)
+
+ // Find user's existing reaction
+ var myExistingReaction: String? = null
+ if (message.hasReactions) {
+ val sortedReactions = message.reactions.sortedBy { it.sentAtNs }
+ val myReactions = mutableMapOf()
+ for (reactionMsg in sortedReactions) {
+ val reaction = reactionMsg.content() ?: continue
+ if (reactionMsg.senderInboxId == ClientManager.client.inboxId) {
+ when (reaction.action) {
+ ReactionAction.Added -> myReactions[reaction.content] = true
+ ReactionAction.Removed -> myReactions[reaction.content] = false
+ else -> {}
+ }
}
}
+ myExistingReaction = myReactions.entries.firstOrNull { it.value }?.key
+ }
+
+ MessageOptionsSheet(
+ state = MessageOptionsState(
+ messageId = message.id,
+ canEdit = canEdit,
+ canDelete = canDelete,
+ currentReaction = myExistingReaction,
+ ),
+ onDismiss = {
+ showMessageOptions = false
+ selectedMessage = null
+ },
+ onReply = {
+ viewModel.setReplyToMessage(message)
+ showMessageOptions = false
+ selectedMessage = null
+ },
+ onEdit = {
+ Toast.makeText(context, "Edit feature coming soon", Toast.LENGTH_SHORT).show()
+ showMessageOptions = false
+ selectedMessage = null
+ },
+ onDelete = {
+ showMessageOptions = false
+ showDeleteConfirmation = true
+ },
+ onReaction = { emoji ->
+ scope.launch {
+ val result = when {
+ myExistingReaction == emoji -> {
+ viewModel.sendReaction(message.id, emoji, isRemoving = true)
+ }
+ myExistingReaction != null -> {
+ viewModel.sendReaction(message.id, myExistingReaction!!, isRemoving = true)
+ viewModel.sendReaction(message.id, emoji, isRemoving = false)
+ }
+ else -> {
+ viewModel.sendReaction(message.id, emoji, isRemoving = false)
+ }
+ }
+ when (result) {
+ is ConversationDetailViewModel.ReactionState.Success -> {
+ viewModel.fetchMessages()
+ }
+ is ConversationDetailViewModel.ReactionState.Error -> {
+ Toast.makeText(context, result.message, Toast.LENGTH_SHORT).show()
+ }
+ else -> {}
+ }
+ }
+ showMessageOptions = false
+ selectedMessage = null
+ },
+ )
+ }
- is ConversationDetailViewModel.UiState.Success -> {
- binding.refresh.isRefreshing = false
- adapter.setData(uiState.listItems)
+ // Delete Confirmation Dialog
+ if (showDeleteConfirmation && selectedMessage != null) {
+ DeleteConfirmationDialog(
+ onConfirm = {
+ scope.launch {
+ viewModel.deleteMessage(selectedMessage!!.id).collect { state ->
+ when (state) {
+ is ConversationDetailViewModel.DeleteMessageState.Success -> {
+ Toast.makeText(context, "Message deleted", Toast.LENGTH_SHORT).show()
+ viewModel.fetchMessages()
+ }
+ is ConversationDetailViewModel.DeleteMessageState.Error -> {
+ Toast.makeText(context, state.message, Toast.LENGTH_SHORT).show()
+ }
+ else -> {}
+ }
+ }
+ }
+ showDeleteConfirmation = false
+ selectedMessage = null
+ },
+ onDismiss = {
+ showDeleteConfirmation = false
+ selectedMessage = null
+ },
+ )
+ }
+}
+
+@Composable
+private fun ConversationHeader(
+ title: String,
+ subtitle: String,
+ avatarText: String,
+ avatarColor: Color,
+ onBackClick: () -> Unit,
+ onHeaderClick: () -> Unit,
+ modifier: Modifier = Modifier,
+) {
+ Surface(
+ modifier = modifier.fillMaxWidth(),
+ color = MaterialTheme.colorScheme.surface,
+ tonalElevation = 2.dp,
+ ) {
+ Row(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(8.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ IconButton(onClick = onBackClick) {
+ Icon(
+ imageVector = Icons.AutoMirrored.Filled.ArrowBack,
+ contentDescription = "Back",
+ )
}
- is ConversationDetailViewModel.UiState.Error -> {
- binding.refresh.isRefreshing = false
- showError(uiState.message)
+ Row(
+ modifier = Modifier
+ .weight(1f)
+ .clickable(onClick = onHeaderClick)
+ .padding(horizontal = 8.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ Box(
+ modifier = Modifier
+ .size(40.dp)
+ .clip(CircleShape)
+ .background(avatarColor),
+ contentAlignment = Alignment.Center,
+ ) {
+ Text(
+ text = avatarText,
+ style = MaterialTheme.typography.titleSmall,
+ color = Color.White,
+ fontWeight = FontWeight.Bold,
+ )
+ }
+
+ Spacer(modifier = Modifier.width(12.dp))
+
+ Column {
+ Text(
+ text = title,
+ style = MaterialTheme.typography.titleMedium,
+ fontWeight = FontWeight.SemiBold,
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis,
+ )
+ Text(
+ text = subtitle,
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis,
+ )
+ }
}
}
}
+}
+
+@Composable
+private fun AttachmentPickerContent(
+ onCameraClick: () -> Unit,
+ onGalleryClick: () -> Unit,
+ onFileClick: () -> Unit,
+ onGifClick: () -> Unit,
+ modifier: Modifier = Modifier,
+) {
+ Column(
+ modifier = modifier
+ .fillMaxWidth()
+ .padding(24.dp),
+ ) {
+ Text(
+ text = "Send attachment",
+ style = MaterialTheme.typography.titleLarge,
+ fontWeight = FontWeight.SemiBold,
+ )
+
+ Spacer(modifier = Modifier.height(24.dp))
+
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.SpaceEvenly,
+ ) {
+ AttachmentOption(
+ icon = Icons.Default.CameraAlt,
+ label = "Camera",
+ onClick = onCameraClick,
+ )
+ AttachmentOption(
+ icon = Icons.Default.Photo,
+ label = "Gallery",
+ onClick = onGalleryClick,
+ )
+ AttachmentOption(
+ icon = Icons.Default.Description,
+ label = "File",
+ onClick = onFileClick,
+ )
+ AttachmentOption(
+ icon = Icons.Default.Gif,
+ label = "GIF",
+ onClick = onGifClick,
+ )
+ }
+
+ Spacer(modifier = Modifier.height(24.dp))
+ }
+}
- private fun ensureSendState(sendState: ConversationDetailViewModel.SendMessageState) {
- when (sendState) {
- is ConversationDetailViewModel.SendMessageState.Error -> {
- showError(sendState.message)
+@Composable
+private fun AttachmentOption(
+ icon: ImageVector,
+ label: String,
+ onClick: () -> Unit,
+ modifier: Modifier = Modifier,
+) {
+ Column(
+ modifier = modifier
+ .clickable(onClick = onClick)
+ .padding(8.dp),
+ horizontalAlignment = Alignment.CenterHorizontally,
+ ) {
+ Surface(
+ modifier = Modifier.size(56.dp),
+ shape = CircleShape,
+ color = MaterialTheme.colorScheme.primaryContainer,
+ ) {
+ Box(
+ contentAlignment = Alignment.Center,
+ ) {
+ Icon(
+ imageVector = icon,
+ contentDescription = label,
+ tint = MaterialTheme.colorScheme.onPrimaryContainer,
+ modifier = Modifier.size(24.dp),
+ )
}
+ }
+ Spacer(modifier = Modifier.height(8.dp))
+ Text(
+ text = label,
+ style = MaterialTheme.typography.bodySmall,
+ )
+ }
+}
- ConversationDetailViewModel.SendMessageState.Loading -> {
- binding.sendButton.isEnabled = false
- binding.messageEditText.isEnabled = false
- }
+private fun convertToDisplayItem(
+ message: DecodedMessageV2,
+ isFromMe: Boolean,
+ avatarColors: List,
+): MessageDisplayItem {
+ val content = message.content()
+
+ val messageContent = when (content) {
+ is String -> content
+ is Reply -> (content.content as? String) ?: "Message"
+ is Attachment -> if (content.mimeType.startsWith("image/")) {
+ if (content.mimeType == "image/gif") "GIF" else "Photo"
+ } else "Attachment: ${content.filename}"
+ is DeletedMessage -> ""
+ else -> message.fallbackText ?: "Message"
+ }
+
+ val isDeleted = content is DeletedMessage
- ConversationDetailViewModel.SendMessageState.Success -> {
- binding.messageEditText.text.clear()
- binding.messageEditText.isEnabled = true
- binding.sendButton.isEnabled = true
- viewModel.fetchMessages()
+ // Get reactions
+ val reactions = if (message.hasReactions) {
+ val sortedReactions = message.reactions.sortedBy { it.sentAtNs }
+ val activeReactions = mutableMapOf>()
+
+ for (reactionMsg in sortedReactions) {
+ val reaction = reactionMsg.content() ?: continue
+ when (reaction.action) {
+ ReactionAction.Added -> {
+ activeReactions.getOrPut(reaction.content) { mutableSetOf() }.add(reactionMsg.senderInboxId)
+ }
+ ReactionAction.Removed -> {
+ activeReactions[reaction.content]?.remove(reactionMsg.senderInboxId)
+ }
+ else -> {}
}
}
+
+ activeReactions.filter { it.value.isNotEmpty() }
+ .map { (emoji, senders) -> emoji to senders.size }
+ } else {
+ null
}
- private fun addStreamedItem(item: ConversationDetailViewModel.MessageListItem?) {
- item?.let {
- adapter.addItem(it)
+ // Get reply info from content if it's a Reply type
+ val (replyPreview, replyAuthor) = when (content) {
+ is Reply -> {
+ // Get the reply content text
+ val replyText = when (val replyContent = content.content) {
+ is String -> replyContent
+ else -> "Message"
+ }
+ // Try to get author from inReplyTo if available
+ val author = content.inReplyTo?.senderInboxId?.let { it.take(8) + "..." }
+ replyText to author
}
+ else -> null to null
}
- private fun showError(message: String) {
- val error = message.ifBlank { resources.getString(R.string.error) }
- Toast.makeText(this, error, Toast.LENGTH_SHORT).show()
+ val senderName = if (!isFromMe) message.senderInboxId.take(8) + "..." else null
+ val senderColor = if (!isFromMe) {
+ avatarColors[abs(message.senderInboxId.hashCode()) % avatarColors.size]
+ } else null
+
+ return MessageDisplayItem(
+ id = message.id,
+ message = messageContent,
+ timestamp = formatMessageTime(message.sentAtNs / 1_000_000),
+ isMe = isFromMe,
+ senderName = senderName,
+ senderColor = senderColor,
+ isSystemMessage = false,
+ reactions = reactions,
+ replyPreview = replyPreview,
+ replyAuthor = replyAuthor,
+ isDeleted = isDeleted,
+ originalMessage = message,
+ )
+}
+
+private fun formatMessageTime(timestampMs: Long): String {
+ val messageDate = Date(timestampMs)
+ val now = Calendar.getInstance()
+ val messageCalendar = Calendar.getInstance().apply { time = messageDate }
+
+ return when {
+ now.get(Calendar.YEAR) == messageCalendar.get(Calendar.YEAR) &&
+ now.get(Calendar.DAY_OF_YEAR) == messageCalendar.get(Calendar.DAY_OF_YEAR) -> {
+ SimpleDateFormat("HH:mm", Locale.getDefault()).format(messageDate)
+ }
+ now.get(Calendar.YEAR) == messageCalendar.get(Calendar.YEAR) &&
+ now.get(Calendar.DAY_OF_YEAR) - messageCalendar.get(Calendar.DAY_OF_YEAR) == 1 -> {
+ "Yesterday"
+ }
+ now.get(Calendar.YEAR) == messageCalendar.get(Calendar.YEAR) &&
+ now.get(Calendar.DAY_OF_YEAR) - messageCalendar.get(Calendar.DAY_OF_YEAR) < 7 -> {
+ SimpleDateFormat("EEE", Locale.getDefault()).format(messageDate)
+ }
+ else -> {
+ SimpleDateFormat("MMM d", Locale.getDefault()).format(messageDate)
+ }
}
}
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 9c8c24d09..c927f7aba 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
@@ -4,6 +4,7 @@ import androidx.annotation.UiThread
import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
+import com.google.protobuf.kotlin.toByteString
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.MutableStateFlow
@@ -15,11 +16,29 @@ import kotlinx.coroutines.flow.emptyFlow
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.mapLatest
import kotlinx.coroutines.launch
-import kotlinx.coroutines.runBlocking
+import kotlinx.coroutines.withContext
import org.xmtp.android.example.ClientManager
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.SendOptions
+import org.xmtp.android.library.codecs.Attachment
+import org.xmtp.android.library.codecs.ContentTypeAttachment
+import org.xmtp.android.library.codecs.ContentTypeReaction
+import org.xmtp.android.library.codecs.ContentTypeReply
+import org.xmtp.android.library.codecs.ContentTypeText
+import org.xmtp.android.library.codecs.DeletedBy
+import org.xmtp.android.library.codecs.DeletedMessage
+import org.xmtp.android.library.codecs.Reaction
+import org.xmtp.android.library.codecs.ReactionAction
+import org.xmtp.android.library.codecs.ReactionSchema
+import org.xmtp.android.library.codecs.Reply
+import org.xmtp.android.library.codecs.ReplyCodec
+import org.xmtp.android.library.codecs.TextCodec
+import org.xmtp.android.library.libxmtp.DecodedMessageV2
+import org.xmtp.proto.mls.message.contents.TranscriptMessages.GroupUpdated
+import timber.log.Timber
+import org.xmtp.android.library.libxmtp.Reply as EnrichedReply
class ConversationDetailViewModel(
private val savedStateHandle: SavedStateHandle,
@@ -30,7 +49,9 @@ class ConversationDetailViewModel(
null,
)
- private val conversationTopic = conversationTopicFlow.value
+ // Read from flow to get the current value (supports updates via setConversationTopic)
+ private val conversationTopic: String?
+ get() = conversationTopicFlow.value
fun setConversationTopic(conversationTopic: String?) {
savedStateHandle[ConversationDetailActivity.EXTRA_CONVERSATION_TOPIC] = conversationTopic
@@ -39,10 +60,44 @@ class ConversationDetailViewModel(
private val _uiState = MutableStateFlow(UiState.Loading(null))
val uiState: StateFlow = _uiState
+ private val _replyToMessage = MutableStateFlow(null)
+ val replyToMessage: StateFlow = _replyToMessage
+
+ private val _editingMessage = MutableStateFlow(null)
+ val editingMessage: StateFlow = _editingMessage
+
+ // Instance-level setting - set from Activity based on user preferences
+ private val _hideDeletedMessages = MutableStateFlow(false)
+
+ fun setHideDeletedMessages(hide: Boolean) {
+ _hideDeletedMessages.value = hide
+ }
+
private var conversation: Conversation? = null
+ fun setReplyToMessage(message: DecodedMessageV2?) {
+ _replyToMessage.value = message
+ }
+
+ fun clearReply() {
+ _replyToMessage.value = null
+ }
+
+ fun setEditingMessage(message: DecodedMessageV2?) {
+ _editingMessage.value = message
+ }
+
+ fun clearEdit() {
+ _editingMessage.value = null
+ }
+
@UiThread
fun fetchMessages() {
+ val topic = conversationTopic
+ if (topic == null) {
+ _uiState.value = UiState.Error("Conversation topic not set")
+ return
+ }
when (val uiState = uiState.value) {
is UiState.Success -> _uiState.value = UiState.Loading(uiState.listItems)
else -> _uiState.value = UiState.Loading(null)
@@ -51,15 +106,36 @@ class ConversationDetailViewModel(
val listItems = mutableListOf()
try {
if (conversation == null) {
- conversation = ClientManager.client.conversations.findConversationByTopic(conversationTopic!!)
+ conversation = ClientManager.client.conversations.findConversationByTopic(topic)
}
- conversation?.let {
- if (conversation is Conversation.Group) {
- (conversation as Conversation.Group).group.sync()
- }
+ conversation?.let { conv ->
+ // Sync conversation to get latest messages (including deletions)
+ val isDm =
+ when (conv) {
+ is Conversation.Group -> {
+ conv.group.sync()
+ false
+ }
+
+ is Conversation.Dm -> {
+ conv.dm.sync()
+ true
+ }
+ }
+ val shouldHideDeleted = _hideDeletedMessages.value
listItems.addAll(
- it.messages().map { message ->
- MessageListItem.Message(message.id, message)
+ conv.enrichedMessages().mapNotNull { message ->
+ message?.let { msg ->
+ val item = classifyMessage(msg, isDm) ?: return@mapNotNull null
+ // Filter out deleted messages if hideDeletedMessages is enabled
+ if (shouldHideDeleted && item is MessageListItem.SystemMessage) {
+ val content = msg.content()
+ if (content is DeletedMessage) {
+ return@mapNotNull null
+ }
+ }
+ item
+ }
},
)
}
@@ -71,36 +147,93 @@ class ConversationDetailViewModel(
}
@OptIn(ExperimentalCoroutinesApi::class)
- val streamMessages: StateFlow =
+ val streamMessages: StateFlow =
stateFlow(viewModelScope, null) { subscriptionCount ->
- if (conversation == null) {
- conversation =
- runBlocking {
- ClientManager.client.conversations.findConversationByTopic(conversationTopic!!)
- }
- }
- if (conversation != null) {
- conversation!!
- .streamMessages()
- .flowWhileShared(
- subscriptionCount,
- SharingStarted.WhileSubscribed(1000L),
- ).flowOn(Dispatchers.IO)
- .distinctUntilChanged()
- .mapLatest { message ->
- MessageListItem.Message(message.id, message)
- }.catch { emptyFlow() }
- } else {
+ val topic = conversationTopic
+ if (topic == null) {
emptyFlow()
+ } else {
+ // Ensure conversation is initialized
+ if (conversation == null) {
+ conversation = ClientManager.client.conversations.findConversationByTopic(topic)
+ }
+ val conv = conversation
+ if (conv != null) {
+ val isDm = conv is Conversation.Dm
+ conv.streamMessages()
+ .flowWhileShared(
+ subscriptionCount,
+ SharingStarted.WhileSubscribed(1000L),
+ ).flowOn(Dispatchers.IO)
+ .distinctUntilChanged()
+ .mapLatest { message ->
+ // Check if this is a delete, reaction, or edit message - if so, signal a refresh is needed
+ val contentTypeId = message.encodedContent.type
+ val isDeleteMessage = contentTypeId?.typeId == "deleteMessage"
+ val isReactionMessage = contentTypeId?.typeId == "reaction"
+ val isEditMessage = contentTypeId?.typeId == "editMessage"
+
+ if (isDeleteMessage || isReactionMessage || isEditMessage) {
+ // Return a signal to refresh the message list
+ // Reactions, deletes, and edits modify existing messages, so we need a full refresh
+ StreamedMessageResult.RefreshNeeded
+ } else {
+ // Convert streamed DecodedMessage to DecodedMessageV2 using findEnrichedMessage
+ val enrichedMessage =
+ ClientManager.client.conversations.findEnrichedMessage(message.id)
+ enrichedMessage?.let {
+ classifyMessage(it, isDm)?.let { item ->
+ StreamedMessageResult.NewMessage(item)
+ }
+ }
+ }
+ }.catch { e ->
+ Timber.e(e, "Error in message stream")
+ emit(null)
+ }
+ } else {
+ emptyFlow()
+ }
}
}
+ sealed class StreamedMessageResult {
+ data class NewMessage(
+ val item: MessageListItem,
+ ) : StreamedMessageResult()
+
+ data object RefreshNeeded : StreamedMessageResult()
+ }
+
@UiThread
fun sendMessage(body: String): StateFlow {
val flow = MutableStateFlow(SendMessageState.Loading)
+ val replyTo = _replyToMessage.value
+ val editMessage = _editingMessage.value
viewModelScope.launch(Dispatchers.IO) {
try {
- conversation?.send(body)
+ if (editMessage != null) {
+ // Edit message feature is not yet released in the library
+ // Clear editing state and show error
+ _editingMessage.value = null
+ throw UnsupportedOperationException("Edit message feature is not yet available")
+ } else if (replyTo != null) {
+ // Send as reply using Reply codec
+ val replyContent =
+ Reply(
+ reference = replyTo.id,
+ content = body,
+ contentType = ContentTypeText,
+ )
+ conversation?.send(
+ content = replyContent,
+ options = SendOptions(contentType = ContentTypeReply),
+ )
+ _replyToMessage.value = null
+ } else {
+ // Send as regular message
+ conversation?.send(body)
+ }
flow.value = SendMessageState.Success
} catch (e: Exception) {
flow.value = SendMessageState.Error(e.localizedMessage.orEmpty())
@@ -109,6 +242,82 @@ class ConversationDetailViewModel(
return flow
}
+ @UiThread
+ fun deleteMessage(messageId: String): StateFlow {
+ val flow = MutableStateFlow(DeleteMessageState.Loading)
+ viewModelScope.launch(Dispatchers.IO) {
+ try {
+ conversation?.deleteMessage(messageId)
+ flow.value = DeleteMessageState.Success
+ } catch (e: Exception) {
+ flow.value = DeleteMessageState.Error(e.localizedMessage.orEmpty())
+ }
+ }
+ return flow
+ }
+
+ suspend fun sendReaction(
+ messageId: String,
+ emoji: String,
+ isRemoving: Boolean = false,
+ ): ReactionState =
+ withContext(Dispatchers.IO) {
+ try {
+ val reaction =
+ Reaction(
+ reference = messageId,
+ action = if (isRemoving) ReactionAction.Removed else ReactionAction.Added,
+ content = emoji,
+ schema = ReactionSchema.Unicode,
+ )
+ conversation?.send(
+ content = reaction,
+ options = SendOptions(contentType = ContentTypeReaction),
+ )
+ // Sync to ensure the reaction is persisted and available for enrichedMessages
+ conversation?.let {
+ when (it) {
+ is Conversation.Group -> it.group.sync()
+ is Conversation.Dm -> it.dm.sync()
+ }
+ }
+ ReactionState.Success
+ } catch (e: Exception) {
+ ReactionState.Error(e.localizedMessage.orEmpty())
+ }
+ }
+
+ suspend fun sendAttachment(
+ filename: String,
+ mimeType: String,
+ data: ByteArray,
+ ): SendAttachmentState =
+ withContext(Dispatchers.IO) {
+ try {
+ Timber.d("sendAttachment: filename=$filename, mimeType=$mimeType, dataSize=${data.size}")
+ val attachment =
+ Attachment(
+ filename = filename,
+ mimeType = mimeType,
+ data = data.toByteString(),
+ )
+ Timber.d("sendAttachment: created Attachment object, conversation=$conversation")
+ if (conversation == null) {
+ Timber.e("sendAttachment: conversation is null!")
+ return@withContext SendAttachmentState.Error("Conversation not found")
+ }
+ conversation?.send(
+ content = attachment,
+ options = SendOptions(contentType = ContentTypeAttachment),
+ )
+ Timber.d("sendAttachment: sent successfully")
+ SendAttachmentState.Success
+ } catch (e: Exception) {
+ Timber.e(e, "sendAttachment: failed")
+ SendAttachmentState.Error(e.localizedMessage.orEmpty())
+ }
+ }
+
sealed class UiState {
data class Loading(
val listItems: List?,
@@ -124,26 +333,139 @@ class ConversationDetailViewModel(
}
sealed class SendMessageState {
- object Loading : SendMessageState()
+ data object Loading : SendMessageState()
- object Success : SendMessageState()
+ data object Success : SendMessageState()
data class Error(
val message: String,
) : SendMessageState()
}
+ sealed class DeleteMessageState {
+ data object Loading : DeleteMessageState()
+
+ data object Success : DeleteMessageState()
+
+ data class Error(
+ val message: String,
+ ) : DeleteMessageState()
+ }
+
+ sealed class ReactionState {
+ data object Loading : ReactionState()
+
+ data object Success : ReactionState()
+
+ data class Error(
+ val message: String,
+ ) : ReactionState()
+ }
+
+ sealed class SendAttachmentState {
+ data object Loading : SendAttachmentState()
+
+ data object Success : SendAttachmentState()
+
+ data class Error(
+ val message: String,
+ ) : SendAttachmentState()
+ }
+
sealed class MessageListItem(
open val id: String,
val itemType: Int,
) {
companion object {
- const val ITEM_TYPE_MESSAGE = 1
+ const val ITEM_TYPE_SENT = 1
+ const val ITEM_TYPE_RECEIVED = 2
+ const val ITEM_TYPE_SYSTEM = 3
}
- data class Message(
+ data class SentMessage(
+ override val id: String,
+ val message: DecodedMessageV2,
+ ) : MessageListItem(id, ITEM_TYPE_SENT)
+
+ data class ReceivedMessage(
override val id: String,
- val message: org.xmtp.android.library.libxmtp.DecodedMessage,
- ) : MessageListItem(id, ITEM_TYPE_MESSAGE)
+ val message: DecodedMessageV2,
+ ) : MessageListItem(id, ITEM_TYPE_RECEIVED)
+
+ data class SystemMessage(
+ override val id: String,
+ val message: DecodedMessageV2,
+ val text: String,
+ ) : MessageListItem(id, ITEM_TYPE_SYSTEM)
+ }
+
+ companion object {
+ // Protocol message types that should not be displayed in the UI
+ private val HIDDEN_CONTENT_TYPES = setOf("editMessage", "deleteMessage", "reaction")
+
+ fun classifyMessage(
+ message: DecodedMessageV2,
+ isDm: Boolean = false,
+ ): MessageListItem? {
+ // Filter out protocol messages that modify other messages
+ val contentTypeId = message.contentTypeId.typeId
+ if (contentTypeId in HIDDEN_CONTENT_TYPES) {
+ return null
+ }
+
+ val content = message.content()
+ val isFromMe = ClientManager.client.inboxId == message.senderInboxId
+
+ // Check for system/protocol messages
+ return when (content) {
+ // Placeholder for deleted message content (shown in UI)
+ is DeletedMessage -> {
+ val deletedByText =
+ when (content.deletedBy) {
+ is DeletedBy.Sender -> "sender"
+ is DeletedBy.Admin -> "admin"
+ }
+ MessageListItem.SystemMessage(
+ message.id,
+ message,
+ "This message was deleted by $deletedByText",
+ )
+ }
+
+ is GroupUpdated -> {
+ // For DMs, show "Conversation started by [initiator]" instead of member changes
+ val text =
+ if (isDm) {
+ val initiatorId = content.initiatedByInboxId
+ if (initiatorId.isNotEmpty()) {
+ "Conversation started by ${initiatorId.take(8)}..."
+ } else {
+ "Conversation started"
+ }
+ } else {
+ val addedText =
+ content.addedInboxesList
+ ?.mapNotNull { it.inboxId }
+ ?.takeIf { it.isNotEmpty() }
+ ?.let { "Added: ${it.joinToString(", ") { id -> id.take(8) + "..." }}" }
+ val removedText =
+ content.removedInboxesList
+ ?.mapNotNull { it.inboxId }
+ ?.takeIf { it.isNotEmpty() }
+ ?.let { "Removed: ${it.joinToString(", ") { id -> id.take(8) + "..." }}" }
+ listOfNotNull(addedText, removedText).joinToString("\n").ifEmpty { "Group updated" }
+ }
+ MessageListItem.SystemMessage(message.id, message, text)
+ }
+
+ else -> {
+ if (isFromMe) {
+ MessageListItem.SentMessage(message.id, message)
+ } else {
+ MessageListItem.ReceivedMessage(message.id, message)
+ }
+ }
+ }
+ }
}
}
diff --git a/example/src/main/java/org/xmtp/android/example/conversation/ConversationFooterViewHolder.kt b/example/src/main/java/org/xmtp/android/example/conversation/ConversationFooterViewHolder.kt
deleted file mode 100644
index 0142c7dad..000000000
--- a/example/src/main/java/org/xmtp/android/example/conversation/ConversationFooterViewHolder.kt
+++ /dev/null
@@ -1,66 +0,0 @@
-package org.xmtp.android.example.conversation
-
-import android.graphics.Color
-import android.graphics.Typeface
-import android.text.Spannable
-import android.text.SpannableString
-import android.text.style.ForegroundColorSpan
-import android.text.style.StyleSpan
-import androidx.recyclerview.widget.RecyclerView
-import org.xmtp.android.example.MainViewModel
-import org.xmtp.android.example.R
-import org.xmtp.android.example.databinding.ListItemConversationFooterBinding
-
-class ConversationFooterViewHolder(
- private val binding: ListItemConversationFooterBinding,
- onFooterClickListener: ConversationsClickListener,
-) : RecyclerView.ViewHolder(binding.root) {
- private var address: String? = null
-
- init {
- binding.root.setOnClickListener {
- address?.let {
- onFooterClickListener.onFooterClick(it)
- }
- }
- }
-
- fun bind(item: MainViewModel.MainListItem.Footer) {
- address = item.address
- val spannable =
- SpannableString(
- binding.root.resources.getString(
- R.string.conversation_footer,
- item.address,
- item.environment,
- ),
- )
- val addressStart = spannable.indexOf(item.address)
- val envStart = spannable.indexOf(item.environment)
- spannable.setSpan(
- StyleSpan(Typeface.BOLD),
- addressStart,
- addressStart + item.address.length,
- Spannable.SPAN_INCLUSIVE_EXCLUSIVE,
- )
- spannable.setSpan(
- ForegroundColorSpan(Color.BLACK),
- addressStart,
- addressStart + item.address.length,
- Spannable.SPAN_INCLUSIVE_EXCLUSIVE,
- )
- spannable.setSpan(
- StyleSpan(Typeface.BOLD),
- envStart,
- envStart + item.environment.length,
- Spannable.SPAN_INCLUSIVE_EXCLUSIVE,
- )
- spannable.setSpan(
- ForegroundColorSpan(Color.BLACK),
- envStart,
- envStart + item.environment.length,
- Spannable.SPAN_INCLUSIVE_EXCLUSIVE,
- )
- binding.footer.text = spannable
- }
-}
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
deleted file mode 100644
index 681debbc2..000000000
--- a/example/src/main/java/org/xmtp/android/example/conversation/ConversationViewHolder.kt
+++ /dev/null
@@ -1,56 +0,0 @@
-package org.xmtp.android.example.conversation
-
-import androidx.recyclerview.widget.RecyclerView
-import org.xmtp.android.example.ClientManager
-import org.xmtp.android.example.MainViewModel
-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 org.xmtp.proto.mls.message.contents.TranscriptMessages.GroupUpdated
-
-class ConversationViewHolder(
- private val binding: ListItemConversationBinding,
- clickListener: ConversationsClickListener,
-) : RecyclerView.ViewHolder(binding.root) {
- private var conversation: Conversation? = null
-
- init {
- binding.root.setOnClickListener {
- conversation?.let {
- clickListener.onConversationClick(it)
- }
- }
- }
-
- fun bind(item: MainViewModel.MainListItem.ConversationItem) {
- conversation = item.conversation
- binding.peerAddress.text = item.conversation.id.truncatedAddress()
-
- val messageBody: String =
- if (item.mostRecentMessage?.content() is String) {
- item.mostRecentMessage.body.orEmpty()
- } else if (item.mostRecentMessage?.content() is GroupUpdated) {
- val changes = item.mostRecentMessage.content() as? GroupUpdated
- "Membership Changed ${
- changes?.addedInboxesList?.mapNotNull { it.inboxId }
- }"
- } else {
- ""
- }
- val isMe = item.mostRecentMessage?.senderInboxId == ClientManager.client.inboxId
- if (messageBody.isNotBlank()) {
- binding.messageBody.text =
- if (isMe) {
- binding.root.resources.getString(
- R.string.your_message_body,
- messageBody,
- )
- } else {
- messageBody
- }
- } else {
- binding.messageBody.text = binding.root.resources.getString(R.string.empty_message)
- }
- }
-}
diff --git a/example/src/main/java/org/xmtp/android/example/conversation/ConversationsAdapter.kt b/example/src/main/java/org/xmtp/android/example/conversation/ConversationsAdapter.kt
deleted file mode 100644
index c9843bf31..000000000
--- a/example/src/main/java/org/xmtp/android/example/conversation/ConversationsAdapter.kt
+++ /dev/null
@@ -1,69 +0,0 @@
-package org.xmtp.android.example.conversation
-
-import android.view.LayoutInflater
-import android.view.ViewGroup
-import androidx.recyclerview.widget.RecyclerView
-import org.xmtp.android.example.MainViewModel
-import org.xmtp.android.example.databinding.ListItemConversationBinding
-import org.xmtp.android.example.databinding.ListItemConversationFooterBinding
-
-class ConversationsAdapter(
- private val clickListener: ConversationsClickListener,
-) : RecyclerView.Adapter() {
- init {
- setHasStableIds(true)
- }
-
- private val listItems = mutableListOf()
-
- fun setData(newItems: List) {
- listItems.clear()
- listItems.addAll(newItems)
- notifyDataSetChanged()
- }
-
- fun addItem(item: MainViewModel.MainListItem) {
- listItems.add(0, item)
- notifyDataSetChanged()
- }
-
- override fun onCreateViewHolder(
- parent: ViewGroup,
- viewType: Int,
- ): RecyclerView.ViewHolder {
- val inflater = LayoutInflater.from(parent.context)
- return when (viewType) {
- MainViewModel.MainListItem.ITEM_TYPE_CONVERSATION -> {
- val binding = ListItemConversationBinding.inflate(inflater, parent, false)
- ConversationViewHolder(binding, clickListener)
- }
- MainViewModel.MainListItem.ITEM_TYPE_FOOTER -> {
- val binding = ListItemConversationFooterBinding.inflate(inflater, parent, false)
- ConversationFooterViewHolder(binding, clickListener)
- }
- else -> throw IllegalArgumentException("Unsupported view type $viewType")
- }
- }
-
- override fun onBindViewHolder(
- holder: RecyclerView.ViewHolder,
- position: Int,
- ) {
- val item = listItems[position]
- when (holder) {
- is ConversationViewHolder -> {
- holder.bind(item as MainViewModel.MainListItem.ConversationItem)
- }
- is ConversationFooterViewHolder -> {
- holder.bind(item as MainViewModel.MainListItem.Footer)
- }
- 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/conversation/ConversationsClickListener.kt b/example/src/main/java/org/xmtp/android/example/conversation/ConversationsClickListener.kt
deleted file mode 100644
index 0e2d9db86..000000000
--- a/example/src/main/java/org/xmtp/android/example/conversation/ConversationsClickListener.kt
+++ /dev/null
@@ -1,9 +0,0 @@
-package org.xmtp.android.example.conversation
-
-import org.xmtp.android.library.Conversation
-
-interface ConversationsClickListener {
- fun onConversationClick(conversation: Conversation)
-
- fun onFooterClick(address: String)
-}
diff --git a/example/src/main/java/org/xmtp/android/example/conversation/GroupManagementActivity.kt b/example/src/main/java/org/xmtp/android/example/conversation/GroupManagementActivity.kt
new file mode 100644
index 000000000..a8f8bd4e5
--- /dev/null
+++ b/example/src/main/java/org/xmtp/android/example/conversation/GroupManagementActivity.kt
@@ -0,0 +1,345 @@
+package org.xmtp.android.example.conversation
+
+import android.content.Context
+import android.content.Intent
+import android.os.Bundle
+import android.widget.Toast
+import androidx.activity.ComponentActivity
+import androidx.activity.compose.setContent
+import androidx.activity.enableEdgeToEdge
+import androidx.activity.viewModels
+import androidx.compose.material3.AlertDialog
+import androidx.compose.material3.OutlinedTextField
+import androidx.compose.material3.Text
+import androidx.compose.material3.TextButton
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.collectAsState
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.rememberCoroutineScope
+import androidx.compose.runtime.setValue
+import kotlinx.coroutines.launch
+import org.xmtp.android.example.ui.components.MemberPermissionLevel
+import org.xmtp.android.example.ui.components.MemberRowData
+import org.xmtp.android.example.ui.screens.GroupDetails
+import org.xmtp.android.example.ui.screens.GroupManagementScreen
+import org.xmtp.android.example.ui.theme.XMTPTheme
+import org.xmtp.android.library.libxmtp.PermissionLevel
+import java.text.SimpleDateFormat
+import java.util.Locale
+import java.util.regex.Pattern
+
+class GroupManagementActivity : ComponentActivity() {
+ private val viewModel: GroupManagementViewModel by viewModels()
+
+ companion object {
+ const val EXTRA_CONVERSATION_TOPIC = "EXTRA_CONVERSATION_TOPIC"
+ private val ADDRESS_PATTERN = Pattern.compile("^0x[a-fA-F0-9]{40}$")
+
+ fun intent(
+ context: Context,
+ topic: String,
+ ): Intent =
+ Intent(context, GroupManagementActivity::class.java).apply {
+ putExtra(EXTRA_CONVERSATION_TOPIC, topic)
+ }
+ }
+
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+ enableEdgeToEdge()
+
+ viewModel.setConversationTopic(intent.extras?.getString(EXTRA_CONVERSATION_TOPIC))
+
+ setContent {
+ XMTPTheme {
+ val uiState by viewModel.uiState.collectAsState()
+ val scope = rememberCoroutineScope()
+
+ var showAddMemberDialog by remember { mutableStateOf(false) }
+ var addMemberAddress by remember { mutableStateOf("") }
+ var showLeaveGroupDialog by remember { mutableStateOf(false) }
+ var showConfirmationDialog by remember { mutableStateOf(null) }
+
+ // Load group info on first composition
+ LaunchedEffect(Unit) {
+ viewModel.loadGroupInfo()
+ }
+
+ // Handle action state results
+ fun handleActionState(state: GroupManagementViewModel.ActionState) {
+ when (state) {
+ is GroupManagementViewModel.ActionState.Success -> {
+ Toast.makeText(
+ this@GroupManagementActivity,
+ state.message,
+ Toast.LENGTH_SHORT,
+ ).show()
+ }
+ is GroupManagementViewModel.ActionState.Error -> {
+ Toast.makeText(
+ this@GroupManagementActivity,
+ state.message.ifBlank { "Error" },
+ Toast.LENGTH_SHORT,
+ ).show()
+ }
+ is GroupManagementViewModel.ActionState.Loading -> Unit
+ }
+ }
+
+ val isLoading = uiState is GroupManagementViewModel.UiState.Loading
+
+ val groupDetails = when (val state = uiState) {
+ is GroupManagementViewModel.UiState.Success -> {
+ val dateFormat = SimpleDateFormat("MMM d, yyyy", Locale.getDefault())
+ val createdAtString = state.createdAt?.let { dateFormat.format(it) } ?: ""
+ GroupDetails(
+ groupId = state.groupId.truncatedAddress(),
+ groupName = state.groupId,
+ createdAt = createdAtString,
+ memberCount = state.members.size,
+ currentUserRole = state.currentUserRole.toMemberPermissionLevel(),
+ )
+ }
+ else -> GroupDetails(
+ groupId = "",
+ groupName = "",
+ createdAt = "",
+ memberCount = 0,
+ currentUserRole = MemberPermissionLevel.MEMBER,
+ )
+ }
+
+ val members = when (val state = uiState) {
+ is GroupManagementViewModel.UiState.Success -> {
+ state.members.map { member ->
+ MemberRowData(
+ inboxId = member.member.inboxId,
+ displayAddress = member.displayAddress,
+ permissionLevel = member.member.permissionLevel.toMemberPermissionLevel(),
+ isCurrentUser = member.isCurrentUser,
+ )
+ }
+ }
+ else -> emptyList()
+ }
+
+ // Handle error state
+ LaunchedEffect(uiState) {
+ if (uiState is GroupManagementViewModel.UiState.Error) {
+ Toast.makeText(
+ this@GroupManagementActivity,
+ (uiState as GroupManagementViewModel.UiState.Error).message,
+ Toast.LENGTH_SHORT,
+ ).show()
+ }
+ }
+
+ GroupManagementScreen(
+ groupDetails = groupDetails,
+ members = members,
+ isLoading = isLoading,
+ onBackClick = { finish() },
+ onAddMemberClick = { showAddMemberDialog = true },
+ onMemberClick = { inboxId ->
+ // Find member's display address
+ val member = members.find { it.inboxId == inboxId }
+ member?.displayAddress?.let { address ->
+ startActivity(
+ UserProfileActivity.intent(
+ this@GroupManagementActivity,
+ address,
+ inboxId,
+ ),
+ )
+ }
+ },
+ onPromoteToAdmin = { inboxId ->
+ val member = members.find { m -> m.inboxId == inboxId }
+ val displayName = member?.displayAddress?.truncatedAddress()
+ ?: inboxId.truncatedAddress()
+ showConfirmationDialog = ConfirmationDialogData(
+ title = "Promote to Admin",
+ message = "Are you sure you want to promote $displayName to admin?",
+ confirmText = "Promote",
+ onConfirm = {
+ scope.launch {
+ viewModel.promoteToAdmin(inboxId).collect { state ->
+ handleActionState(state)
+ }
+ }
+ },
+ )
+ },
+ onDemoteFromAdmin = { inboxId ->
+ val member = members.find { m -> m.inboxId == inboxId }
+ val displayName = member?.displayAddress?.truncatedAddress()
+ ?: inboxId.truncatedAddress()
+ showConfirmationDialog = ConfirmationDialogData(
+ title = "Remove Admin",
+ message = "Are you sure you want to remove admin privileges from $displayName?",
+ confirmText = "Remove",
+ onConfirm = {
+ scope.launch {
+ viewModel.demoteFromAdmin(inboxId).collect { state ->
+ handleActionState(state)
+ }
+ }
+ },
+ )
+ },
+ onRemoveMember = { inboxId ->
+ val member = members.find { m -> m.inboxId == inboxId }
+ val displayName = member?.displayAddress?.truncatedAddress()
+ ?: inboxId.truncatedAddress()
+ showConfirmationDialog = ConfirmationDialogData(
+ title = "Remove Member",
+ message = "Are you sure you want to remove $displayName from this group?",
+ confirmText = "Remove",
+ onConfirm = {
+ scope.launch {
+ viewModel.removeMember(inboxId).collect { state ->
+ handleActionState(state)
+ }
+ }
+ },
+ )
+ },
+ onLeaveGroup = { showLeaveGroupDialog = true },
+ )
+
+ // Add member dialog
+ if (showAddMemberDialog) {
+ AlertDialog(
+ onDismissRequest = {
+ showAddMemberDialog = false
+ addMemberAddress = ""
+ },
+ title = { Text("Add Member") },
+ text = {
+ OutlinedTextField(
+ value = addMemberAddress,
+ onValueChange = { addMemberAddress = it },
+ label = { Text("Wallet Address") },
+ placeholder = { Text("0x...") },
+ singleLine = true,
+ )
+ },
+ confirmButton = {
+ TextButton(
+ onClick = {
+ val address = addMemberAddress.trim()
+ if (ADDRESS_PATTERN.matcher(address).matches()) {
+ scope.launch {
+ viewModel.addMember(address).collect { state ->
+ handleActionState(state)
+ }
+ }
+ showAddMemberDialog = false
+ addMemberAddress = ""
+ } else {
+ Toast.makeText(
+ this@GroupManagementActivity,
+ "Invalid address",
+ Toast.LENGTH_SHORT,
+ ).show()
+ }
+ },
+ ) {
+ Text("Add")
+ }
+ },
+ dismissButton = {
+ TextButton(
+ onClick = {
+ showAddMemberDialog = false
+ addMemberAddress = ""
+ },
+ ) {
+ Text("Cancel")
+ }
+ },
+ )
+ }
+
+ // Leave group confirmation dialog
+ if (showLeaveGroupDialog) {
+ AlertDialog(
+ onDismissRequest = { showLeaveGroupDialog = false },
+ title = { Text("Leave Group") },
+ text = { Text("Are you sure you want to leave this group?") },
+ confirmButton = {
+ TextButton(
+ onClick = {
+ showLeaveGroupDialog = false
+ // TODO: Implement leave group when API supports it
+ Toast.makeText(
+ this@GroupManagementActivity,
+ "Leave group not yet implemented",
+ Toast.LENGTH_SHORT,
+ ).show()
+ },
+ ) {
+ Text("Leave")
+ }
+ },
+ dismissButton = {
+ TextButton(onClick = { showLeaveGroupDialog = false }) {
+ Text("Cancel")
+ }
+ },
+ )
+ }
+
+ // Generic confirmation dialog
+ showConfirmationDialog?.let { dialogData ->
+ AlertDialog(
+ onDismissRequest = { showConfirmationDialog = null },
+ title = { Text(dialogData.title) },
+ text = { Text(dialogData.message) },
+ confirmButton = {
+ TextButton(
+ onClick = {
+ dialogData.onConfirm()
+ showConfirmationDialog = null
+ },
+ ) {
+ Text(dialogData.confirmText)
+ }
+ },
+ dismissButton = {
+ TextButton(onClick = { showConfirmationDialog = null }) {
+ Text("Cancel")
+ }
+ },
+ )
+ }
+ }
+ }
+ }
+}
+
+private data class ConfirmationDialogData(
+ val title: String,
+ val message: String,
+ val confirmText: String,
+ val onConfirm: () -> Unit,
+)
+
+private fun PermissionLevel.toMemberPermissionLevel(): MemberPermissionLevel {
+ return when (this) {
+ PermissionLevel.SUPER_ADMIN -> MemberPermissionLevel.SUPER_ADMIN
+ PermissionLevel.ADMIN -> MemberPermissionLevel.ADMIN
+ PermissionLevel.MEMBER -> MemberPermissionLevel.MEMBER
+ }
+}
+
+private fun String.truncatedAddress(): String {
+ if (length >= 10) {
+ val start = 6
+ val end = length - 4
+ return replaceRange(start, end, "...")
+ }
+ return this
+}
diff --git a/example/src/main/java/org/xmtp/android/example/conversation/GroupManagementViewModel.kt b/example/src/main/java/org/xmtp/android/example/conversation/GroupManagementViewModel.kt
new file mode 100644
index 000000000..818cb9c94
--- /dev/null
+++ b/example/src/main/java/org/xmtp/android/example/conversation/GroupManagementViewModel.kt
@@ -0,0 +1,184 @@
+package org.xmtp.android.example.conversation
+
+import androidx.lifecycle.SavedStateHandle
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.viewModelScope
+import kotlinx.coroutines.Dispatchers
+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.Group
+import org.xmtp.android.library.libxmtp.IdentityKind
+import org.xmtp.android.library.libxmtp.PermissionLevel
+import org.xmtp.android.library.libxmtp.PublicIdentity
+
+class GroupManagementViewModel(
+ private val savedStateHandle: SavedStateHandle,
+) : ViewModel() {
+ private val conversationTopicFlow =
+ savedStateHandle.getStateFlow(
+ GroupManagementActivity.EXTRA_CONVERSATION_TOPIC,
+ null,
+ )
+
+ fun setConversationTopic(topic: String?) {
+ savedStateHandle[GroupManagementActivity.EXTRA_CONVERSATION_TOPIC] = topic
+ }
+
+ private val _uiState = MutableStateFlow(UiState.Loading)
+ val uiState: StateFlow = _uiState
+
+ private var group: Group? = null
+
+ fun loadGroupInfo() {
+ viewModelScope.launch(Dispatchers.IO) {
+ try {
+ val topic = conversationTopicFlow.value ?: throw Exception("No topic provided")
+ val conversation =
+ ClientManager.client.conversations.findConversationByTopic(topic)
+ ?: throw Exception("Conversation not found")
+
+ if (conversation !is Conversation.Group) {
+ throw Exception("Not a group conversation")
+ }
+
+ group = conversation.group
+ group?.sync()
+
+ val members = group?.members() ?: emptyList()
+ val currentUserInboxId = ClientManager.client.inboxId
+
+ // Get addresses for members
+ val memberItems =
+ members
+ .map { member ->
+ val addresses = member.identities.mapNotNull { it.identifier }
+ MemberItem(
+ member = member,
+ isCurrentUser = member.inboxId == currentUserInboxId,
+ displayAddress = addresses.firstOrNull(),
+ )
+ }.sortedWith(
+ compareBy { !it.isCurrentUser }
+ .thenBy {
+ when (it.member.permissionLevel) {
+ PermissionLevel.SUPER_ADMIN -> 0
+ PermissionLevel.ADMIN -> 1
+ PermissionLevel.MEMBER -> 2
+ }
+ },
+ )
+
+ val currentUserMember = members.find { it.inboxId == currentUserInboxId }
+ val currentUserRole = currentUserMember?.permissionLevel ?: PermissionLevel.MEMBER
+ val canManageMembers =
+ currentUserRole == PermissionLevel.SUPER_ADMIN ||
+ currentUserRole == PermissionLevel.ADMIN
+
+ _uiState.value =
+ UiState.Success(
+ groupId = group?.id ?: "",
+ createdAt = group?.createdAt,
+ members = memberItems,
+ currentUserRole = currentUserRole,
+ canManageMembers = canManageMembers,
+ )
+ } catch (e: Exception) {
+ _uiState.value = UiState.Error(e.localizedMessage ?: "Unknown error")
+ }
+ }
+ }
+
+ fun addMember(address: String): StateFlow {
+ val flow = MutableStateFlow(ActionState.Loading)
+ viewModelScope.launch(Dispatchers.IO) {
+ try {
+ val publicIdentity = PublicIdentity(IdentityKind.ETHEREUM, address)
+ group?.addMembersByIdentity(listOf(publicIdentity))
+ flow.value = ActionState.Success("Member added successfully")
+ loadGroupInfo() // Refresh
+ } catch (e: Exception) {
+ flow.value = ActionState.Error(e.localizedMessage ?: "Failed to add member")
+ }
+ }
+ return flow
+ }
+
+ fun removeMember(inboxId: String): StateFlow {
+ val flow = MutableStateFlow(ActionState.Loading)
+ viewModelScope.launch(Dispatchers.IO) {
+ try {
+ group?.removeMembers(listOf(inboxId))
+ flow.value = ActionState.Success("Member removed successfully")
+ loadGroupInfo() // Refresh
+ } catch (e: Exception) {
+ flow.value = ActionState.Error(e.localizedMessage ?: "Failed to remove member")
+ }
+ }
+ return flow
+ }
+
+ fun promoteToAdmin(inboxId: String): StateFlow {
+ val flow = MutableStateFlow(ActionState.Loading)
+ viewModelScope.launch(Dispatchers.IO) {
+ try {
+ group?.addAdmin(inboxId)
+ flow.value = ActionState.Success("Member promoted to admin")
+ loadGroupInfo() // Refresh
+ } catch (e: Exception) {
+ flow.value = ActionState.Error(e.localizedMessage ?: "Failed to promote member")
+ }
+ }
+ return flow
+ }
+
+ fun demoteFromAdmin(inboxId: String): StateFlow {
+ val flow = MutableStateFlow(ActionState.Loading)
+ viewModelScope.launch(Dispatchers.IO) {
+ try {
+ group?.removeAdmin(inboxId)
+ flow.value = ActionState.Success("Admin demoted to member")
+ loadGroupInfo() // Refresh
+ } catch (e: Exception) {
+ flow.value = ActionState.Error(e.localizedMessage ?: "Failed to demote admin")
+ }
+ }
+ return flow
+ }
+
+ sealed class UiState {
+ object Loading : UiState()
+
+ data class Success(
+ val groupId: String,
+ val createdAt: java.util.Date?,
+ val members: List,
+ val currentUserRole: PermissionLevel,
+ val canManageMembers: Boolean,
+ ) : UiState()
+
+ data class Error(
+ val message: String,
+ ) : UiState()
+ }
+
+ sealed class ActionState {
+ object Loading : ActionState()
+
+ data class Success(
+ val message: String,
+ ) : ActionState()
+
+ data class Error(
+ val message: String,
+ ) : ActionState()
+ }
+}
+
+data class MemberItem(
+ val member: org.xmtp.android.library.libxmtp.Member,
+ val isCurrentUser: Boolean,
+ val displayAddress: String?,
+)
diff --git a/example/src/main/java/org/xmtp/android/example/conversation/NewConversationActivity.kt b/example/src/main/java/org/xmtp/android/example/conversation/NewConversationActivity.kt
new file mode 100644
index 000000000..a21b46f30
--- /dev/null
+++ b/example/src/main/java/org/xmtp/android/example/conversation/NewConversationActivity.kt
@@ -0,0 +1,155 @@
+package org.xmtp.android.example.conversation
+
+import android.content.Context
+import android.content.Intent
+import android.os.Bundle
+import android.widget.Toast
+import androidx.activity.ComponentActivity
+import androidx.activity.compose.setContent
+import androidx.activity.enableEdgeToEdge
+import androidx.activity.viewModels
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.collectAsState
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateListOf
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import org.xmtp.android.example.ui.components.RecentContactData
+import org.xmtp.android.example.ui.screens.ConversationType
+import org.xmtp.android.example.ui.screens.NewConversationScreen
+import org.xmtp.android.example.ui.theme.XMTPTheme
+import java.util.regex.Pattern
+
+class NewConversationActivity : ComponentActivity() {
+ private val viewModel: NewConversationViewModel by viewModels()
+
+ companion object {
+ private val ADDRESS_PATTERN = Pattern.compile("^0x[a-fA-F0-9]{40}$")
+
+ fun intent(context: Context): Intent = Intent(context, NewConversationActivity::class.java)
+ }
+
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+ enableEdgeToEdge()
+
+ viewModel.loadRecentContacts()
+
+ setContent {
+ XMTPTheme {
+ var addressInput by remember { mutableStateOf("") }
+ var groupName by remember { mutableStateOf("") }
+ var conversationType by remember { mutableStateOf(ConversationType.DM) }
+ val selectedMembers = remember { mutableStateListOf() }
+
+ val uiState by viewModel.uiState.collectAsState()
+ val recentContacts by viewModel.recentContacts.collectAsState()
+
+ val isLoading = uiState is NewConversationViewModel.UiState.Loading
+
+ val isValidAddress = ADDRESS_PATTERN.matcher(addressInput.trim()).matches()
+ val canCreate = when (conversationType) {
+ ConversationType.DM -> isValidAddress
+ ConversationType.GROUP -> selectedMembers.isNotEmpty()
+ }
+
+ // Convert RecentContact to RecentContactData
+ val recentContactsData = recentContacts.map { contact ->
+ RecentContactData(
+ address = contact.address,
+ lastActivityTimeNs = contact.lastActivityTime,
+ )
+ }
+
+ // Handle UI state changes
+ LaunchedEffect(uiState) {
+ when (uiState) {
+ is NewConversationViewModel.UiState.Success -> {
+ val conversation = (uiState as NewConversationViewModel.UiState.Success).conversation
+ startActivity(
+ ConversationDetailActivity.intent(
+ this@NewConversationActivity,
+ topic = conversation.topic,
+ peerAddress = conversation.id,
+ ),
+ )
+ finish()
+ }
+ is NewConversationViewModel.UiState.Error -> {
+ val error = (uiState as NewConversationViewModel.UiState.Error).message
+ Toast.makeText(
+ this@NewConversationActivity,
+ error.ifBlank { "Error" },
+ Toast.LENGTH_SHORT,
+ ).show()
+ }
+ else -> Unit
+ }
+ }
+
+ NewConversationScreen(
+ addressInput = addressInput,
+ groupName = groupName,
+ selectedMembers = selectedMembers.toList(),
+ recentContacts = recentContactsData,
+ conversationType = conversationType,
+ isLoading = isLoading,
+ canCreate = canCreate,
+ onAddressChange = { addressInput = it },
+ onGroupNameChange = { groupName = it },
+ onConversationTypeChange = { newType ->
+ conversationType = newType
+ // Clear input and members when switching modes
+ addressInput = ""
+ selectedMembers.clear()
+ },
+ onAddMember = {
+ val address = addressInput.trim()
+ if (ADDRESS_PATTERN.matcher(address).matches() &&
+ !selectedMembers.contains(address.lowercase())
+ ) {
+ selectedMembers.add(address.lowercase())
+ addressInput = ""
+ }
+ },
+ onRemoveMember = { address ->
+ selectedMembers.remove(address)
+ },
+ onRecentContactClick = { address ->
+ if (conversationType == ConversationType.GROUP) {
+ val normalizedAddress = address.lowercase()
+ if (selectedMembers.contains(normalizedAddress)) {
+ selectedMembers.remove(normalizedAddress)
+ } else {
+ selectedMembers.add(normalizedAddress)
+ }
+ } else {
+ // In DM mode, start conversation directly
+ viewModel.createConversation(address)
+ }
+ },
+ onClose = { finish() },
+ onCreate = {
+ if (conversationType == ConversationType.GROUP) {
+ if (selectedMembers.isNotEmpty()) {
+ viewModel.createGroup(selectedMembers.toList(), groupName)
+ }
+ } else {
+ val address = addressInput.trim()
+ if (ADDRESS_PATTERN.matcher(address).matches()) {
+ viewModel.createConversation(address)
+ }
+ }
+ },
+ )
+ }
+ }
+ }
+}
+
+data class RecentContact(
+ val address: String,
+ val inboxId: String?,
+ val lastActivityTime: Long,
+)
diff --git a/example/src/main/java/org/xmtp/android/example/conversation/NewConversationBottomSheet.kt b/example/src/main/java/org/xmtp/android/example/conversation/NewConversationBottomSheet.kt
deleted file mode 100644
index f88926a11..000000000
--- a/example/src/main/java/org/xmtp/android/example/conversation/NewConversationBottomSheet.kt
+++ /dev/null
@@ -1,97 +0,0 @@
-package org.xmtp.android.example.conversation
-
-import android.os.Bundle
-import android.view.LayoutInflater
-import android.view.View
-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 java.util.regex.Pattern
-
-class NewConversationBottomSheet : BottomSheetDialogFragment() {
- private val viewModel: NewConversationViewModel by viewModels()
- private var _binding: BottomSheetNewConversationBinding? = null
- private val binding get() = _binding!!
-
- companion object {
- const val TAG = "NewConversationBottomSheet"
-
- private val ADDRESS_PATTERN = Pattern.compile("^0x[a-fA-F0-9]{40}\$")
-
- fun newInstance(): NewConversationBottomSheet = NewConversationBottomSheet()
- }
-
- override fun onCreateView(
- inflater: LayoutInflater,
- container: ViewGroup?,
- savedInstanceState: Bundle?,
- ): View {
- _binding = BottomSheetNewConversationBinding.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.addressInput.addTextChangedListener {
- if (viewModel.uiState.value is NewConversationViewModel.UiState.Loading) return@addTextChangedListener
- val input = binding.addressInput.text.trim()
- val matcher = ADDRESS_PATTERN.matcher(input)
- if (matcher.matches()) {
- viewModel.createConversation(input.toString())
- }
- }
- }
-
- override fun onDestroyView() {
- super.onDestroyView()
- _binding = null
- }
-
- private fun ensureUiState(uiState: NewConversationViewModel.UiState) {
- when (uiState) {
- is NewConversationViewModel.UiState.Error -> {
- binding.addressInput.isEnabled = true
- binding.progress.visibility = View.GONE
- showError(uiState.message)
- }
- NewConversationViewModel.UiState.Loading -> {
- binding.addressInput.isEnabled = false
- binding.progress.visibility = View.VISIBLE
- }
- is NewConversationViewModel.UiState.Success -> {
- startActivity(
- ConversationDetailActivity.intent(
- requireContext(),
- topic = uiState.conversation.topic,
- peerAddress = uiState.conversation.id,
- ),
- )
- 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/conversation/NewConversationViewModel.kt b/example/src/main/java/org/xmtp/android/example/conversation/NewConversationViewModel.kt
index 607e4374a..0bef97492 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
@@ -16,6 +16,9 @@ class NewConversationViewModel : ViewModel() {
private val _uiState = MutableStateFlow(UiState.Unknown)
val uiState: StateFlow = _uiState
+ private val _recentContacts = MutableStateFlow>(emptyList())
+ val recentContacts: StateFlow> = _recentContacts
+
@UiThread
fun createConversation(address: String) {
_uiState.value = UiState.Loading
@@ -36,18 +39,23 @@ class NewConversationViewModel : ViewModel() {
}
@UiThread
- fun createGroup(addresses: List) {
+ fun createGroup(
+ addresses: List,
+ groupName: String = "",
+ ) {
_uiState.value = UiState.Loading
viewModelScope.launch(Dispatchers.IO) {
try {
val group =
ClientManager.client.conversations.newGroupWithIdentities(
- addresses.map {
- PublicIdentity(
- IdentityKind.ETHEREUM,
- it,
- )
- },
+ identities =
+ addresses.map {
+ PublicIdentity(
+ IdentityKind.ETHEREUM,
+ it,
+ )
+ },
+ groupName = groupName,
)
_uiState.value = UiState.Success(Conversation.Group(group))
} catch (e: Exception) {
@@ -56,6 +64,72 @@ class NewConversationViewModel : ViewModel() {
}
}
+ @UiThread
+ fun loadRecentContacts() {
+ viewModelScope.launch(Dispatchers.IO) {
+ try {
+ val currentInboxId = ClientManager.client.inboxId
+ val conversations = ClientManager.client.conversations.list()
+
+ // Get unique peer addresses from DM conversations
+ val contactsMap = mutableMapOf()
+
+ for (conversation in conversations) {
+ when (conversation) {
+ is Conversation.Dm -> {
+ val members = conversation.dm.members()
+ val peerMember = members.find { it.inboxId != currentInboxId }
+ peerMember?.let { member ->
+ val address = member.identities.firstOrNull()?.identifier
+ if (address != null && !contactsMap.containsKey(address.lowercase())) {
+ contactsMap[address.lowercase()] =
+ RecentContact(
+ address = address,
+ inboxId = member.inboxId,
+ lastActivityTime = conversation.lastActivityNs,
+ )
+ }
+ }
+ }
+ is Conversation.Group -> {
+ // Get all members from groups except current user
+ val members = conversation.group.members()
+ for (member in members) {
+ if (member.inboxId != currentInboxId) {
+ val address = member.identities.firstOrNull()?.identifier
+ if (address != null) {
+ val existingContact = contactsMap[address.lowercase()]
+ if (existingContact == null ||
+ conversation.lastActivityNs > existingContact.lastActivityTime
+ ) {
+ contactsMap[address.lowercase()] =
+ RecentContact(
+ address = address,
+ inboxId = member.inboxId,
+ lastActivityTime = conversation.lastActivityNs,
+ )
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+
+ // Sort by most recent activity
+ val sortedContacts =
+ contactsMap.values
+ .sortedByDescending { it.lastActivityTime }
+ .take(20) // Limit to 20 recent contacts
+
+ _recentContacts.value = sortedContacts
+ } catch (e: Exception) {
+ // Silently fail - just show empty recent contacts
+ _recentContacts.value = emptyList()
+ }
+ }
+ }
+
sealed class UiState {
object Unknown : 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
deleted file mode 100644
index c892f418f..000000000
--- a/example/src/main/java/org/xmtp/android/example/conversation/NewGroupBottomSheet.kt
+++ /dev/null
@@ -1,115 +0,0 @@
-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.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 = 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 = VISIBLE
- }
-
- is NewConversationViewModel.UiState.Success -> {
- startActivity(
- ConversationDetailActivity.intent(
- requireContext(),
- topic = uiState.conversation.topic,
- peerAddress = uiState.conversation.id,
- ),
- )
- 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/conversation/UserProfileActivity.kt b/example/src/main/java/org/xmtp/android/example/conversation/UserProfileActivity.kt
new file mode 100644
index 000000000..930fc3c37
--- /dev/null
+++ b/example/src/main/java/org/xmtp/android/example/conversation/UserProfileActivity.kt
@@ -0,0 +1,416 @@
+package org.xmtp.android.example.conversation
+
+import android.content.ClipData
+import android.content.ClipboardManager
+import android.content.Context
+import android.content.Intent
+import android.os.Bundle
+import android.widget.Toast
+import androidx.activity.ComponentActivity
+import androidx.activity.compose.setContent
+import androidx.activity.enableEdgeToEdge
+import androidx.compose.foundation.background
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.rememberScrollState
+import androidx.compose.foundation.shape.CircleShape
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.foundation.verticalScroll
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.automirrored.filled.ArrowBack
+import androidx.compose.material.icons.automirrored.filled.Message
+import androidx.compose.material.icons.filled.ContentCopy
+import androidx.compose.material3.Button
+import androidx.compose.material3.Card
+import androidx.compose.material3.CardDefaults
+import androidx.compose.material3.CircularProgressIndicator
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.Icon
+import androidx.compose.material3.IconButton
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Scaffold
+import androidx.compose.material3.Text
+import androidx.compose.material3.TopAppBar
+import androidx.compose.material3.TopAppBarDefaults
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.text.font.FontFamily
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.text.style.TextOverflow
+import androidx.compose.ui.unit.dp
+import androidx.lifecycle.lifecycleScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.withContext
+import org.xmtp.android.example.ClientManager
+import org.xmtp.android.example.ui.theme.XMTPTheme
+import org.xmtp.android.library.libxmtp.IdentityKind
+import org.xmtp.android.library.libxmtp.InboxState
+import org.xmtp.android.library.libxmtp.PublicIdentity
+import kotlin.math.abs
+
+class UserProfileActivity : ComponentActivity() {
+ companion object {
+ private const val EXTRA_WALLET_ADDRESS = "EXTRA_WALLET_ADDRESS"
+ private const val EXTRA_INBOX_ID = "EXTRA_INBOX_ID"
+
+ fun intent(
+ context: Context,
+ walletAddress: String,
+ inboxId: String? = null,
+ ): Intent =
+ Intent(context, UserProfileActivity::class.java).apply {
+ putExtra(EXTRA_WALLET_ADDRESS, walletAddress)
+ putExtra(EXTRA_INBOX_ID, inboxId)
+ }
+ }
+
+ private val avatarColors = listOf(
+ Color(0xFFFC4F37),
+ Color(0xFF5856D6),
+ Color(0xFF34C759),
+ Color(0xFFFF9500),
+ Color(0xFF007AFF),
+ Color(0xFFAF52DE),
+ Color(0xFF00C7BE),
+ Color(0xFFFF2D55),
+ )
+
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+ enableEdgeToEdge()
+
+ val walletAddress = intent.getStringExtra(EXTRA_WALLET_ADDRESS) ?: run {
+ finish()
+ return
+ }
+ val inboxId = intent.getStringExtra(EXTRA_INBOX_ID)
+
+ setContent {
+ XMTPTheme {
+ var isLoading by remember { mutableStateOf(false) }
+ var inboxState by remember { mutableStateOf(null) }
+ var isStartingConversation by remember { mutableStateOf(false) }
+
+ // Load inbox state if we have an inbox ID
+ LaunchedEffect(inboxId) {
+ if (inboxId != null) {
+ isLoading = true
+ try {
+ val inboxStates = withContext(Dispatchers.IO) {
+ ClientManager.client.inboxStatesForInboxIds(
+ refreshFromNetwork = true,
+ inboxIds = listOf(inboxId),
+ )
+ }
+ inboxState = inboxStates.firstOrNull()
+ } catch (e: Exception) {
+ // Silently fail - we still have basic info
+ } finally {
+ isLoading = false
+ }
+ }
+ }
+
+ UserProfileScreen(
+ walletAddress = walletAddress,
+ inboxId = inboxId,
+ inboxState = inboxState,
+ isLoading = isLoading || isStartingConversation,
+ avatarColors = avatarColors,
+ onBackClick = { finish() },
+ onCopyAddress = { copyToClipboard("Wallet Address", walletAddress) },
+ onCopyInboxId = {
+ inboxId?.let { id -> copyToClipboard("Inbox ID", id) }
+ },
+ onSendMessage = {
+ isStartingConversation = true
+ startConversation(walletAddress)
+ },
+ )
+ }
+ }
+ }
+
+ private fun copyToClipboard(label: String, text: String) {
+ val clipboard = getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
+ val clip = ClipData.newPlainText(label, text)
+ clipboard.setPrimaryClip(clip)
+ Toast.makeText(this, "$label copied to clipboard", Toast.LENGTH_SHORT).show()
+ }
+
+ private fun startConversation(walletAddress: String) {
+ lifecycleScope.launch {
+ try {
+ val conversation = withContext(Dispatchers.IO) {
+ val publicIdentity = PublicIdentity(IdentityKind.ETHEREUM, walletAddress)
+ ClientManager.client.conversations.findOrCreateDmWithIdentity(publicIdentity)
+ }
+
+ startActivity(
+ ConversationDetailActivity.intent(
+ this@UserProfileActivity,
+ topic = conversation.topic,
+ peerAddress = conversation.id,
+ ),
+ )
+ finish()
+ } catch (e: Exception) {
+ Toast.makeText(
+ this@UserProfileActivity,
+ e.localizedMessage ?: "Error",
+ Toast.LENGTH_SHORT,
+ ).show()
+ }
+ }
+ }
+}
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+private fun UserProfileScreen(
+ walletAddress: String,
+ inboxId: String?,
+ inboxState: InboxState?,
+ isLoading: Boolean,
+ avatarColors: List,
+ onBackClick: () -> Unit,
+ onCopyAddress: () -> Unit,
+ onCopyInboxId: () -> Unit,
+ onSendMessage: () -> Unit,
+) {
+ val avatarText = walletAddress
+ .removePrefix("0x")
+ .take(2)
+ .uppercase()
+
+ val colorIndex = abs((inboxId ?: walletAddress).hashCode()) % avatarColors.size
+ val avatarColor = avatarColors[colorIndex]
+
+ Scaffold(
+ topBar = {
+ TopAppBar(
+ title = { Text("Profile") },
+ navigationIcon = {
+ IconButton(onClick = onBackClick) {
+ Icon(
+ imageVector = Icons.AutoMirrored.Filled.ArrowBack,
+ contentDescription = "Back",
+ )
+ }
+ },
+ colors = TopAppBarDefaults.topAppBarColors(
+ containerColor = MaterialTheme.colorScheme.primary,
+ titleContentColor = Color.White,
+ navigationIconContentColor = Color.White,
+ ),
+ )
+ },
+ ) { paddingValues ->
+ Box(
+ modifier = Modifier
+ .fillMaxSize()
+ .padding(paddingValues),
+ ) {
+ Column(
+ modifier = Modifier
+ .fillMaxSize()
+ .verticalScroll(rememberScrollState())
+ .padding(16.dp),
+ horizontalAlignment = Alignment.CenterHorizontally,
+ ) {
+ Spacer(modifier = Modifier.height(24.dp))
+
+ // Avatar
+ Box(
+ modifier = Modifier
+ .size(100.dp)
+ .clip(CircleShape)
+ .background(avatarColor),
+ contentAlignment = Alignment.Center,
+ ) {
+ Text(
+ text = avatarText,
+ style = MaterialTheme.typography.headlineLarge,
+ fontWeight = FontWeight.Bold,
+ color = Color.White,
+ )
+ }
+
+ Spacer(modifier = Modifier.height(16.dp))
+
+ // Abbreviated address
+ Text(
+ text = walletAddress.truncatedAddress(),
+ style = MaterialTheme.typography.headlineSmall,
+ color = MaterialTheme.colorScheme.onSurface,
+ )
+
+ Spacer(modifier = Modifier.height(24.dp))
+
+ // Wallet Address Card
+ InfoCard(
+ title = "Wallet Address",
+ value = walletAddress,
+ onCopy = onCopyAddress,
+ )
+
+ Spacer(modifier = Modifier.height(12.dp))
+
+ // Inbox ID Card
+ InfoCard(
+ title = "Inbox ID",
+ value = inboxId ?: "Unknown",
+ onCopy = if (inboxId != null) onCopyInboxId else null,
+ )
+
+ // Linked Identities
+ inboxState?.let { state ->
+ if (state.identities.size > 1) {
+ Spacer(modifier = Modifier.height(24.dp))
+
+ Card(
+ modifier = Modifier.fillMaxWidth(),
+ shape = RoundedCornerShape(12.dp),
+ colors = CardDefaults.cardColors(
+ containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f),
+ ),
+ ) {
+ Column(
+ modifier = Modifier.padding(16.dp),
+ ) {
+ Text(
+ text = "Linked Identities",
+ style = MaterialTheme.typography.titleMedium,
+ fontWeight = FontWeight.Bold,
+ )
+
+ Spacer(modifier = Modifier.height(12.dp))
+
+ state.identities.forEach { identity ->
+ Text(
+ text = identity.identifier,
+ style = MaterialTheme.typography.bodyMedium,
+ fontFamily = FontFamily.Monospace,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis,
+ modifier = Modifier.padding(vertical = 4.dp),
+ )
+ }
+ }
+ }
+ }
+ }
+
+ Spacer(modifier = Modifier.height(32.dp))
+
+ // Send Message Button
+ Button(
+ onClick = onSendMessage,
+ modifier = Modifier
+ .fillMaxWidth()
+ .height(52.dp),
+ enabled = !isLoading,
+ shape = RoundedCornerShape(12.dp),
+ ) {
+ if (isLoading) {
+ CircularProgressIndicator(
+ modifier = Modifier.size(24.dp),
+ color = MaterialTheme.colorScheme.onPrimary,
+ )
+ } else {
+ Icon(
+ imageVector = Icons.AutoMirrored.Filled.Message,
+ contentDescription = null,
+ modifier = Modifier.size(20.dp),
+ )
+ Text(
+ text = "Send Message",
+ modifier = Modifier.padding(start = 8.dp),
+ )
+ }
+ }
+ }
+
+ if (isLoading) {
+ CircularProgressIndicator(
+ modifier = Modifier.align(Alignment.Center),
+ )
+ }
+ }
+ }
+}
+
+@Composable
+private fun InfoCard(
+ title: String,
+ value: String,
+ onCopy: (() -> Unit)?,
+) {
+ Card(
+ modifier = Modifier.fillMaxWidth(),
+ shape = RoundedCornerShape(12.dp),
+ colors = CardDefaults.cardColors(
+ containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f),
+ ),
+ ) {
+ Row(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(16.dp),
+ horizontalArrangement = Arrangement.SpaceBetween,
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ Column(modifier = Modifier.weight(1f)) {
+ Text(
+ text = title,
+ style = MaterialTheme.typography.labelMedium,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+ Text(
+ text = value,
+ style = MaterialTheme.typography.bodyMedium,
+ fontFamily = FontFamily.Monospace,
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis,
+ modifier = Modifier.padding(top = 4.dp),
+ )
+ }
+ if (onCopy != null) {
+ IconButton(onClick = onCopy) {
+ Icon(
+ imageVector = Icons.Default.ContentCopy,
+ contentDescription = "Copy",
+ tint = MaterialTheme.colorScheme.primary,
+ )
+ }
+ }
+ }
+ }
+}
+
+private fun String.truncatedAddress(): String {
+ if (length >= 10) {
+ val start = 6
+ val end = length - 4
+ return replaceRange(start, end, "...")
+ }
+ return this
+}
diff --git a/example/src/main/java/org/xmtp/android/example/extension/FlowExtension.kt b/example/src/main/java/org/xmtp/android/example/extension/FlowExtension.kt
index 921f65f96..6324ad794 100644
--- a/example/src/main/java/org/xmtp/android/example/extension/FlowExtension.kt
+++ b/example/src/main/java/org/xmtp/android/example/extension/FlowExtension.kt
@@ -34,7 +34,7 @@ fun Flow.flowWhileShared(
fun stateFlow(
scope: CoroutineScope,
initialValue: T,
- producer: (subscriptionCount: StateFlow) -> Flow,
+ producer: suspend (subscriptionCount: StateFlow) -> Flow,
): StateFlow {
val state = MutableStateFlow(initialValue)
scope.launch(Dispatchers.IO) {
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
index dceb91444..90d1e22e7 100644
--- a/example/src/main/java/org/xmtp/android/example/extension/StringExtension.kt
+++ b/example/src/main/java/org/xmtp/android/example/extension/StringExtension.kt
@@ -1,9 +1,10 @@
package org.xmtp.android.example.extension
fun String.truncatedAddress(): String {
- if (length > 6) {
+ // Need at least 10 characters to meaningfully truncate (show first 6 + "..." + last 4)
+ if (length >= 10) {
val start = 6
- val end = lastIndex - 3
+ val end = length - 4
return replaceRange(start, end, "...")
}
return this
diff --git a/example/src/main/java/org/xmtp/android/example/logs/LogViewerBottomSheet.kt b/example/src/main/java/org/xmtp/android/example/logs/LogViewerBottomSheet.kt
deleted file mode 100644
index d10ee3c2f..000000000
--- a/example/src/main/java/org/xmtp/android/example/logs/LogViewerBottomSheet.kt
+++ /dev/null
@@ -1,97 +0,0 @@
-package org.xmtp.android.example.logs
-
-import android.content.Intent
-import android.os.Bundle
-import android.view.LayoutInflater
-import android.view.View
-import android.view.ViewGroup
-import android.widget.ArrayAdapter
-import android.widget.ListView
-import android.widget.TextView
-import android.widget.Toast
-import androidx.core.content.FileProvider
-import com.google.android.material.bottomsheet.BottomSheetDialogFragment
-import org.xmtp.android.example.R
-import java.io.File
-
-class LogViewerBottomSheet : BottomSheetDialogFragment() {
- companion object {
- const val TAG = "LogViewerBottomSheet"
-
- fun newInstance(): LogViewerBottomSheet = LogViewerBottomSheet()
- }
-
- override fun onCreateView(
- inflater: LayoutInflater,
- container: ViewGroup?,
- savedInstanceState: Bundle?,
- ): View? = inflater.inflate(R.layout.bottom_sheet_log_viewer, container, false)
-
- override fun onViewCreated(
- view: View,
- savedInstanceState: Bundle?,
- ) {
- super.onViewCreated(view, savedInstanceState)
-
- val logsList = view.findViewById(R.id.logs_list)
- val emptyText = view.findViewById(R.id.empty_text)
-
- // Get log files from the app's files directory
- val logDirectory = File(requireContext().filesDir, "xmtp_logs")
- val logFiles = if (logDirectory.exists()) logDirectory.listFiles() else null
-
- if (logFiles.isNullOrEmpty()) {
- logsList.visibility = View.GONE
- emptyText.visibility = View.VISIBLE
- } else {
- logsList.visibility = View.VISIBLE
- emptyText.visibility = View.GONE
-
- // Sort files by last modified date (newest first)
- val sortedFiles = logFiles.sortedByDescending { it.lastModified() }
-
- // Create adapter with file names and sizes
- val fileItems =
- sortedFiles.map {
- "${it.name} (${formatFileSize(it.length())})"
- }
- val adapter = ArrayAdapter(requireContext(), android.R.layout.simple_list_item_1, fileItems)
- logsList.adapter = adapter
-
- // Handle click on log file
- logsList.setOnItemClickListener { _, _, position, _ ->
- shareLogFile(sortedFiles[position])
- }
- }
- }
-
- private fun formatFileSize(size: Long): String =
- when {
- size < 1024 -> "$size B"
- size < 1024 * 1024 -> "${size / 1024} KB"
- else -> "${size / (1024 * 1024)} MB"
- }
-
- private fun shareLogFile(file: File) {
- try {
- val uri =
- FileProvider.getUriForFile(
- requireContext(),
- "${requireContext().packageName}.fileprovider",
- file,
- )
-
- val shareIntent =
- Intent().apply {
- action = Intent.ACTION_SEND
- putExtra(Intent.EXTRA_STREAM, uri)
- type = "text/plain"
- addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
- }
-
- startActivity(Intent.createChooser(shareIntent, getString(R.string.share_log)))
- } catch (e: Exception) {
- Toast.makeText(context, "Error sharing log: ${e.message}", Toast.LENGTH_SHORT).show()
- }
- }
-}
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
deleted file mode 100644
index dfef5857c..000000000
--- a/example/src/main/java/org/xmtp/android/example/message/MessageAdapter.kt
+++ /dev/null
@@ -1,59 +0,0 @@
-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() {
- init {
- setHasStableIds(true)
- }
-
- private val listItems = mutableListOf()
-
- fun setData(newItems: List) {
- listItems.clear()
- listItems.addAll(newItems)
- notifyDataSetChanged()
- }
-
- fun addItem(item: ConversationDetailViewModel.MessageListItem) {
- listItems.add(0, item)
- notifyDataSetChanged()
- }
-
- 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.size
-
- 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
deleted file mode 100644
index 4f2bee4da..000000000
--- a/example/src/main/java/org/xmtp/android/example/message/MessageViewHolder.kt
+++ /dev/null
@@ -1,58 +0,0 @@
-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
-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
-import org.xmtp.proto.mls.message.contents.TranscriptMessages.GroupUpdated
-import java.text.SimpleDateFormat
-import java.util.Locale
-
-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)
-
- @SuppressLint("SetTextI18n")
- fun bind(item: ConversationDetailViewModel.MessageListItem.Message) {
- val isFromMe =
- ClientManager.client.inboxId == item.message.senderInboxId
- 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.messageContainer.layoutParams = params
- if (item.message.content() is String) {
- binding.messageBody.text = item.message.body
- val sdf = SimpleDateFormat("yyyy-MM-dd HH:mm:ss", Locale.getDefault())
- binding.messageDate.text = sdf.format(item.message.sentAt)
- } else if (item.message.content() is GroupUpdated) {
- val changes = item.message.content() as? GroupUpdated
- binding.messageBody.text =
- "Membership Changed ${
- changes?.addedInboxesList?.mapNotNull { it.inboxId }
- }"
- }
- }
-}
diff --git a/example/src/main/java/org/xmtp/android/example/ui/components/Avatar.kt b/example/src/main/java/org/xmtp/android/example/ui/components/Avatar.kt
new file mode 100644
index 000000000..5579a1763
--- /dev/null
+++ b/example/src/main/java/org/xmtp/android/example/ui/components/Avatar.kt
@@ -0,0 +1,104 @@
+package org.xmtp.android.example.ui.components
+
+import androidx.compose.foundation.background
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.shape.CircleShape
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.tooling.preview.Preview
+import androidx.compose.ui.unit.Dp
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.sp
+import org.xmtp.android.example.ui.theme.AvatarColors
+import org.xmtp.android.example.ui.theme.XMTPTheme
+import kotlin.math.abs
+
+@Composable
+fun Avatar(
+ name: String,
+ modifier: Modifier = Modifier,
+ size: Dp = 48.dp,
+ showOnlineIndicator: Boolean = false,
+) {
+ val initials = getInitials(name)
+ val backgroundColor = getAvatarColor(name)
+
+ Box(
+ modifier = modifier.size(size),
+ contentAlignment = Alignment.Center,
+ ) {
+ // Main avatar circle
+ Box(
+ modifier =
+ Modifier
+ .size(size)
+ .clip(CircleShape)
+ .background(backgroundColor),
+ contentAlignment = Alignment.Center,
+ ) {
+ Text(
+ text = initials,
+ color = Color.White,
+ fontSize = (size.value * 0.35f).sp,
+ fontWeight = FontWeight.SemiBold,
+ )
+ }
+
+ // Online indicator
+ if (showOnlineIndicator) {
+ Box(
+ modifier =
+ Modifier
+ .size(size * 0.25f)
+ .align(Alignment.BottomEnd)
+ .clip(CircleShape)
+ .background(Color.White),
+ ) {
+ Box(
+ modifier =
+ Modifier
+ .size(size * 0.2f)
+ .align(Alignment.Center)
+ .clip(CircleShape)
+ .background(Color(0xFF34C759)),
+ )
+ }
+ }
+ }
+}
+
+private fun getInitials(name: String): String {
+ val cleaned = name.removePrefix("0x").trim()
+ return if (cleaned.length >= 2) {
+ cleaned.take(2).uppercase()
+ } else {
+ cleaned.uppercase()
+ }
+}
+
+private fun getAvatarColor(name: String): Color {
+ val hash = abs(name.hashCode())
+ return AvatarColors[hash % AvatarColors.size]
+}
+
+@Preview
+@Composable
+private fun AvatarPreview() {
+ XMTPTheme {
+ Avatar(name = "0x1234567890abcdef")
+ }
+}
+
+@Preview
+@Composable
+private fun AvatarWithOnlinePreview() {
+ XMTPTheme {
+ Avatar(name = "John Doe", showOnlineIndicator = true)
+ }
+}
diff --git a/example/src/main/java/org/xmtp/android/example/ui/components/ContactRow.kt b/example/src/main/java/org/xmtp/android/example/ui/components/ContactRow.kt
new file mode 100644
index 000000000..9ba3053f2
--- /dev/null
+++ b/example/src/main/java/org/xmtp/android/example/ui/components/ContactRow.kt
@@ -0,0 +1,231 @@
+package org.xmtp.android.example.ui.components
+
+import androidx.compose.foundation.background
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.layout.width
+import androidx.compose.foundation.shape.CircleShape
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.Add
+import androidx.compose.material.icons.filled.Check
+import androidx.compose.material3.Icon
+import androidx.compose.material3.IconButton
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Surface
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.text.style.TextOverflow
+import androidx.compose.ui.tooling.preview.Preview
+import androidx.compose.ui.unit.dp
+import org.xmtp.android.example.ui.theme.XMTPTheme
+import java.text.SimpleDateFormat
+import java.util.Calendar
+import java.util.Date
+import java.util.Locale
+import kotlin.math.abs
+
+data class RecentContactData(
+ val address: String,
+ val lastActivityTimeNs: Long,
+)
+
+private val avatarColors = listOf(
+ Color(0xFFFC4F37), // XMTP Red
+ Color(0xFF5856D6), // Purple
+ Color(0xFF34C759), // Green
+ Color(0xFFFF9500), // Orange
+ Color(0xFF007AFF), // Blue
+ Color(0xFFAF52DE), // Magenta
+ Color(0xFF00C7BE), // Teal
+ Color(0xFFFF2D55), // Pink
+)
+
+@Composable
+fun ContactRow(
+ contact: RecentContactData,
+ modifier: Modifier = Modifier,
+ isGroupMode: Boolean = false,
+ isSelected: Boolean = false,
+ onClick: () -> Unit = {},
+ onAddClick: () -> Unit = {},
+) {
+ val avatarText = contact.address
+ .removePrefix("0x")
+ .take(2)
+ .uppercase()
+
+ val colorIndex = abs(contact.address.hashCode()) % avatarColors.size
+ val avatarColor = avatarColors[colorIndex]
+
+ val displayAddress = contact.address.truncatedAddress()
+ val lastActivity = formatLastActivity(contact.lastActivityTimeNs)
+
+ Surface(
+ modifier = modifier
+ .fillMaxWidth()
+ .clickable(onClick = onClick),
+ color = MaterialTheme.colorScheme.surface,
+ ) {
+ Row(
+ modifier = Modifier
+ .padding(horizontal = 16.dp, vertical = 12.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ // Avatar
+ Box(
+ modifier = Modifier
+ .size(48.dp)
+ .clip(CircleShape)
+ .background(avatarColor),
+ contentAlignment = Alignment.Center,
+ ) {
+ Text(
+ text = avatarText,
+ style = MaterialTheme.typography.titleMedium,
+ color = Color.White,
+ fontWeight = FontWeight.Bold,
+ )
+ }
+
+ Spacer(modifier = Modifier.width(12.dp))
+
+ // Contact info
+ Column(
+ modifier = Modifier.weight(1f),
+ verticalArrangement = Arrangement.Center,
+ ) {
+ Text(
+ text = displayAddress,
+ style = MaterialTheme.typography.bodyLarge,
+ fontWeight = FontWeight.Medium,
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis,
+ )
+ Text(
+ text = lastActivity,
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+ }
+
+ // Group mode controls
+ if (isGroupMode) {
+ if (isSelected) {
+ Box(
+ modifier = Modifier
+ .size(40.dp)
+ .clip(CircleShape)
+ .background(MaterialTheme.colorScheme.primary),
+ contentAlignment = Alignment.Center,
+ ) {
+ Icon(
+ imageVector = Icons.Default.Check,
+ contentDescription = "Selected",
+ tint = MaterialTheme.colorScheme.onPrimary,
+ modifier = Modifier.size(20.dp),
+ )
+ }
+ } else {
+ IconButton(onClick = onAddClick) {
+ Icon(
+ imageVector = Icons.Default.Add,
+ contentDescription = "Add to group",
+ tint = MaterialTheme.colorScheme.primary,
+ )
+ }
+ }
+ }
+ }
+ }
+}
+
+private fun String.truncatedAddress(): String {
+ if (length >= 10) {
+ val start = 6
+ val end = length - 4
+ return replaceRange(start, end, "...")
+ }
+ return this
+}
+
+private fun formatLastActivity(timestampNs: Long): String {
+ val timestampMs = timestampNs / 1_000_000
+ val messageDate = Date(timestampMs)
+ val now = Calendar.getInstance()
+ val messageCalendar = Calendar.getInstance().apply { time = messageDate }
+
+ return when {
+ now.get(Calendar.YEAR) == messageCalendar.get(Calendar.YEAR) &&
+ now.get(Calendar.DAY_OF_YEAR) == messageCalendar.get(Calendar.DAY_OF_YEAR) -> {
+ "Last messaged today"
+ }
+ now.get(Calendar.YEAR) == messageCalendar.get(Calendar.YEAR) &&
+ now.get(Calendar.DAY_OF_YEAR) - messageCalendar.get(Calendar.DAY_OF_YEAR) == 1 -> {
+ "Last messaged yesterday"
+ }
+ now.get(Calendar.YEAR) == messageCalendar.get(Calendar.YEAR) &&
+ now.get(Calendar.DAY_OF_YEAR) - messageCalendar.get(Calendar.DAY_OF_YEAR) < 7 -> {
+ val daysDiff = now.get(Calendar.DAY_OF_YEAR) - messageCalendar.get(Calendar.DAY_OF_YEAR)
+ "Last messaged $daysDiff days ago"
+ }
+ else -> {
+ val formattedDate = SimpleDateFormat("MMM d", Locale.getDefault()).format(messageDate)
+ "Last messaged $formattedDate"
+ }
+ }
+}
+
+@Preview(showBackground = true)
+@Composable
+private fun ContactRowPreview() {
+ XMTPTheme {
+ ContactRow(
+ contact = RecentContactData(
+ address = "0x1234567890abcdef1234567890abcdef12345678",
+ lastActivityTimeNs = System.currentTimeMillis() * 1_000_000,
+ ),
+ )
+ }
+}
+
+@Preview(showBackground = true)
+@Composable
+private fun ContactRowGroupModePreview() {
+ XMTPTheme {
+ ContactRow(
+ contact = RecentContactData(
+ address = "0xabcdef1234567890abcdef1234567890abcdef12",
+ lastActivityTimeNs = (System.currentTimeMillis() - 86400000) * 1_000_000, // yesterday
+ ),
+ isGroupMode = true,
+ isSelected = false,
+ )
+ }
+}
+
+@Preview(showBackground = true)
+@Composable
+private fun ContactRowSelectedPreview() {
+ XMTPTheme {
+ ContactRow(
+ contact = RecentContactData(
+ address = "0x9876543210fedcba9876543210fedcba98765432",
+ lastActivityTimeNs = (System.currentTimeMillis() - 172800000) * 1_000_000, // 2 days ago
+ ),
+ isGroupMode = true,
+ isSelected = true,
+ )
+ }
+}
diff --git a/example/src/main/java/org/xmtp/android/example/ui/components/ConversationRow.kt b/example/src/main/java/org/xmtp/android/example/ui/components/ConversationRow.kt
new file mode 100644
index 000000000..83bfdd4a5
--- /dev/null
+++ b/example/src/main/java/org/xmtp/android/example/ui/components/ConversationRow.kt
@@ -0,0 +1,202 @@
+package org.xmtp.android.example.ui.components
+
+import androidx.compose.foundation.background
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.layout.width
+import androidx.compose.foundation.shape.CircleShape
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.Group
+import androidx.compose.material.icons.filled.Person
+import androidx.compose.material3.Icon
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Surface
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.text.style.TextOverflow
+import androidx.compose.ui.tooling.preview.Preview
+import androidx.compose.ui.unit.dp
+import org.xmtp.android.example.ui.theme.XMTPTheme
+
+@Composable
+fun ConversationRow(
+ name: String,
+ lastMessage: String,
+ timestamp: String,
+ modifier: Modifier = Modifier,
+ isGroup: Boolean = false,
+ memberCount: Int? = null,
+ unreadCount: Int = 0,
+ onClick: () -> Unit = {},
+) {
+ Surface(
+ modifier =
+ modifier
+ .fillMaxWidth()
+ .clickable(onClick = onClick),
+ color = MaterialTheme.colorScheme.surface,
+ ) {
+ Row(
+ modifier =
+ Modifier
+ .padding(horizontal = 16.dp, vertical = 12.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ // Avatar
+ Box(
+ modifier = Modifier.size(52.dp),
+ contentAlignment = Alignment.Center,
+ ) {
+ Avatar(name = name, size = 52.dp)
+
+ // Group/DM indicator badge
+ Box(
+ modifier =
+ Modifier
+ .size(20.dp)
+ .align(Alignment.BottomEnd)
+ .clip(CircleShape)
+ .background(if (isGroup) Color(0xFF34C759) else Color(0xFF007AFF)),
+ contentAlignment = Alignment.Center,
+ ) {
+ Icon(
+ imageVector = if (isGroup) Icons.Default.Group else Icons.Default.Person,
+ contentDescription = if (isGroup) "Group" else "Direct Message",
+ modifier = Modifier.size(12.dp),
+ tint = Color.White,
+ )
+ }
+ }
+
+ Spacer(modifier = Modifier.width(12.dp))
+
+ // Content
+ Column(
+ modifier = Modifier.weight(1f),
+ verticalArrangement = Arrangement.Center,
+ ) {
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.SpaceBetween,
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ Text(
+ text = name,
+ style = MaterialTheme.typography.titleMedium,
+ fontWeight = if (unreadCount > 0) FontWeight.Bold else FontWeight.Normal,
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis,
+ modifier = Modifier.weight(1f),
+ )
+
+ Spacer(modifier = Modifier.width(8.dp))
+
+ Text(
+ text = timestamp,
+ style = MaterialTheme.typography.labelSmall,
+ color =
+ if (unreadCount > 0) {
+ MaterialTheme.colorScheme.primary
+ } else {
+ MaterialTheme.colorScheme.onSurfaceVariant
+ },
+ )
+ }
+
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.SpaceBetween,
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ Column(modifier = Modifier.weight(1f)) {
+ if (isGroup && memberCount != null) {
+ Text(
+ text = "$memberCount members",
+ style = MaterialTheme.typography.labelSmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+ }
+
+ Text(
+ text = lastMessage,
+ style = MaterialTheme.typography.bodyMedium,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis,
+ fontWeight = if (unreadCount > 0) FontWeight.Medium else FontWeight.Normal,
+ )
+ }
+
+ if (unreadCount > 0) {
+ Spacer(modifier = Modifier.width(8.dp))
+
+ Surface(
+ shape = CircleShape,
+ color = MaterialTheme.colorScheme.primary,
+ ) {
+ Text(
+ text = if (unreadCount > 99) "99+" else unreadCount.toString(),
+ style = MaterialTheme.typography.labelSmall,
+ color = Color.White,
+ modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp),
+ )
+ }
+ }
+ }
+ }
+ }
+ }
+}
+
+@Preview
+@Composable
+private fun DMConversationPreview() {
+ XMTPTheme {
+ ConversationRow(
+ name = "0x1234...5678",
+ lastMessage = "Hey, how are you doing?",
+ timestamp = "10:30 AM",
+ isGroup = false,
+ )
+ }
+}
+
+@Preview
+@Composable
+private fun GroupConversationPreview() {
+ XMTPTheme {
+ ConversationRow(
+ name = "XMTP Dev Team",
+ lastMessage = "Alice: Let's ship this feature!",
+ timestamp = "Yesterday",
+ isGroup = true,
+ memberCount = 5,
+ )
+ }
+}
+
+@Preview
+@Composable
+private fun UnreadConversationPreview() {
+ XMTPTheme {
+ ConversationRow(
+ name = "Bob",
+ lastMessage = "Check out this new update!",
+ timestamp = "2:45 PM",
+ isGroup = false,
+ unreadCount = 3,
+ )
+ }
+}
diff --git a/example/src/main/java/org/xmtp/android/example/ui/components/EditPreviewBar.kt b/example/src/main/java/org/xmtp/android/example/ui/components/EditPreviewBar.kt
new file mode 100644
index 000000000..891925d94
--- /dev/null
+++ b/example/src/main/java/org/xmtp/android/example/ui/components/EditPreviewBar.kt
@@ -0,0 +1,157 @@
+package org.xmtp.android.example.ui.components
+
+import android.graphics.Bitmap
+import androidx.compose.foundation.Image
+import androidx.compose.foundation.background
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.layout.width
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.Close
+import androidx.compose.material.icons.filled.Edit
+import androidx.compose.material3.Icon
+import androidx.compose.material3.IconButton
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Surface
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.graphics.asImageBitmap
+import androidx.compose.ui.layout.ContentScale
+import androidx.compose.ui.text.style.TextOverflow
+import androidx.compose.ui.tooling.preview.Preview
+import androidx.compose.ui.unit.dp
+import org.xmtp.android.example.ui.theme.XMTPTheme
+
+data class EditPreviewData(
+ val messageText: String,
+ val imageThumbnail: Bitmap? = null,
+)
+
+@Composable
+fun EditPreviewBar(
+ editData: EditPreviewData,
+ onClose: () -> Unit,
+ modifier: Modifier = Modifier,
+) {
+ Surface(
+ modifier = modifier.fillMaxWidth(),
+ color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f),
+ ) {
+ Row(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(horizontal = 8.dp, vertical = 8.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ // Edit icon with accent bar
+ Row(
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ Box(
+ modifier = Modifier
+ .width(3.dp)
+ .height(40.dp)
+ .background(
+ MaterialTheme.colorScheme.tertiary,
+ RoundedCornerShape(2.dp),
+ ),
+ )
+
+ Spacer(modifier = Modifier.width(8.dp))
+
+ Icon(
+ imageVector = Icons.Default.Edit,
+ contentDescription = "Editing",
+ tint = MaterialTheme.colorScheme.tertiary,
+ modifier = Modifier.size(20.dp),
+ )
+ }
+
+ Spacer(modifier = Modifier.width(8.dp))
+
+ // Edit content
+ Column(
+ modifier = Modifier.weight(1f),
+ ) {
+ Text(
+ text = "Editing",
+ style = MaterialTheme.typography.labelMedium,
+ color = MaterialTheme.colorScheme.tertiary,
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis,
+ )
+
+ Text(
+ text = editData.messageText,
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis,
+ )
+ }
+
+ // Image thumbnail if present
+ editData.imageThumbnail?.let { bitmap ->
+ Spacer(modifier = Modifier.width(8.dp))
+ Image(
+ bitmap = bitmap.asImageBitmap(),
+ contentDescription = "Image preview",
+ modifier = Modifier
+ .size(40.dp)
+ .clip(RoundedCornerShape(4.dp)),
+ contentScale = ContentScale.Crop,
+ )
+ }
+
+ // Close button
+ IconButton(
+ onClick = onClose,
+ modifier = Modifier.size(32.dp),
+ ) {
+ Icon(
+ imageVector = Icons.Default.Close,
+ contentDescription = "Cancel edit",
+ tint = MaterialTheme.colorScheme.onSurfaceVariant,
+ modifier = Modifier.size(20.dp),
+ )
+ }
+ }
+ }
+}
+
+@Preview(showBackground = true)
+@Composable
+private fun EditPreviewBarPreview() {
+ XMTPTheme {
+ EditPreviewBar(
+ editData = EditPreviewData(
+ messageText = "This is my original message that I want to edit",
+ ),
+ onClose = {},
+ )
+ }
+}
+
+@Preview(showBackground = true)
+@Composable
+private fun EditPreviewBarPhotoPreview() {
+ XMTPTheme {
+ EditPreviewBar(
+ editData = EditPreviewData(
+ messageText = "Photo",
+ imageThumbnail = null, // In real usage, this would be a Bitmap
+ ),
+ onClose = {},
+ )
+ }
+}
diff --git a/example/src/main/java/org/xmtp/android/example/ui/components/EmojiPicker.kt b/example/src/main/java/org/xmtp/android/example/ui/components/EmojiPicker.kt
new file mode 100644
index 000000000..363227240
--- /dev/null
+++ b/example/src/main/java/org/xmtp/android/example/ui/components/EmojiPicker.kt
@@ -0,0 +1,169 @@
+package org.xmtp.android.example.ui.components
+
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.PaddingValues
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.lazy.grid.GridCells
+import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
+import androidx.compose.foundation.lazy.grid.items
+import androidx.compose.foundation.shape.CircleShape
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Surface
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.tooling.preview.Preview
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.sp
+import org.xmtp.android.example.ui.theme.XMTPTheme
+
+val defaultEmojis = listOf(
+ // Quick reactions row
+ "\uD83D\uDC4D", // 👍
+ "\u2764\uFE0F", // ❤️
+ "\uD83D\uDE02", // 😂
+ "\uD83D\uDE2E", // 😮
+ "\uD83D\uDE22", // 😢
+ "\uD83D\uDE4F", // 🙏
+ // Smileys & Emotion
+ "\uD83D\uDE00", "\uD83D\uDE03", "\uD83D\uDE04", "\uD83D\uDE01", "\uD83D\uDE06",
+ "\uD83D\uDE05", "\uD83E\uDD23", "\uD83D\uDE0A", "\uD83D\uDE07", "\uD83D\uDE42",
+ "\uD83D\uDE43", "\uD83D\uDE09", "\uD83D\uDE0C", "\uD83D\uDE0D", "\uD83E\uDD70",
+ "\uD83D\uDE18", "\uD83D\uDE17", "\uD83D\uDE19", "\uD83D\uDE1A", "\uD83D\uDE0B",
+ "\uD83D\uDE1B", "\uD83D\uDE1C", "\uD83E\uDD2A", "\uD83D\uDE1D", "\uD83E\uDD11",
+ "\uD83E\uDD17", "\uD83E\uDD2D", "\uD83E\uDD2B", "\uD83E\uDD14", "\uD83E\uDD10",
+ "\uD83E\uDD28", "\uD83D\uDE10", "\uD83D\uDE11", "\uD83D\uDE36", "\uD83D\uDE0F",
+ "\uD83D\uDE12", "\uD83D\uDE44", "\uD83D\uDE2C", "\uD83E\uDD25", "\uD83D\uDE14",
+ "\uD83D\uDE2A", "\uD83E\uDD24", "\uD83D\uDE34", "\uD83D\uDE37", "\uD83E\uDD12",
+ "\uD83E\uDD15", "\uD83E\uDD22", "\uD83E\uDD2E", "\uD83E\uDD27", "\uD83E\uDD75",
+ "\uD83E\uDD76", "\uD83E\uDD74", "\uD83D\uDE35", "\uD83E\uDD2F", "\uD83E\uDD20",
+ "\uD83E\uDD73", "\uD83D\uDE0E", "\uD83E\uDD13", "\uD83E\uDDD0", "\uD83D\uDE15",
+ "\uD83D\uDE1F", "\uD83D\uDE41", "\u2639\uFE0F", "\uD83D\uDE2E", "\uD83D\uDE2F",
+ "\uD83D\uDE32", "\uD83D\uDE33", "\uD83E\uDD7A", "\uD83D\uDE26", "\uD83D\uDE27",
+ "\uD83D\uDE28", "\uD83D\uDE30", "\uD83D\uDE25", "\uD83D\uDE2D", "\uD83D\uDE31",
+ "\uD83D\uDE16", "\uD83D\uDE23", "\uD83D\uDE1E", "\uD83D\uDE13", "\uD83D\uDE29",
+ "\uD83D\uDE2B", "\uD83E\uDD71", "\uD83D\uDE24", "\uD83D\uDE21", "\uD83D\uDE20",
+ "\uD83E\uDD2C", "\uD83D\uDE08", "\uD83D\uDC7F", "\uD83D\uDC80", "\u2620\uFE0F",
+ "\uD83D\uDCA9", "\uD83E\uDD21", "\uD83D\uDC79", "\uD83D\uDC7A", "\uD83D\uDC7B",
+ "\uD83D\uDC7D", "\uD83D\uDC7E", "\uD83E\uDD16",
+ // Gestures
+ "\uD83D\uDC4E", "\uD83D\uDC4A", "\u270A", "\uD83E\uDD1B", "\uD83E\uDD1C",
+ "\uD83D\uDC4F", "\uD83D\uDE4C", "\uD83D\uDC50", "\uD83E\uDD32", "\uD83E\uDD1D",
+ "\u270D\uFE0F", "\uD83D\uDC85", "\uD83E\uDD33", "\uD83D\uDCAA",
+ // Hearts
+ "\uD83E\uDDE1", "\uD83D\uDC9B", "\uD83D\uDC9A", "\uD83D\uDC99", "\uD83D\uDC9C",
+ "\uD83E\uDD0E", "\uD83D\uDDA4", "\uD83E\uDD0D", "\uD83D\uDC94", "\u2763\uFE0F",
+ "\uD83D\uDC95", "\uD83D\uDC9E", "\uD83D\uDC93", "\uD83D\uDC97", "\uD83D\uDC96",
+ "\uD83D\uDC98", "\uD83D\uDC9D", "\uD83D\uDC9F",
+ // Common objects
+ "\uD83D\uDD25", "\u2B50", "\uD83C\uDF1F", "\u2728", "\uD83C\uDF88", "\uD83C\uDF89",
+ "\uD83C\uDF8A", "\uD83C\uDF81", "\uD83C\uDF80", "\uD83D\uDCAF", "\uD83D\uDCA2",
+ "\uD83D\uDCA5", "\uD83D\uDCAB", "\uD83D\uDCA6", "\uD83D\uDCA8", "\uD83D\uDCA3",
+ "\uD83D\uDCAC",
+ // Symbols
+ "\u2714\uFE0F", "\u274C", "\u274E", "\u2753", "\u2757", "\u26A0\uFE0F",
+ "\uD83D\uDEAB", "\u2705", "\uD83D\uDC4C",
+)
+
+@Composable
+fun EmojiPicker(
+ onEmojiSelected: (String) -> Unit,
+ modifier: Modifier = Modifier,
+ emojis: List = defaultEmojis,
+) {
+ LazyVerticalGrid(
+ columns = GridCells.Fixed(8),
+ modifier = modifier
+ .fillMaxWidth()
+ .height(250.dp),
+ contentPadding = PaddingValues(8.dp),
+ horizontalArrangement = Arrangement.spacedBy(4.dp),
+ verticalArrangement = Arrangement.spacedBy(4.dp),
+ ) {
+ items(emojis) { emoji ->
+ EmojiItem(
+ emoji = emoji,
+ onClick = { onEmojiSelected(emoji) },
+ )
+ }
+ }
+}
+
+@Composable
+fun QuickReactionPicker(
+ onEmojiSelected: (String) -> Unit,
+ modifier: Modifier = Modifier,
+ reactions: List = defaultEmojis.take(6),
+) {
+ Surface(
+ modifier = modifier,
+ shape = MaterialTheme.shapes.large,
+ color = MaterialTheme.colorScheme.surfaceVariant,
+ tonalElevation = 2.dp,
+ ) {
+ androidx.compose.foundation.layout.Row(
+ modifier = Modifier
+ .fillMaxWidth()
+ .height(48.dp),
+ horizontalArrangement = Arrangement.SpaceEvenly,
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ reactions.forEach { emoji ->
+ EmojiItem(
+ emoji = emoji,
+ onClick = { onEmojiSelected(emoji) },
+ modifier = Modifier.size(40.dp),
+ )
+ }
+ }
+ }
+}
+
+@Composable
+private fun EmojiItem(
+ emoji: String,
+ onClick: () -> Unit,
+ modifier: Modifier = Modifier,
+) {
+ Surface(
+ modifier = modifier
+ .size(40.dp)
+ .clickable(onClick = onClick),
+ shape = CircleShape,
+ color = MaterialTheme.colorScheme.surface,
+ ) {
+ Box(
+ contentAlignment = Alignment.Center,
+ ) {
+ Text(
+ text = emoji,
+ fontSize = 24.sp,
+ )
+ }
+ }
+}
+
+@Preview(showBackground = true)
+@Composable
+private fun EmojiPickerPreview() {
+ XMTPTheme {
+ EmojiPicker(
+ onEmojiSelected = {},
+ )
+ }
+}
+
+@Preview(showBackground = true)
+@Composable
+private fun QuickReactionPickerPreview() {
+ XMTPTheme {
+ QuickReactionPicker(
+ onEmojiSelected = {},
+ )
+ }
+}
diff --git a/example/src/main/java/org/xmtp/android/example/ui/components/EnvironmentSelector.kt b/example/src/main/java/org/xmtp/android/example/ui/components/EnvironmentSelector.kt
new file mode 100644
index 000000000..b53590a86
--- /dev/null
+++ b/example/src/main/java/org/xmtp/android/example/ui/components/EnvironmentSelector.kt
@@ -0,0 +1,82 @@
+package org.xmtp.android.example.ui.components
+
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.material3.DropdownMenuItem
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.ExposedDropdownMenuBox
+import androidx.compose.material3.ExposedDropdownMenuDefaults
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.OutlinedTextField
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.tooling.preview.Preview
+import org.xmtp.android.example.ui.theme.XMTPTheme
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+fun DropdownSelector(
+ label: String,
+ options: List,
+ selectedOption: T,
+ onOptionSelected: (T) -> Unit,
+ optionLabel: (T) -> String,
+ modifier: Modifier = Modifier,
+) {
+ var expanded by remember { mutableStateOf(false) }
+
+ ExposedDropdownMenuBox(
+ expanded = expanded,
+ onExpandedChange = { expanded = it },
+ modifier = modifier,
+ ) {
+ OutlinedTextField(
+ value = optionLabel(selectedOption),
+ onValueChange = {},
+ readOnly = true,
+ label = { Text(label) },
+ trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = expanded) },
+ colors = ExposedDropdownMenuDefaults.outlinedTextFieldColors(),
+ modifier = Modifier
+ .menuAnchor()
+ .fillMaxWidth(),
+ )
+
+ ExposedDropdownMenu(
+ expanded = expanded,
+ onDismissRequest = { expanded = false },
+ ) {
+ options.forEach { option ->
+ DropdownMenuItem(
+ text = { Text(optionLabel(option)) },
+ onClick = {
+ onOptionSelected(option)
+ expanded = false
+ },
+ contentPadding = ExposedDropdownMenuDefaults.ItemContentPadding,
+ )
+ }
+ }
+ }
+}
+
+@Preview(showBackground = true)
+@Composable
+private fun DropdownSelectorPreview() {
+ XMTPTheme {
+ val options = listOf("Dev", "Production", "Local")
+ var selected by remember { mutableStateOf(options[0]) }
+
+ DropdownSelector(
+ label = "Environment",
+ options = options,
+ selectedOption = selected,
+ onOptionSelected = { selected = it },
+ optionLabel = { it },
+ )
+ }
+}
diff --git a/example/src/main/java/org/xmtp/android/example/ui/components/LoadingButton.kt b/example/src/main/java/org/xmtp/android/example/ui/components/LoadingButton.kt
new file mode 100644
index 000000000..600aa412b
--- /dev/null
+++ b/example/src/main/java/org/xmtp/android/example/ui/components/LoadingButton.kt
@@ -0,0 +1,74 @@
+package org.xmtp.android.example.ui.components
+
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.layout.width
+import androidx.compose.material3.Button
+import androidx.compose.material3.ButtonDefaults
+import androidx.compose.material3.CircularProgressIndicator
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.tooling.preview.Preview
+import androidx.compose.ui.unit.dp
+import org.xmtp.android.example.ui.theme.XMTPTheme
+
+@Composable
+fun LoadingButton(
+ text: String,
+ onClick: () -> Unit,
+ modifier: Modifier = Modifier,
+ isLoading: Boolean = false,
+ enabled: Boolean = true,
+) {
+ Button(
+ onClick = onClick,
+ modifier = modifier
+ .fillMaxWidth()
+ .height(56.dp),
+ enabled = enabled && !isLoading,
+ colors = ButtonDefaults.buttonColors(
+ containerColor = MaterialTheme.colorScheme.primary,
+ contentColor = MaterialTheme.colorScheme.onPrimary,
+ ),
+ ) {
+ if (isLoading) {
+ CircularProgressIndicator(
+ modifier = Modifier.size(24.dp),
+ color = MaterialTheme.colorScheme.onPrimary,
+ strokeWidth = 2.dp,
+ )
+ Spacer(modifier = Modifier.width(8.dp))
+ }
+ Text(
+ text = if (isLoading) "Loading..." else text,
+ style = MaterialTheme.typography.labelLarge,
+ )
+ }
+}
+
+@Preview(showBackground = true)
+@Composable
+private fun LoadingButtonPreview() {
+ XMTPTheme {
+ LoadingButton(
+ text = "Generate Wallet",
+ onClick = {},
+ )
+ }
+}
+
+@Preview(showBackground = true)
+@Composable
+private fun LoadingButtonLoadingPreview() {
+ XMTPTheme {
+ LoadingButton(
+ text = "Generate Wallet",
+ onClick = {},
+ isLoading = true,
+ )
+ }
+}
diff --git a/example/src/main/java/org/xmtp/android/example/ui/components/MemberRow.kt b/example/src/main/java/org/xmtp/android/example/ui/components/MemberRow.kt
new file mode 100644
index 000000000..a77a255e6
--- /dev/null
+++ b/example/src/main/java/org/xmtp/android/example/ui/components/MemberRow.kt
@@ -0,0 +1,296 @@
+package org.xmtp.android.example.ui.components
+
+import androidx.compose.foundation.background
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.layout.width
+import androidx.compose.foundation.shape.CircleShape
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.MoreVert
+import androidx.compose.material3.DropdownMenu
+import androidx.compose.material3.DropdownMenuItem
+import androidx.compose.material3.Icon
+import androidx.compose.material3.IconButton
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Surface
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.text.style.TextOverflow
+import androidx.compose.ui.tooling.preview.Preview
+import androidx.compose.ui.unit.dp
+import org.xmtp.android.example.ui.theme.XMTPTheme
+import kotlin.math.abs
+
+enum class MemberPermissionLevel {
+ SUPER_ADMIN,
+ ADMIN,
+ MEMBER,
+}
+
+data class MemberRowData(
+ val inboxId: String,
+ val displayAddress: String?,
+ val permissionLevel: MemberPermissionLevel,
+ val isCurrentUser: Boolean,
+)
+
+private val avatarColors = listOf(
+ Color(0xFFFC4F37), // XMTP Red
+ Color(0xFF5856D6), // Purple
+ Color(0xFF34C759), // Green
+ Color(0xFFFF9500), // Orange
+ Color(0xFF007AFF), // Blue
+ Color(0xFFAF52DE), // Magenta
+ Color(0xFF00C7BE), // Teal
+ Color(0xFFFF2D55), // Pink
+)
+
+@Composable
+fun MemberRow(
+ member: MemberRowData,
+ canManageMembers: Boolean,
+ modifier: Modifier = Modifier,
+ onMemberClick: () -> Unit = {},
+ onPromoteToAdmin: () -> Unit = {},
+ onDemoteFromAdmin: () -> Unit = {},
+ onRemoveMember: () -> Unit = {},
+) {
+ var showMenu by remember { mutableStateOf(false) }
+
+ val avatarText = member.displayAddress
+ ?.removePrefix("0x")
+ ?.take(2)
+ ?.uppercase()
+ ?: member.inboxId.take(2).uppercase()
+
+ val colorIndex = abs(member.inboxId.hashCode()) % avatarColors.size
+ val avatarColor = avatarColors[colorIndex]
+
+ val displayName = if (member.isCurrentUser) {
+ "${member.displayAddress?.truncatedAddress() ?: member.inboxId.truncatedAddress()} (You)"
+ } else {
+ member.displayAddress?.truncatedAddress() ?: member.inboxId.truncatedAddress()
+ }
+
+ Surface(
+ modifier = modifier
+ .fillMaxWidth()
+ .clickable(onClick = onMemberClick),
+ color = MaterialTheme.colorScheme.surface,
+ ) {
+ Row(
+ modifier = Modifier
+ .padding(horizontal = 16.dp, vertical = 12.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ // Avatar
+ Box(
+ modifier = Modifier
+ .size(48.dp)
+ .clip(CircleShape)
+ .background(avatarColor),
+ contentAlignment = Alignment.Center,
+ ) {
+ Text(
+ text = avatarText,
+ style = MaterialTheme.typography.titleMedium,
+ color = Color.White,
+ fontWeight = FontWeight.Bold,
+ )
+ }
+
+ Spacer(modifier = Modifier.width(12.dp))
+
+ // Member info
+ Column(
+ modifier = Modifier.weight(1f),
+ verticalArrangement = Arrangement.Center,
+ ) {
+ Row(
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.spacedBy(8.dp),
+ ) {
+ Text(
+ text = displayName,
+ style = MaterialTheme.typography.bodyLarge,
+ fontWeight = FontWeight.Medium,
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis,
+ modifier = Modifier.weight(1f, fill = false),
+ )
+
+ // Role badge
+ when (member.permissionLevel) {
+ MemberPermissionLevel.SUPER_ADMIN -> {
+ RoleBadge(
+ text = "Super Admin",
+ color = MaterialTheme.colorScheme.primary,
+ )
+ }
+ MemberPermissionLevel.ADMIN -> {
+ RoleBadge(
+ text = "Admin",
+ color = Color(0xFF5856D6),
+ )
+ }
+ MemberPermissionLevel.MEMBER -> {
+ // No badge for regular members
+ }
+ }
+ }
+
+ Text(
+ text = "Inbox: ${member.inboxId.take(8)}...",
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+ }
+
+ // Menu button
+ if (canManageMembers && !member.isCurrentUser) {
+ Box {
+ IconButton(onClick = { showMenu = true }) {
+ Icon(
+ imageVector = Icons.Default.MoreVert,
+ contentDescription = "Member options",
+ tint = MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+ }
+
+ DropdownMenu(
+ expanded = showMenu,
+ onDismissRequest = { showMenu = false },
+ ) {
+ when (member.permissionLevel) {
+ MemberPermissionLevel.MEMBER -> {
+ DropdownMenuItem(
+ text = { Text("Promote to Admin") },
+ onClick = {
+ showMenu = false
+ onPromoteToAdmin()
+ },
+ )
+ }
+ MemberPermissionLevel.ADMIN -> {
+ DropdownMenuItem(
+ text = { Text("Remove Admin") },
+ onClick = {
+ showMenu = false
+ onDemoteFromAdmin()
+ },
+ )
+ }
+ MemberPermissionLevel.SUPER_ADMIN -> {
+ // Can't modify super admin
+ }
+ }
+ if (member.permissionLevel != MemberPermissionLevel.SUPER_ADMIN) {
+ DropdownMenuItem(
+ text = { Text("Remove from Group") },
+ onClick = {
+ showMenu = false
+ onRemoveMember()
+ },
+ )
+ }
+ }
+ }
+ }
+ }
+ }
+}
+
+@Composable
+private fun RoleBadge(
+ text: String,
+ color: Color,
+ modifier: Modifier = Modifier,
+) {
+ Surface(
+ shape = RoundedCornerShape(12.dp),
+ color = color.copy(alpha = 0.15f),
+ modifier = modifier,
+ ) {
+ Text(
+ text = text,
+ style = MaterialTheme.typography.labelSmall,
+ color = color,
+ fontWeight = FontWeight.Medium,
+ modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp),
+ )
+ }
+}
+
+private fun String.truncatedAddress(): String {
+ if (length >= 10) {
+ val start = 6
+ val end = length - 4
+ return replaceRange(start, end, "...")
+ }
+ return this
+}
+
+@Preview(showBackground = true)
+@Composable
+private fun MemberRowPreview() {
+ XMTPTheme {
+ MemberRow(
+ member = MemberRowData(
+ inboxId = "abc123def456",
+ displayAddress = "0x1234567890abcdef1234567890abcdef12345678",
+ permissionLevel = MemberPermissionLevel.MEMBER,
+ isCurrentUser = false,
+ ),
+ canManageMembers = true,
+ )
+ }
+}
+
+@Preview(showBackground = true)
+@Composable
+private fun MemberRowAdminPreview() {
+ XMTPTheme {
+ MemberRow(
+ member = MemberRowData(
+ inboxId = "admin123456",
+ displayAddress = "0xabcdef1234567890abcdef1234567890abcdef12",
+ permissionLevel = MemberPermissionLevel.ADMIN,
+ isCurrentUser = false,
+ ),
+ canManageMembers = true,
+ )
+ }
+}
+
+@Preview(showBackground = true)
+@Composable
+private fun MemberRowCurrentUserPreview() {
+ XMTPTheme {
+ MemberRow(
+ member = MemberRowData(
+ inboxId = "myinbox12345",
+ displayAddress = "0x9876543210fedcba9876543210fedcba98765432",
+ permissionLevel = MemberPermissionLevel.SUPER_ADMIN,
+ isCurrentUser = true,
+ ),
+ canManageMembers = true,
+ )
+ }
+}
diff --git a/example/src/main/java/org/xmtp/android/example/ui/components/MessageBubble.kt b/example/src/main/java/org/xmtp/android/example/ui/components/MessageBubble.kt
new file mode 100644
index 000000000..2391663d8
--- /dev/null
+++ b/example/src/main/java/org/xmtp/android/example/ui/components/MessageBubble.kt
@@ -0,0 +1,552 @@
+package org.xmtp.android.example.ui.components
+
+import android.graphics.BitmapFactory
+import androidx.compose.foundation.Image
+import androidx.compose.foundation.background
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.IntrinsicSize
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.aspectRatio
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.layout.width
+import androidx.compose.foundation.layout.widthIn
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.Check
+import androidx.compose.material.icons.filled.Description
+import androidx.compose.material.icons.filled.Error
+import androidx.compose.material.icons.filled.Schedule
+import androidx.compose.material3.Icon
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Surface
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.remember
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.graphics.asImageBitmap
+import androidx.compose.ui.layout.ContentScale
+import androidx.compose.ui.text.font.FontStyle
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.text.style.TextOverflow
+import androidx.compose.ui.tooling.preview.Preview
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.sp
+import org.xmtp.android.example.ui.theme.SentMessageBackground
+import org.xmtp.android.example.ui.theme.XMTPTheme
+import java.util.Locale
+
+enum class DeliveryStatus {
+ SENDING,
+ SENT,
+ FAILED,
+}
+
+sealed class MessageAttachment {
+ data class Image(
+ val data: ByteArray,
+ val mimeType: String,
+ ) : MessageAttachment() {
+ override fun equals(other: Any?): Boolean {
+ if (this === other) return true
+ if (javaClass != other?.javaClass) return false
+ other as Image
+ return data.contentEquals(other.data) && mimeType == other.mimeType
+ }
+
+ override fun hashCode(): Int {
+ var result = data.contentHashCode()
+ result = 31 * result + mimeType.hashCode()
+ return result
+ }
+ }
+
+ data class File(
+ val filename: String,
+ val size: Int,
+ val mimeType: String,
+ ) : MessageAttachment()
+}
+
+@Composable
+fun MessageBubble(
+ message: String,
+ timestamp: String,
+ isMe: Boolean,
+ modifier: Modifier = Modifier,
+ senderName: String? = null,
+ senderColor: Color? = null,
+ deliveryStatus: DeliveryStatus = DeliveryStatus.SENT,
+ reactions: List>? = null,
+ replyPreview: String? = null,
+ replyAuthor: String? = null,
+ isDeleted: Boolean = false,
+ attachment: MessageAttachment? = null,
+ onLongClick: () -> Unit = {},
+ onReplyClick: (() -> Unit)? = null,
+ onAttachmentClick: (() -> Unit)? = null,
+) {
+ Row(
+ modifier =
+ modifier
+ .fillMaxWidth()
+ .padding(horizontal = 8.dp, vertical = 2.dp),
+ horizontalArrangement = if (isMe) Arrangement.End else Arrangement.Start,
+ ) {
+ if (isMe) {
+ Spacer(
+ modifier =
+ Modifier
+ .weight(1f, fill = false)
+ .widthIn(min = 60.dp),
+ )
+ }
+
+ Column(
+ horizontalAlignment = if (isMe) Alignment.End else Alignment.Start,
+ ) {
+ // Sender name for group messages
+ if (senderName != null && !isMe) {
+ Text(
+ text = senderName,
+ style = MaterialTheme.typography.labelSmall,
+ fontWeight = FontWeight.SemiBold,
+ color = senderColor ?: MaterialTheme.colorScheme.primary,
+ modifier = Modifier.padding(start = 12.dp, bottom = 2.dp),
+ )
+ }
+
+ // Message bubble
+ Surface(
+ shape = RoundedCornerShape(18.dp),
+ color = if (isMe) SentMessageBackground else MaterialTheme.colorScheme.surfaceVariant,
+ modifier =
+ Modifier
+ .widthIn(max = 280.dp)
+ .clickable(onClick = onLongClick),
+ ) {
+ // Use IntrinsicSize.Max to ensure all children share the maximum width
+ // This makes the reply bubble at least as wide as the preview
+ Column(
+ modifier =
+ Modifier
+ .width(IntrinsicSize.Max)
+ .padding(
+ horizontal = if (attachment is MessageAttachment.Image) 4.dp else 12.dp,
+ vertical = if (attachment is MessageAttachment.Image) 4.dp else 8.dp,
+ ),
+ ) {
+ // Reply preview
+ if (replyPreview != null) {
+ Surface(
+ shape = RoundedCornerShape(8.dp),
+ color =
+ if (isMe) {
+ Color.White.copy(alpha = 0.15f)
+ } else {
+ MaterialTheme.colorScheme.surface.copy(alpha = 0.5f)
+ },
+ modifier =
+ Modifier
+ .fillMaxWidth()
+ .padding(bottom = 4.dp)
+ .clickable { onReplyClick?.invoke() },
+ ) {
+ Row(
+ modifier = Modifier.padding(horizontal = 8.dp, vertical = 6.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ Box(
+ modifier =
+ Modifier
+ .width(2.dp)
+ .size(2.dp, 24.dp)
+ .background(
+ if (isMe) {
+ Color.White.copy(alpha = 0.5f)
+ } else {
+ MaterialTheme.colorScheme.primary.copy(alpha = 0.5f)
+ },
+ ),
+ )
+ Spacer(modifier = Modifier.width(6.dp))
+ Column {
+ if (replyAuthor != null) {
+ Text(
+ text = replyAuthor,
+ style = MaterialTheme.typography.labelSmall,
+ fontWeight = FontWeight.SemiBold,
+ color =
+ if (isMe) {
+ Color.White.copy(alpha = 0.8f)
+ } else {
+ MaterialTheme.colorScheme.primary
+ },
+ )
+ }
+ Text(
+ text = replyPreview,
+ style = MaterialTheme.typography.bodySmall,
+ color =
+ if (isMe) {
+ Color.White.copy(alpha = 0.7f)
+ } else {
+ MaterialTheme.colorScheme.onSurfaceVariant
+ },
+ maxLines = 2,
+ )
+ }
+ }
+ }
+ }
+
+ // Image attachment
+ if (attachment is MessageAttachment.Image) {
+ val bitmap = remember(attachment.data) {
+ try {
+ BitmapFactory.decodeByteArray(attachment.data, 0, attachment.data.size)
+ } catch (e: Exception) {
+ null
+ }
+ }
+ if (bitmap != null) {
+ Image(
+ bitmap = bitmap.asImageBitmap(),
+ contentDescription = "Image attachment",
+ modifier = Modifier
+ .fillMaxWidth()
+ .clip(RoundedCornerShape(14.dp))
+ .clickable { onAttachmentClick?.invoke() },
+ contentScale = ContentScale.FillWidth,
+ )
+ }
+ }
+
+ // File attachment
+ if (attachment is MessageAttachment.File) {
+ FileAttachmentView(
+ filename = attachment.filename,
+ size = attachment.size,
+ isMe = isMe,
+ onClick = onAttachmentClick,
+ )
+ }
+
+ // Message content (show only if there's text and not just an image)
+ if (message.isNotEmpty() || (attachment == null && !isDeleted)) {
+ if (attachment is MessageAttachment.Image && message.isEmpty()) {
+ // Don't show empty text for image-only messages
+ } else {
+ val textPadding = if (attachment != null) {
+ Modifier.padding(
+ start = 8.dp,
+ end = 8.dp,
+ top = if (attachment is MessageAttachment.Image) 4.dp else 0.dp,
+ )
+ } else {
+ Modifier
+ }
+ Text(
+ text = if (isDeleted) "This message was deleted" else message,
+ style = MaterialTheme.typography.bodyLarge,
+ color = if (isMe) Color.White else MaterialTheme.colorScheme.onSurface,
+ fontStyle = if (isDeleted) FontStyle.Italic else FontStyle.Normal,
+ modifier = textPadding.then(
+ if (replyPreview != null) Modifier.fillMaxWidth() else Modifier,
+ ),
+ )
+ }
+ }
+
+ // Timestamp and delivery status
+ Row(
+ modifier =
+ Modifier
+ .align(Alignment.End)
+ .padding(
+ top = 2.dp,
+ end = if (attachment is MessageAttachment.Image) 8.dp else 0.dp,
+ ),
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ Text(
+ text = timestamp,
+ style = MaterialTheme.typography.labelSmall,
+ color =
+ if (isMe) {
+ Color.White.copy(alpha = 0.7f)
+ } else {
+ MaterialTheme.colorScheme.onSurfaceVariant
+ },
+ fontSize = 11.sp,
+ )
+
+ if (isMe) {
+ Spacer(modifier = Modifier.width(4.dp))
+ when (deliveryStatus) {
+ DeliveryStatus.SENDING ->
+ Icon(
+ imageVector = Icons.Default.Schedule,
+ contentDescription = "Sending",
+ modifier = Modifier.size(12.dp),
+ tint = Color.White.copy(alpha = 0.7f),
+ )
+
+ DeliveryStatus.SENT ->
+ Icon(
+ imageVector = Icons.Default.Check,
+ contentDescription = "Sent",
+ modifier = Modifier.size(12.dp),
+ tint = Color.White.copy(alpha = 0.7f),
+ )
+
+ DeliveryStatus.FAILED ->
+ Icon(
+ imageVector = Icons.Default.Error,
+ contentDescription = "Failed",
+ modifier = Modifier.size(12.dp),
+ tint = Color.Red,
+ )
+ }
+ }
+ }
+ }
+ }
+
+ // Reactions
+ if (!reactions.isNullOrEmpty()) {
+ Row(
+ modifier = Modifier.padding(top = 4.dp),
+ horizontalArrangement = Arrangement.spacedBy(4.dp),
+ ) {
+ reactions.take(5).forEach { (emoji, count) ->
+ Surface(
+ shape = RoundedCornerShape(12.dp),
+ color = MaterialTheme.colorScheme.surfaceVariant,
+ ) {
+ Row(
+ modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ Text(text = emoji, fontSize = 14.sp)
+ if (count > 1) {
+ Spacer(modifier = Modifier.width(2.dp))
+ Text(
+ text = count.toString(),
+ style = MaterialTheme.typography.labelSmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+
+ if (!isMe) {
+ Spacer(
+ modifier =
+ Modifier
+ .weight(1f, fill = false)
+ .widthIn(min = 60.dp),
+ )
+ }
+ }
+}
+
+@Composable
+private fun FileAttachmentView(
+ filename: String,
+ size: Int,
+ isMe: Boolean,
+ onClick: (() -> Unit)? = null,
+ modifier: Modifier = Modifier,
+) {
+ Surface(
+ shape = RoundedCornerShape(12.dp),
+ color = if (isMe) Color.White.copy(alpha = 0.15f) else MaterialTheme.colorScheme.surface,
+ modifier = modifier
+ .fillMaxWidth()
+ .clickable(enabled = onClick != null) { onClick?.invoke() },
+ ) {
+ Row(
+ modifier = Modifier.padding(12.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ Icon(
+ imageVector = Icons.Default.Description,
+ contentDescription = "File",
+ modifier = Modifier.size(32.dp),
+ tint = if (isMe) Color.White else MaterialTheme.colorScheme.primary,
+ )
+ Spacer(modifier = Modifier.width(8.dp))
+ Column(modifier = Modifier.weight(1f)) {
+ Text(
+ text = filename,
+ style = MaterialTheme.typography.bodyMedium,
+ fontWeight = FontWeight.Medium,
+ color = if (isMe) Color.White else MaterialTheme.colorScheme.onSurface,
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis,
+ )
+ Text(
+ text = formatFileSize(size),
+ style = MaterialTheme.typography.bodySmall,
+ color = if (isMe) Color.White.copy(alpha = 0.7f) else MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+ }
+ }
+ }
+}
+
+private fun formatFileSize(bytes: Int): String =
+ when {
+ bytes < 1024 -> "$bytes B"
+ bytes < 1024 * 1024 -> "${bytes / 1024} KB"
+ else -> String.format(Locale.getDefault(), "%.1f MB", bytes / (1024.0 * 1024.0))
+ }
+
+@Composable
+fun SystemMessage(
+ text: String,
+ modifier: Modifier = Modifier,
+) {
+ Box(
+ modifier =
+ modifier
+ .fillMaxWidth()
+ .padding(horizontal = 16.dp, vertical = 8.dp),
+ contentAlignment = Alignment.Center,
+ ) {
+ Surface(
+ shape = RoundedCornerShape(16.dp),
+ color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.6f),
+ ) {
+ Text(
+ text = text,
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ modifier = Modifier.padding(horizontal = 12.dp, vertical = 6.dp),
+ )
+ }
+ }
+}
+
+@Preview
+@Composable
+private fun SentMessagePreview() {
+ XMTPTheme {
+ MessageBubble(
+ message = "Hello! How are you doing today?",
+ timestamp = "10:30 AM",
+ isMe = true,
+ )
+ }
+}
+
+@Preview
+@Composable
+private fun ReceivedMessagePreview() {
+ XMTPTheme {
+ MessageBubble(
+ message = "I'm doing great, thanks for asking!",
+ timestamp = "10:31 AM",
+ isMe = false,
+ senderName = "0x1234...5678",
+ senderColor = Color(0xFF5856D6),
+ )
+ }
+}
+
+@Preview
+@Composable
+private fun MessageWithReactionsPreview() {
+ XMTPTheme {
+ MessageBubble(
+ message = "This is awesome!",
+ timestamp = "10:32 AM",
+ isMe = true,
+ reactions = listOf("👍" to 3, "❤️" to 2),
+ )
+ }
+}
+
+@Preview
+@Composable
+private fun ReplyMessagePreview() {
+ XMTPTheme {
+ MessageBubble(
+ message = "Yes, I agree with you!",
+ timestamp = "10:33 AM",
+ isMe = false,
+ replyPreview = "This is the original message that was very long...",
+ replyAuthor = "0x1234...5678",
+ )
+ }
+}
+
+@Preview
+@Composable
+private fun DeletedMessagePreview() {
+ XMTPTheme {
+ MessageBubble(
+ message = "",
+ timestamp = "10:34 AM",
+ isMe = true,
+ isDeleted = true,
+ )
+ }
+}
+
+@Preview
+@Composable
+private fun FileAttachmentPreview() {
+ XMTPTheme {
+ MessageBubble(
+ message = "",
+ timestamp = "10:35 AM",
+ isMe = true,
+ attachment = MessageAttachment.File(
+ filename = "document.pdf",
+ size = 1024 * 512,
+ mimeType = "application/pdf",
+ ),
+ )
+ }
+}
+
+@Preview
+@Composable
+private fun ReceivedFileAttachmentPreview() {
+ XMTPTheme {
+ MessageBubble(
+ message = "",
+ timestamp = "10:36 AM",
+ isMe = false,
+ senderName = "0x1234...5678",
+ attachment = MessageAttachment.File(
+ filename = "presentation.pptx",
+ size = 1024 * 1024 * 2,
+ mimeType = "application/vnd.ms-powerpoint",
+ ),
+ )
+ }
+}
+
+@Preview
+@Composable
+private fun SystemMessagePreview() {
+ XMTPTheme {
+ SystemMessage(text = "Alice joined the group")
+ }
+}
diff --git a/example/src/main/java/org/xmtp/android/example/ui/components/MessageComposer.kt b/example/src/main/java/org/xmtp/android/example/ui/components/MessageComposer.kt
new file mode 100644
index 000000000..7800446bd
--- /dev/null
+++ b/example/src/main/java/org/xmtp/android/example/ui/components/MessageComposer.kt
@@ -0,0 +1,305 @@
+package org.xmtp.android.example.ui.components
+
+import androidx.compose.animation.AnimatedVisibility
+import androidx.compose.animation.slideInVertically
+import androidx.compose.animation.slideOutVertically
+import androidx.compose.foundation.background
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.heightIn
+import androidx.compose.foundation.layout.imePadding
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.layout.width
+import androidx.compose.foundation.shape.CircleShape
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.foundation.text.BasicTextField
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.automirrored.filled.Send
+import androidx.compose.material.icons.filled.Add
+import androidx.compose.material.icons.filled.Close
+import androidx.compose.material.icons.filled.EmojiEmotions
+import androidx.compose.material3.CircularProgressIndicator
+import androidx.compose.material3.Icon
+import androidx.compose.material3.IconButton
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Surface
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.graphics.SolidColor
+import androidx.compose.ui.text.TextStyle
+import androidx.compose.ui.tooling.preview.Preview
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.sp
+import org.xmtp.android.example.ui.theme.XMTPTheme
+
+@Composable
+fun MessageComposer(
+ value: String,
+ onValueChange: (String) -> Unit,
+ onSendClick: () -> Unit,
+ modifier: Modifier = Modifier,
+ isSending: Boolean = false,
+ replyTo: String? = null,
+ replyAuthor: String? = null,
+ onClearReply: () -> Unit = {},
+ onAttachmentClick: () -> Unit = {},
+ onEmojiClick: () -> Unit = {},
+) {
+ val canSend = value.isNotBlank() && !isSending
+
+ Surface(
+ modifier =
+ modifier
+ .fillMaxWidth()
+ .imePadding(),
+ color = MaterialTheme.colorScheme.surface,
+ tonalElevation = 2.dp,
+ ) {
+ Column {
+ // Reply preview
+ AnimatedVisibility(
+ visible = replyTo != null,
+ enter = slideInVertically { it },
+ exit = slideOutVertically { it },
+ ) {
+ if (replyTo != null) {
+ ReplyPreview(
+ message = replyTo,
+ author = replyAuthor,
+ onDismiss = onClearReply,
+ )
+ }
+ }
+
+ Row(
+ modifier =
+ Modifier
+ .fillMaxWidth()
+ .padding(horizontal = 8.dp, vertical = 8.dp),
+ verticalAlignment = Alignment.Bottom,
+ ) {
+ // Attachment button
+ IconButton(
+ onClick = onAttachmentClick,
+ modifier = Modifier.size(40.dp),
+ ) {
+ Icon(
+ imageVector = Icons.Default.Add,
+ contentDescription = "Add attachment",
+ tint = MaterialTheme.colorScheme.primary,
+ )
+ }
+
+ // Text input field
+ Surface(
+ modifier = Modifier.weight(1f),
+ shape = RoundedCornerShape(20.dp),
+ color = MaterialTheme.colorScheme.surfaceVariant,
+ ) {
+ Row(
+ modifier = Modifier.padding(horizontal = 4.dp),
+ verticalAlignment = Alignment.Bottom,
+ ) {
+ BasicTextField(
+ value = value,
+ onValueChange = onValueChange,
+ modifier =
+ Modifier
+ .weight(1f)
+ .heightIn(min = 40.dp, max = 120.dp)
+ .padding(horizontal = 12.dp, vertical = 10.dp),
+ textStyle =
+ TextStyle(
+ color = MaterialTheme.colorScheme.onSurface,
+ fontSize = 16.sp,
+ ),
+ cursorBrush = SolidColor(MaterialTheme.colorScheme.primary),
+ decorationBox = { innerTextField ->
+ Box {
+ if (value.isEmpty()) {
+ Text(
+ text = "Message",
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ fontSize = 16.sp,
+ )
+ }
+ innerTextField()
+ }
+ },
+ enabled = !isSending,
+ )
+
+ // Emoji button
+ IconButton(
+ onClick = onEmojiClick,
+ modifier =
+ Modifier
+ .size(36.dp)
+ .padding(bottom = 2.dp),
+ ) {
+ Icon(
+ imageVector = Icons.Default.EmojiEmotions,
+ contentDescription = "Emoji",
+ tint = MaterialTheme.colorScheme.onSurfaceVariant,
+ modifier = Modifier.size(22.dp),
+ )
+ }
+ }
+ }
+
+ Spacer(modifier = Modifier.width(8.dp))
+
+ // Send button
+ Box(
+ modifier =
+ Modifier
+ .size(40.dp)
+ .clip(CircleShape)
+ .background(
+ if (canSend) {
+ MaterialTheme.colorScheme.primary
+ } else {
+ MaterialTheme.colorScheme.surfaceVariant
+ },
+ ).clickable(enabled = canSend) { onSendClick() },
+ contentAlignment = Alignment.Center,
+ ) {
+ if (isSending) {
+ CircularProgressIndicator(
+ modifier = Modifier.size(20.dp),
+ color = Color.White,
+ strokeWidth = 2.dp,
+ )
+ } else {
+ Icon(
+ imageVector = Icons.AutoMirrored.Filled.Send,
+ contentDescription = "Send",
+ tint = Color.White,
+ modifier = Modifier.size(20.dp),
+ )
+ }
+ }
+ }
+ }
+ }
+}
+
+@Composable
+private fun ReplyPreview(
+ message: String,
+ author: String?,
+ onDismiss: () -> Unit,
+ modifier: Modifier = Modifier,
+) {
+ Row(
+ modifier =
+ modifier
+ .fillMaxWidth()
+ .background(MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f))
+ .padding(horizontal = 12.dp, vertical = 8.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ // Left accent bar
+ Box(
+ modifier =
+ Modifier
+ .width(2.dp)
+ .height(32.dp)
+ .background(MaterialTheme.colorScheme.primary),
+ )
+
+ Spacer(modifier = Modifier.width(8.dp))
+
+ Column(modifier = Modifier.weight(1f)) {
+ Text(
+ text = "Replying to ${author ?: "message"}",
+ style = MaterialTheme.typography.labelSmall,
+ color = MaterialTheme.colorScheme.primary,
+ )
+ Text(
+ text = message,
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ maxLines = 1,
+ )
+ }
+
+ IconButton(
+ onClick = onDismiss,
+ modifier = Modifier.size(24.dp),
+ ) {
+ Icon(
+ imageVector = Icons.Default.Close,
+ contentDescription = "Cancel reply",
+ tint = MaterialTheme.colorScheme.onSurfaceVariant,
+ modifier = Modifier.size(16.dp),
+ )
+ }
+ }
+}
+
+@Preview
+@Composable
+private fun MessageComposerPreview() {
+ XMTPTheme {
+ var text by remember { mutableStateOf("") }
+ MessageComposer(
+ value = text,
+ onValueChange = { text = it },
+ onSendClick = {},
+ )
+ }
+}
+
+@Preview
+@Composable
+private fun MessageComposerWithTextPreview() {
+ XMTPTheme {
+ MessageComposer(
+ value = "Hello, this is a test message!",
+ onValueChange = {},
+ onSendClick = {},
+ )
+ }
+}
+
+@Preview
+@Composable
+private fun MessageComposerSendingPreview() {
+ XMTPTheme {
+ MessageComposer(
+ value = "Sending message...",
+ onValueChange = {},
+ onSendClick = {},
+ isSending = true,
+ )
+ }
+}
+
+@Preview
+@Composable
+private fun MessageComposerWithReplyPreview() {
+ XMTPTheme {
+ MessageComposer(
+ value = "",
+ onValueChange = {},
+ onSendClick = {},
+ replyTo = "This is the original message content",
+ replyAuthor = "0x1234...5678",
+ )
+ }
+}
diff --git a/example/src/main/java/org/xmtp/android/example/ui/components/NavigationDrawer.kt b/example/src/main/java/org/xmtp/android/example/ui/components/NavigationDrawer.kt
new file mode 100644
index 000000000..2eba0bae0
--- /dev/null
+++ b/example/src/main/java/org/xmtp/android/example/ui/components/NavigationDrawer.kt
@@ -0,0 +1,287 @@
+package org.xmtp.android.example.ui.components
+
+import androidx.compose.foundation.background
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.layout.statusBarsPadding
+import androidx.compose.foundation.shape.CircleShape
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.automirrored.filled.Logout
+import androidx.compose.material.icons.filled.Add
+import androidx.compose.material.icons.filled.BugReport
+import androidx.compose.material.icons.filled.ContentCopy
+import androidx.compose.material.icons.filled.Description
+import androidx.compose.material.icons.filled.GroupAdd
+import androidx.compose.material.icons.filled.Info
+import androidx.compose.material.icons.filled.Person
+import androidx.compose.material.icons.filled.VisibilityOff
+import androidx.compose.material3.HorizontalDivider
+import androidx.compose.material3.Icon
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.ModalDrawerSheet
+import androidx.compose.material3.NavigationDrawerItem
+import androidx.compose.material3.NavigationDrawerItemDefaults
+import androidx.compose.material3.Switch
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.graphics.vector.ImageVector
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.text.style.TextOverflow
+import androidx.compose.ui.tooling.preview.Preview
+import androidx.compose.ui.unit.dp
+import org.xmtp.android.example.ui.theme.XMTPTheme
+
+data class DrawerState(
+ val walletAddress: String,
+ val environment: String,
+ val isLogsEnabled: Boolean,
+ val hideDeletedMessages: Boolean,
+)
+
+@Composable
+fun NavigationDrawerContent(
+ drawerState: DrawerState,
+ onWalletInfoClick: () -> Unit,
+ onNewConversationClick: () -> Unit,
+ onNewGroupClick: () -> Unit,
+ onHideDeletedMessagesToggle: (Boolean) -> Unit,
+ onViewLogsClick: () -> Unit,
+ onToggleLogsClick: (Boolean) -> Unit,
+ onCopyAddressClick: () -> Unit,
+ onDisconnectClick: () -> Unit,
+ modifier: Modifier = Modifier,
+) {
+ ModalDrawerSheet(modifier = modifier) {
+ // Header
+ DrawerHeader(
+ walletAddress = drawerState.walletAddress,
+ environment = drawerState.environment,
+ )
+
+ Spacer(modifier = Modifier.height(8.dp))
+
+ // Main actions
+ DrawerItem(
+ label = "Wallet Info",
+ icon = Icons.Default.Info,
+ onClick = onWalletInfoClick,
+ )
+
+ DrawerItem(
+ label = "New Conversation",
+ icon = Icons.Default.Add,
+ onClick = onNewConversationClick,
+ )
+
+ DrawerItem(
+ label = "New Group",
+ icon = Icons.Default.GroupAdd,
+ onClick = onNewGroupClick,
+ )
+
+ HorizontalDivider(modifier = Modifier.padding(vertical = 8.dp))
+
+ // Settings section
+ Text(
+ text = "Settings",
+ style = MaterialTheme.typography.labelMedium,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ modifier = Modifier.padding(horizontal = 28.dp, vertical = 8.dp),
+ )
+
+ DrawerToggleItem(
+ label = "Hide Deleted Messages",
+ icon = Icons.Default.VisibilityOff,
+ checked = drawerState.hideDeletedMessages,
+ onCheckedChange = onHideDeletedMessagesToggle,
+ )
+
+ HorizontalDivider(modifier = Modifier.padding(vertical = 8.dp))
+
+ // Logs section
+ Text(
+ text = "Logs",
+ style = MaterialTheme.typography.labelMedium,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ modifier = Modifier.padding(horizontal = 28.dp, vertical = 8.dp),
+ )
+
+ DrawerItem(
+ label = "View Logs",
+ icon = Icons.Default.Description,
+ onClick = onViewLogsClick,
+ )
+
+ DrawerToggleItem(
+ label = "Persistent Logs",
+ icon = Icons.Default.BugReport,
+ checked = drawerState.isLogsEnabled,
+ onCheckedChange = onToggleLogsClick,
+ )
+
+ HorizontalDivider(modifier = Modifier.padding(vertical = 8.dp))
+
+ // Account section
+ Text(
+ text = "Account",
+ style = MaterialTheme.typography.labelMedium,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ modifier = Modifier.padding(horizontal = 28.dp, vertical = 8.dp),
+ )
+
+ DrawerItem(
+ label = "Copy Address",
+ icon = Icons.Default.ContentCopy,
+ onClick = onCopyAddressClick,
+ )
+
+ DrawerItem(
+ label = "Disconnect Wallet",
+ icon = Icons.AutoMirrored.Filled.Logout,
+ onClick = onDisconnectClick,
+ tint = MaterialTheme.colorScheme.error,
+ )
+ }
+}
+
+@Composable
+private fun DrawerHeader(
+ walletAddress: String,
+ environment: String,
+ modifier: Modifier = Modifier,
+) {
+ Box(
+ modifier = modifier
+ .fillMaxWidth()
+ .background(MaterialTheme.colorScheme.primary)
+ .statusBarsPadding()
+ .padding(16.dp),
+ ) {
+ Column {
+ // Avatar
+ Box(
+ modifier = Modifier
+ .size(64.dp)
+ .clip(CircleShape)
+ .background(Color.White.copy(alpha = 0.2f)),
+ contentAlignment = Alignment.Center,
+ ) {
+ Icon(
+ imageVector = Icons.Default.Person,
+ contentDescription = "Profile",
+ modifier = Modifier.size(36.dp),
+ tint = Color.White,
+ )
+ }
+
+ Spacer(modifier = Modifier.height(12.dp))
+
+ // Wallet address
+ Text(
+ text = walletAddress,
+ style = MaterialTheme.typography.titleSmall,
+ fontWeight = FontWeight.Bold,
+ color = Color.White,
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis,
+ )
+
+ Spacer(modifier = Modifier.height(4.dp))
+
+ // Environment
+ Text(
+ text = environment,
+ style = MaterialTheme.typography.bodySmall,
+ color = Color.White.copy(alpha = 0.8f),
+ )
+ }
+ }
+}
+
+@Composable
+private fun DrawerItem(
+ label: String,
+ icon: ImageVector,
+ onClick: () -> Unit,
+ modifier: Modifier = Modifier,
+ tint: Color = MaterialTheme.colorScheme.onSurfaceVariant,
+) {
+ NavigationDrawerItem(
+ icon = {
+ Icon(
+ imageVector = icon,
+ contentDescription = label,
+ tint = tint,
+ )
+ },
+ label = {
+ Text(
+ text = label,
+ color = if (tint == MaterialTheme.colorScheme.error) tint else MaterialTheme.colorScheme.onSurface,
+ )
+ },
+ selected = false,
+ onClick = onClick,
+ modifier = modifier.padding(NavigationDrawerItemDefaults.ItemPadding),
+ )
+}
+
+@Composable
+private fun DrawerToggleItem(
+ label: String,
+ icon: ImageVector,
+ checked: Boolean,
+ onCheckedChange: (Boolean) -> Unit,
+ modifier: Modifier = Modifier,
+) {
+ NavigationDrawerItem(
+ icon = {
+ Icon(
+ imageVector = icon,
+ contentDescription = label,
+ )
+ },
+ label = { Text(label) },
+ badge = {
+ Switch(
+ checked = checked,
+ onCheckedChange = onCheckedChange,
+ )
+ },
+ selected = false,
+ onClick = { onCheckedChange(!checked) },
+ modifier = modifier.padding(NavigationDrawerItemDefaults.ItemPadding),
+ )
+}
+
+@Preview(showBackground = true)
+@Composable
+private fun NavigationDrawerContentPreview() {
+ XMTPTheme {
+ NavigationDrawerContent(
+ drawerState = DrawerState(
+ walletAddress = "0x1234567890abcdef1234567890abcdef12345678",
+ environment = "DEV",
+ isLogsEnabled = false,
+ hideDeletedMessages = false,
+ ),
+ onWalletInfoClick = {},
+ onNewConversationClick = {},
+ onNewGroupClick = {},
+ onHideDeletedMessagesToggle = {},
+ onViewLogsClick = {},
+ onToggleLogsClick = {},
+ onCopyAddressClick = {},
+ onDisconnectClick = {},
+ )
+ }
+}
diff --git a/example/src/main/java/org/xmtp/android/example/ui/components/ReplyPreviewBar.kt b/example/src/main/java/org/xmtp/android/example/ui/components/ReplyPreviewBar.kt
new file mode 100644
index 000000000..e5add20c6
--- /dev/null
+++ b/example/src/main/java/org/xmtp/android/example/ui/components/ReplyPreviewBar.kt
@@ -0,0 +1,160 @@
+package org.xmtp.android.example.ui.components
+
+import android.graphics.Bitmap
+import androidx.compose.foundation.Image
+import androidx.compose.foundation.background
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.layout.width
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.automirrored.filled.Reply
+import androidx.compose.material.icons.filled.Close
+import androidx.compose.material3.Icon
+import androidx.compose.material3.IconButton
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Surface
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.graphics.asImageBitmap
+import androidx.compose.ui.layout.ContentScale
+import androidx.compose.ui.text.style.TextOverflow
+import androidx.compose.ui.tooling.preview.Preview
+import androidx.compose.ui.unit.dp
+import org.xmtp.android.example.ui.theme.XMTPTheme
+
+data class ReplyPreviewData(
+ val authorName: String,
+ val messageText: String,
+ val imageThumbnail: Bitmap? = null,
+)
+
+@Composable
+fun ReplyPreviewBar(
+ replyData: ReplyPreviewData,
+ onClose: () -> Unit,
+ modifier: Modifier = Modifier,
+) {
+ Surface(
+ modifier = modifier.fillMaxWidth(),
+ color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f),
+ ) {
+ Row(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(horizontal = 8.dp, vertical = 8.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ // Reply icon with accent bar
+ Row(
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ Box(
+ modifier = Modifier
+ .width(3.dp)
+ .height(40.dp)
+ .background(
+ MaterialTheme.colorScheme.primary,
+ RoundedCornerShape(2.dp),
+ ),
+ )
+
+ Spacer(modifier = Modifier.width(8.dp))
+
+ Icon(
+ imageVector = Icons.AutoMirrored.Filled.Reply,
+ contentDescription = "Replying to",
+ tint = MaterialTheme.colorScheme.primary,
+ modifier = Modifier.size(20.dp),
+ )
+ }
+
+ Spacer(modifier = Modifier.width(8.dp))
+
+ // Reply content
+ Column(
+ modifier = Modifier.weight(1f),
+ ) {
+ Text(
+ text = replyData.authorName,
+ style = MaterialTheme.typography.labelMedium,
+ color = MaterialTheme.colorScheme.primary,
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis,
+ )
+
+ Text(
+ text = replyData.messageText,
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis,
+ )
+ }
+
+ // Image thumbnail if present
+ replyData.imageThumbnail?.let { bitmap ->
+ Spacer(modifier = Modifier.width(8.dp))
+ Image(
+ bitmap = bitmap.asImageBitmap(),
+ contentDescription = "Image preview",
+ modifier = Modifier
+ .size(40.dp)
+ .clip(RoundedCornerShape(4.dp)),
+ contentScale = ContentScale.Crop,
+ )
+ }
+
+ // Close button
+ IconButton(
+ onClick = onClose,
+ modifier = Modifier.size(32.dp),
+ ) {
+ Icon(
+ imageVector = Icons.Default.Close,
+ contentDescription = "Cancel reply",
+ tint = MaterialTheme.colorScheme.onSurfaceVariant,
+ modifier = Modifier.size(20.dp),
+ )
+ }
+ }
+ }
+}
+
+@Preview(showBackground = true)
+@Composable
+private fun ReplyPreviewBarPreview() {
+ XMTPTheme {
+ ReplyPreviewBar(
+ replyData = ReplyPreviewData(
+ authorName = "0x1234...5678",
+ messageText = "Hey, did you see the new update?",
+ ),
+ onClose = {},
+ )
+ }
+}
+
+@Preview(showBackground = true)
+@Composable
+private fun ReplyPreviewBarPhotoPreview() {
+ XMTPTheme {
+ ReplyPreviewBar(
+ replyData = ReplyPreviewData(
+ authorName = "Alice",
+ messageText = "Photo",
+ imageThumbnail = null, // In real usage, this would be a Bitmap
+ ),
+ onClose = {},
+ )
+ }
+}
diff --git a/example/src/main/java/org/xmtp/android/example/ui/components/SearchBar.kt b/example/src/main/java/org/xmtp/android/example/ui/components/SearchBar.kt
new file mode 100644
index 000000000..600b5887e
--- /dev/null
+++ b/example/src/main/java/org/xmtp/android/example/ui/components/SearchBar.kt
@@ -0,0 +1,375 @@
+package org.xmtp.android.example.ui.components
+
+import androidx.compose.animation.AnimatedVisibility
+import androidx.compose.animation.fadeIn
+import androidx.compose.animation.fadeOut
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.layout.width
+import androidx.compose.foundation.lazy.LazyColumn
+import androidx.compose.foundation.lazy.items
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.foundation.text.BasicTextField
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.Close
+import androidx.compose.material.icons.filled.Search
+import androidx.compose.material3.HorizontalDivider
+import androidx.compose.material3.Icon
+import androidx.compose.material3.IconButton
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Surface
+import androidx.compose.material3.Text
+import androidx.compose.material3.TextButton
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.focus.FocusRequester
+import androidx.compose.ui.focus.focusRequester
+import androidx.compose.ui.graphics.SolidColor
+import androidx.compose.ui.text.SpanStyle
+import androidx.compose.ui.text.TextStyle
+import androidx.compose.ui.text.buildAnnotatedString
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.text.style.TextOverflow
+import androidx.compose.ui.text.withStyle
+import androidx.compose.ui.tooling.preview.Preview
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.sp
+import org.xmtp.android.example.ui.theme.XMTPTheme
+
+data class SearchResult(
+ val id: String,
+ val senderName: String,
+ val content: String,
+ val timestamp: String,
+ val isDeleted: Boolean = false,
+)
+
+@Composable
+fun MessageSearchBar(
+ searchText: String,
+ onSearchTextChange: (String) -> Unit,
+ isSearching: Boolean,
+ onSearchToggle: (Boolean) -> Unit,
+ modifier: Modifier = Modifier,
+) {
+ val focusRequester = remember { FocusRequester() }
+
+ AnimatedVisibility(
+ visible = isSearching,
+ enter = fadeIn(),
+ exit = fadeOut(),
+ ) {
+ Surface(
+ modifier = modifier.fillMaxWidth(),
+ color = MaterialTheme.colorScheme.surface,
+ tonalElevation = 2.dp,
+ ) {
+ Row(
+ modifier =
+ Modifier
+ .fillMaxWidth()
+ .padding(horizontal = 12.dp, vertical = 8.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ // Search input field
+ Surface(
+ modifier = Modifier.weight(1f),
+ shape = RoundedCornerShape(20.dp),
+ color = MaterialTheme.colorScheme.surfaceVariant,
+ ) {
+ Row(
+ modifier = Modifier.padding(horizontal = 12.dp, vertical = 8.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ Icon(
+ imageVector = Icons.Default.Search,
+ contentDescription = "Search",
+ modifier = Modifier.size(20.dp),
+ tint = MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+
+ Spacer(modifier = Modifier.width(8.dp))
+
+ BasicTextField(
+ value = searchText,
+ onValueChange = onSearchTextChange,
+ modifier =
+ Modifier
+ .weight(1f)
+ .focusRequester(focusRequester),
+ textStyle =
+ TextStyle(
+ color = MaterialTheme.colorScheme.onSurface,
+ fontSize = 16.sp,
+ ),
+ cursorBrush = SolidColor(MaterialTheme.colorScheme.primary),
+ singleLine = true,
+ decorationBox = { innerTextField ->
+ Box {
+ if (searchText.isEmpty()) {
+ Text(
+ text = "Search messages",
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ fontSize = 16.sp,
+ )
+ }
+ innerTextField()
+ }
+ },
+ )
+
+ if (searchText.isNotEmpty()) {
+ IconButton(
+ onClick = { onSearchTextChange("") },
+ modifier = Modifier.size(20.dp),
+ ) {
+ Icon(
+ imageVector = Icons.Default.Close,
+ contentDescription = "Clear",
+ modifier = Modifier.size(16.dp),
+ tint = MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+ }
+ }
+ }
+ }
+
+ Spacer(modifier = Modifier.width(8.dp))
+
+ TextButton(onClick = {
+ onSearchTextChange("")
+ onSearchToggle(false)
+ }) {
+ Text("Cancel")
+ }
+ }
+ }
+ }
+}
+
+@Composable
+fun SearchResultsList(
+ searchText: String,
+ results: List,
+ onResultClick: (String) -> Unit,
+ modifier: Modifier = Modifier,
+) {
+ if (searchText.isEmpty()) {
+ return
+ }
+
+ Surface(
+ modifier = modifier.fillMaxSize(),
+ color = MaterialTheme.colorScheme.background,
+ ) {
+ if (results.isEmpty()) {
+ // Empty state
+ Column(
+ modifier = Modifier.fillMaxSize(),
+ horizontalAlignment = Alignment.CenterHorizontally,
+ verticalArrangement = Arrangement.Center,
+ ) {
+ Icon(
+ imageVector = Icons.Default.Search,
+ contentDescription = null,
+ modifier = Modifier.size(48.dp),
+ tint = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f),
+ )
+ Spacer(modifier = Modifier.height(16.dp))
+ Text(
+ text = "No results found",
+ style = MaterialTheme.typography.titleMedium,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+ Spacer(modifier = Modifier.height(4.dp))
+ Text(
+ text = "Try searching for different keywords",
+ style = MaterialTheme.typography.bodyMedium,
+ color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f),
+ )
+ }
+ } else {
+ LazyColumn(
+ modifier = Modifier.fillMaxSize(),
+ ) {
+ items(
+ items = results,
+ key = { it.id },
+ ) { result ->
+ SearchResultRow(
+ result = result,
+ searchText = searchText,
+ onClick = { onResultClick(result.id) },
+ )
+ HorizontalDivider(
+ modifier = Modifier.padding(start = 72.dp),
+ color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f),
+ )
+ }
+ }
+ }
+ }
+}
+
+@Composable
+private fun SearchResultRow(
+ result: SearchResult,
+ searchText: String,
+ onClick: () -> Unit,
+) {
+ Row(
+ modifier =
+ Modifier
+ .fillMaxWidth()
+ .clickable(onClick = onClick)
+ .padding(horizontal = 16.dp, vertical = 12.dp),
+ verticalAlignment = Alignment.Top,
+ ) {
+ // Avatar
+ Avatar(name = result.senderName, size = 44.dp)
+
+ Spacer(modifier = Modifier.width(12.dp))
+
+ Column(modifier = Modifier.weight(1f)) {
+ // Sender and timestamp row
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.SpaceBetween,
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ Text(
+ text = result.senderName,
+ style = MaterialTheme.typography.titleSmall,
+ fontWeight = FontWeight.SemiBold,
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis,
+ modifier = Modifier.weight(1f),
+ )
+
+ Text(
+ text = result.timestamp,
+ style = MaterialTheme.typography.labelSmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+ }
+
+ Spacer(modifier = Modifier.height(4.dp))
+
+ // Highlighted content
+ Text(
+ text = highlightSearchTerm(result.content, searchText),
+ style = MaterialTheme.typography.bodyMedium,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ maxLines = 2,
+ overflow = TextOverflow.Ellipsis,
+ )
+ }
+ }
+}
+
+@Composable
+private fun highlightSearchTerm(
+ text: String,
+ searchTerm: String,
+) = buildAnnotatedString {
+ if (searchTerm.isEmpty()) {
+ append(text)
+ return@buildAnnotatedString
+ }
+
+ val lowercaseText = text.lowercase()
+ val lowercaseSearch = searchTerm.lowercase()
+ var currentIndex = 0
+
+ while (currentIndex < text.length) {
+ val matchIndex = lowercaseText.indexOf(lowercaseSearch, currentIndex)
+ if (matchIndex == -1) {
+ append(text.substring(currentIndex))
+ break
+ }
+
+ // Add text before match
+ if (matchIndex > currentIndex) {
+ append(text.substring(currentIndex, matchIndex))
+ }
+
+ // Add highlighted match
+ withStyle(
+ style =
+ SpanStyle(
+ fontWeight = FontWeight.Bold,
+ color = MaterialTheme.colorScheme.primary,
+ ),
+ ) {
+ append(text.substring(matchIndex, matchIndex + searchTerm.length))
+ }
+
+ currentIndex = matchIndex + searchTerm.length
+ }
+}
+
+@Preview
+@Composable
+private fun SearchBarPreview() {
+ XMTPTheme {
+ var searchText by remember { mutableStateOf("") }
+ MessageSearchBar(
+ searchText = searchText,
+ onSearchTextChange = { searchText = it },
+ isSearching = true,
+ onSearchToggle = {},
+ )
+ }
+}
+
+@Preview
+@Composable
+private fun SearchResultsPreview() {
+ XMTPTheme {
+ SearchResultsList(
+ searchText = "hello",
+ results =
+ listOf(
+ SearchResult(
+ id = "1",
+ senderName = "0x1234...5678",
+ content = "Hello! How are you doing today?",
+ timestamp = "10:30 AM",
+ ),
+ SearchResult(
+ id = "2",
+ senderName = "Alice",
+ content = "Just wanted to say hello and check in",
+ timestamp = "Yesterday",
+ ),
+ ),
+ onResultClick = {},
+ )
+ }
+}
+
+@Preview
+@Composable
+private fun EmptySearchResultsPreview() {
+ XMTPTheme {
+ SearchResultsList(
+ searchText = "xyz",
+ results = emptyList(),
+ onResultClick = {},
+ )
+ }
+}
diff --git a/example/src/main/java/org/xmtp/android/example/ui/dialogs/DeleteConfirmationDialog.kt b/example/src/main/java/org/xmtp/android/example/ui/dialogs/DeleteConfirmationDialog.kt
new file mode 100644
index 000000000..b52c0ec2c
--- /dev/null
+++ b/example/src/main/java/org/xmtp/android/example/ui/dialogs/DeleteConfirmationDialog.kt
@@ -0,0 +1,62 @@
+package org.xmtp.android.example.ui.dialogs
+
+import androidx.compose.material3.AlertDialog
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Text
+import androidx.compose.material3.TextButton
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.tooling.preview.Preview
+import org.xmtp.android.example.ui.theme.XMTPTheme
+
+@Composable
+fun DeleteConfirmationDialog(
+ onConfirm: () -> Unit,
+ onDismiss: () -> Unit,
+ modifier: Modifier = Modifier,
+ title: String = "Delete Message",
+ message: String = "Are you sure you want to delete this message?",
+) {
+ AlertDialog(
+ onDismissRequest = onDismiss,
+ modifier = modifier,
+ title = {
+ Text(
+ text = title,
+ style = MaterialTheme.typography.headlineSmall,
+ )
+ },
+ text = {
+ Text(
+ text = message,
+ style = MaterialTheme.typography.bodyMedium,
+ )
+ },
+ confirmButton = {
+ TextButton(
+ onClick = onConfirm,
+ ) {
+ Text(
+ text = "Delete",
+ color = MaterialTheme.colorScheme.error,
+ )
+ }
+ },
+ dismissButton = {
+ TextButton(onClick = onDismiss) {
+ Text(text = "Cancel")
+ }
+ },
+ )
+}
+
+@Preview(showBackground = true)
+@Composable
+private fun DeleteConfirmationDialogPreview() {
+ XMTPTheme {
+ DeleteConfirmationDialog(
+ onConfirm = {},
+ onDismiss = {},
+ )
+ }
+}
diff --git a/example/src/main/java/org/xmtp/android/example/ui/navigation/AppNavigation.kt b/example/src/main/java/org/xmtp/android/example/ui/navigation/AppNavigation.kt
new file mode 100644
index 000000000..3c3744455
--- /dev/null
+++ b/example/src/main/java/org/xmtp/android/example/ui/navigation/AppNavigation.kt
@@ -0,0 +1,126 @@
+package org.xmtp.android.example.ui.navigation
+
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+import androidx.navigation.NavHostController
+import androidx.navigation.NavType
+import androidx.navigation.compose.NavHost
+import androidx.navigation.compose.composable
+import androidx.navigation.compose.rememberNavController
+import androidx.navigation.navArgument
+import org.xmtp.android.example.connect.ConnectWalletViewModel
+import org.xmtp.android.example.ui.components.DrawerState
+import org.xmtp.android.example.ui.screens.ChatScreen
+import org.xmtp.android.example.ui.screens.ConnectWalletScreen
+import org.xmtp.android.example.ui.screens.ConversationItem
+import org.xmtp.android.example.ui.screens.MainScreen
+import org.xmtp.android.example.ui.screens.MessageItem
+import org.xmtp.android.library.XMTPEnvironment
+import uniffi.xmtpv3.FfiLogLevel
+
+sealed class Screen(
+ val route: String,
+) {
+ object ConnectWallet : Screen("connect")
+
+ object Home : Screen("home")
+
+ object Chat : Screen("chat/{conversationId}") {
+ fun createRoute(conversationId: String) = "chat/$conversationId"
+ }
+}
+
+@Composable
+fun AppNavigation(
+ navController: NavHostController = rememberNavController(),
+ startDestination: String = Screen.Home.route,
+ modifier: Modifier = Modifier,
+ // Connect wallet state
+ connectWalletUiState: ConnectWalletViewModel.ConnectUiState = ConnectWalletViewModel.ConnectUiState.Unknown,
+ onGenerateWallet: (XMTPEnvironment, FfiLogLevel?) -> Unit = { _, _ -> },
+ onConnectSuccess: (String) -> Unit = {},
+ // Main screen state
+ conversations: List = emptyList(),
+ isLoading: Boolean = false,
+ drawerState: DrawerState = DrawerState(
+ walletAddress = "",
+ environment = "",
+ isLogsEnabled = false,
+ hideDeletedMessages = false,
+ ),
+ // Main screen drawer actions
+ onNewConversationClick: () -> Unit = {},
+ onSearchClick: () -> Unit = {},
+ onWalletInfoClick: () -> Unit = {},
+ onNewGroupClick: () -> Unit = {},
+ onHideDeletedMessagesToggle: (Boolean) -> Unit = {},
+ onViewLogsClick: () -> Unit = {},
+ onToggleLogsClick: (Boolean) -> Unit = {},
+ onCopyAddressClick: () -> Unit = {},
+ onDisconnectClick: () -> Unit = {},
+ // Chat screen state
+ messages: Map> = emptyMap(),
+ onSendMessage: (String, String) -> Unit = { _, _ -> },
+) {
+ NavHost(
+ navController = navController,
+ startDestination = startDestination,
+ modifier = modifier,
+ ) {
+ composable(Screen.ConnectWallet.route) {
+ ConnectWalletScreen(
+ uiState = connectWalletUiState,
+ onGenerateWallet = onGenerateWallet,
+ onConnectSuccess = { address ->
+ onConnectSuccess(address)
+ navController.navigate(Screen.Home.route) {
+ popUpTo(Screen.ConnectWallet.route) { inclusive = true }
+ }
+ },
+ )
+ }
+
+ composable(Screen.Home.route) {
+ MainScreen(
+ conversations = conversations,
+ drawerState = drawerState,
+ isLoading = isLoading,
+ onConversationClick = { conversationId ->
+ navController.navigate(Screen.Chat.createRoute(conversationId))
+ },
+ onNewConversationClick = onNewConversationClick,
+ onSearchClick = onSearchClick,
+ onWalletInfoClick = onWalletInfoClick,
+ onNewGroupClick = onNewGroupClick,
+ onHideDeletedMessagesToggle = onHideDeletedMessagesToggle,
+ onViewLogsClick = onViewLogsClick,
+ onToggleLogsClick = onToggleLogsClick,
+ onCopyAddressClick = onCopyAddressClick,
+ onDisconnectClick = onDisconnectClick,
+ )
+ }
+
+ composable(
+ route = Screen.Chat.route,
+ arguments =
+ listOf(
+ navArgument("conversationId") { type = NavType.StringType },
+ ),
+ ) { backStackEntry ->
+ val conversationId = backStackEntry.arguments?.getString("conversationId") ?: ""
+ val conversation = conversations.find { it.id == conversationId }
+ val conversationMessages = messages[conversationId] ?: emptyList()
+
+ ChatScreen(
+ conversationName = conversation?.name ?: "Conversation",
+ messages = conversationMessages,
+ isGroup = conversation?.isGroup ?: false,
+ memberCount = conversation?.memberCount,
+ onBackClick = { navController.popBackStack() },
+ onSendMessage = { message ->
+ onSendMessage(conversationId, message)
+ },
+ )
+ }
+ }
+}
diff --git a/example/src/main/java/org/xmtp/android/example/ui/screens/AttachmentPreviewScreen.kt b/example/src/main/java/org/xmtp/android/example/ui/screens/AttachmentPreviewScreen.kt
new file mode 100644
index 000000000..6c4e13660
--- /dev/null
+++ b/example/src/main/java/org/xmtp/android/example/ui/screens/AttachmentPreviewScreen.kt
@@ -0,0 +1,451 @@
+package org.xmtp.android.example.ui.screens
+
+import android.graphics.Bitmap
+import android.net.Uri
+import androidx.compose.foundation.Image
+import androidx.compose.foundation.background
+import androidx.compose.foundation.border
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.layout.width
+import androidx.compose.foundation.lazy.LazyRow
+import androidx.compose.foundation.lazy.itemsIndexed
+import androidx.compose.foundation.pager.HorizontalPager
+import androidx.compose.foundation.pager.rememberPagerState
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.automirrored.filled.Send
+import androidx.compose.material.icons.filled.AttachFile
+import androidx.compose.material.icons.filled.Close
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.Icon
+import androidx.compose.material3.IconButton
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.OutlinedTextField
+import androidx.compose.material3.OutlinedTextFieldDefaults
+import androidx.compose.material3.Scaffold
+import androidx.compose.material3.Text
+import androidx.compose.material3.TopAppBar
+import androidx.compose.material3.TopAppBarDefaults
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.graphics.asImageBitmap
+import androidx.compose.ui.layout.ContentScale
+import androidx.compose.ui.text.style.TextAlign
+import androidx.compose.ui.text.style.TextOverflow
+import androidx.compose.ui.tooling.preview.Preview
+import androidx.compose.ui.unit.dp
+import org.xmtp.android.example.ui.theme.XMTPTheme
+
+data class AttachmentPreviewItem(
+ val uri: Uri,
+ val filename: String,
+ val fileSize: String,
+ val mimeType: String,
+ val thumbnail: Bitmap? = null,
+ val isImage: Boolean = false,
+)
+
+@OptIn(ExperimentalMaterial3Api::class, androidx.compose.foundation.ExperimentalFoundationApi::class)
+@Composable
+fun AttachmentPreviewScreen(
+ attachments: List,
+ caption: String = "",
+ selectedIndex: Int = 0,
+ onCaptionChange: (String) -> Unit = {},
+ onSelectedIndexChange: (Int) -> Unit = {},
+ onClose: () -> Unit = {},
+ onSend: () -> Unit = {},
+ modifier: Modifier = Modifier,
+) {
+ val pagerState = rememberPagerState(
+ initialPage = selectedIndex,
+ pageCount = { attachments.size },
+ )
+
+ // Sync pager with selected index from parent
+ LaunchedEffect(pagerState.currentPage) {
+ if (pagerState.currentPage != selectedIndex) {
+ onSelectedIndexChange(pagerState.currentPage)
+ }
+ }
+
+ LaunchedEffect(selectedIndex) {
+ if (pagerState.currentPage != selectedIndex) {
+ pagerState.animateScrollToPage(selectedIndex)
+ }
+ }
+
+ Scaffold(
+ modifier = modifier.fillMaxSize(),
+ containerColor = Color.Black,
+ topBar = {
+ TopAppBar(
+ title = {
+ if (attachments.size > 1) {
+ Text(
+ text = "${selectedIndex + 1} of ${attachments.size}",
+ color = Color.White,
+ textAlign = TextAlign.Center,
+ modifier = Modifier.fillMaxWidth(),
+ )
+ } else {
+ Text(
+ text = "Preview",
+ color = Color.White,
+ textAlign = TextAlign.Center,
+ modifier = Modifier.fillMaxWidth(),
+ )
+ }
+ },
+ navigationIcon = {
+ IconButton(onClick = onClose) {
+ Icon(
+ imageVector = Icons.Default.Close,
+ contentDescription = "Close",
+ tint = Color.White,
+ )
+ }
+ },
+ actions = {
+ IconButton(onClick = onSend) {
+ Icon(
+ imageVector = Icons.AutoMirrored.Filled.Send,
+ contentDescription = "Send",
+ tint = MaterialTheme.colorScheme.primary,
+ )
+ }
+ },
+ colors = TopAppBarDefaults.topAppBarColors(
+ containerColor = Color.Black.copy(alpha = 0.5f),
+ ),
+ )
+ },
+ ) { paddingValues ->
+ Column(
+ modifier = Modifier
+ .fillMaxSize()
+ .padding(paddingValues),
+ ) {
+ // Main content area
+ Box(
+ modifier = Modifier
+ .weight(1f)
+ .fillMaxWidth(),
+ contentAlignment = Alignment.Center,
+ ) {
+ if (attachments.size == 1) {
+ // Single attachment
+ AttachmentContent(
+ attachment = attachments.first(),
+ modifier = Modifier.fillMaxSize(),
+ )
+ } else {
+ // Multiple attachments - pager
+ HorizontalPager(
+ state = pagerState,
+ modifier = Modifier.fillMaxSize(),
+ ) { page ->
+ AttachmentContent(
+ attachment = attachments[page],
+ modifier = Modifier.fillMaxSize(),
+ )
+ }
+ }
+ }
+
+ // Caption input
+ CaptionInput(
+ caption = caption,
+ onCaptionChange = onCaptionChange,
+ modifier = Modifier
+ .fillMaxWidth()
+ .background(Color.Black.copy(alpha = 0.5f))
+ .padding(12.dp),
+ )
+
+ // Thumbnail strip for multiple attachments
+ if (attachments.size > 1) {
+ ThumbnailStrip(
+ attachments = attachments,
+ selectedIndex = selectedIndex,
+ onThumbnailClick = { index -> onSelectedIndexChange(index) },
+ modifier = Modifier
+ .fillMaxWidth()
+ .background(Color.Black.copy(alpha = 0.5f))
+ .padding(8.dp),
+ )
+ }
+ }
+ }
+}
+
+@Composable
+private fun AttachmentContent(
+ attachment: AttachmentPreviewItem,
+ modifier: Modifier = Modifier,
+) {
+ Box(
+ modifier = modifier,
+ contentAlignment = Alignment.Center,
+ ) {
+ if (attachment.isImage && attachment.thumbnail != null) {
+ Image(
+ bitmap = attachment.thumbnail.asImageBitmap(),
+ contentDescription = attachment.filename,
+ modifier = Modifier.fillMaxSize(),
+ contentScale = ContentScale.Fit,
+ )
+ } else {
+ // File preview
+ FilePreviewContent(
+ filename = attachment.filename,
+ fileSize = attachment.fileSize,
+ )
+ }
+ }
+}
+
+@Composable
+private fun FilePreviewContent(
+ filename: String,
+ fileSize: String,
+ modifier: Modifier = Modifier,
+) {
+ Column(
+ modifier = modifier,
+ horizontalAlignment = Alignment.CenterHorizontally,
+ verticalArrangement = Arrangement.Center,
+ ) {
+ Icon(
+ imageVector = Icons.Default.AttachFile,
+ contentDescription = "File",
+ tint = Color.White,
+ modifier = Modifier.size(80.dp),
+ )
+
+ Spacer(modifier = Modifier.height(16.dp))
+
+ Text(
+ text = filename,
+ color = Color.White,
+ style = MaterialTheme.typography.titleMedium,
+ maxLines = 2,
+ overflow = TextOverflow.Ellipsis,
+ )
+
+ Spacer(modifier = Modifier.height(4.dp))
+
+ Text(
+ text = fileSize,
+ color = Color.White.copy(alpha = 0.5f),
+ style = MaterialTheme.typography.bodyMedium,
+ )
+ }
+}
+
+@Composable
+private fun CaptionInput(
+ caption: String,
+ onCaptionChange: (String) -> Unit,
+ modifier: Modifier = Modifier,
+) {
+ OutlinedTextField(
+ value = caption,
+ onValueChange = onCaptionChange,
+ modifier = modifier,
+ placeholder = {
+ Text(
+ text = "Add a caption...",
+ color = Color.White.copy(alpha = 0.5f),
+ )
+ },
+ colors = OutlinedTextFieldDefaults.colors(
+ focusedTextColor = Color.White,
+ unfocusedTextColor = Color.White,
+ cursorColor = Color.White,
+ focusedBorderColor = Color.White,
+ unfocusedBorderColor = Color.White.copy(alpha = 0.5f),
+ ),
+ maxLines = 3,
+ )
+}
+
+@Composable
+private fun ThumbnailStrip(
+ attachments: List,
+ selectedIndex: Int,
+ onThumbnailClick: (Int) -> Unit,
+ modifier: Modifier = Modifier,
+) {
+ LazyRow(
+ modifier = modifier,
+ horizontalArrangement = Arrangement.spacedBy(8.dp),
+ ) {
+ itemsIndexed(attachments) { index, attachment ->
+ ThumbnailItem(
+ attachment = attachment,
+ isSelected = index == selectedIndex,
+ onClick = { onThumbnailClick(index) },
+ )
+ }
+ }
+}
+
+@Composable
+private fun ThumbnailItem(
+ attachment: AttachmentPreviewItem,
+ isSelected: Boolean,
+ onClick: () -> Unit,
+ modifier: Modifier = Modifier,
+) {
+ Box(
+ modifier = modifier
+ .size(60.dp)
+ .clip(RoundedCornerShape(8.dp))
+ .border(
+ width = if (isSelected) 2.dp else 0.dp,
+ color = if (isSelected) MaterialTheme.colorScheme.primary else Color.Transparent,
+ shape = RoundedCornerShape(8.dp),
+ )
+ .clickable(onClick = onClick),
+ contentAlignment = Alignment.Center,
+ ) {
+ if (attachment.isImage && attachment.thumbnail != null) {
+ Image(
+ bitmap = attachment.thumbnail.asImageBitmap(),
+ contentDescription = attachment.filename,
+ modifier = Modifier.fillMaxSize(),
+ contentScale = ContentScale.Crop,
+ )
+ } else {
+ Box(
+ modifier = Modifier
+ .fillMaxSize()
+ .background(Color.DarkGray),
+ contentAlignment = Alignment.Center,
+ ) {
+ Icon(
+ imageVector = Icons.Default.AttachFile,
+ contentDescription = "File",
+ tint = Color.White,
+ modifier = Modifier.size(24.dp),
+ )
+ }
+ }
+ }
+}
+
+@Preview(showBackground = true, backgroundColor = 0xFF000000)
+@Composable
+private fun AttachmentPreviewScreenSingleImagePreview() {
+ XMTPTheme {
+ AttachmentPreviewScreen(
+ attachments = listOf(
+ AttachmentPreviewItem(
+ uri = Uri.EMPTY,
+ filename = "photo.jpg",
+ fileSize = "2.5 MB",
+ mimeType = "image/jpeg",
+ isImage = true,
+ thumbnail = null,
+ ),
+ ),
+ caption = "",
+ )
+ }
+}
+
+@Preview(showBackground = true, backgroundColor = 0xFF000000)
+@Composable
+private fun AttachmentPreviewScreenFilePreview() {
+ XMTPTheme {
+ AttachmentPreviewScreen(
+ attachments = listOf(
+ AttachmentPreviewItem(
+ uri = Uri.EMPTY,
+ filename = "document.pdf",
+ fileSize = "1.2 MB",
+ mimeType = "application/pdf",
+ isImage = false,
+ ),
+ ),
+ )
+ }
+}
+
+@Preview(showBackground = true, backgroundColor = 0xFF000000)
+@Composable
+private fun AttachmentPreviewScreenMultiplePreview() {
+ XMTPTheme {
+ Column(
+ modifier = Modifier.fillMaxSize(),
+ horizontalAlignment = Alignment.CenterHorizontally,
+ ) {
+ // Simulating multiple attachment preview
+ Text(
+ text = "1 of 3",
+ color = Color.White,
+ modifier = Modifier.padding(16.dp),
+ )
+
+ Box(
+ modifier = Modifier
+ .weight(1f)
+ .fillMaxWidth()
+ .background(Color.DarkGray),
+ contentAlignment = Alignment.Center,
+ ) {
+ FilePreviewContent(
+ filename = "document.pdf",
+ fileSize = "1.2 MB",
+ )
+ }
+
+ Row(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(8.dp),
+ horizontalArrangement = Arrangement.spacedBy(8.dp),
+ ) {
+ repeat(3) { index ->
+ Box(
+ modifier = Modifier
+ .size(60.dp)
+ .clip(RoundedCornerShape(8.dp))
+ .background(Color.DarkGray)
+ .border(
+ width = if (index == 0) 2.dp else 0.dp,
+ color = if (index == 0) Color.Blue else Color.Transparent,
+ shape = RoundedCornerShape(8.dp),
+ ),
+ contentAlignment = Alignment.Center,
+ ) {
+ Icon(
+ imageVector = Icons.Default.AttachFile,
+ contentDescription = "File",
+ tint = Color.White,
+ )
+ }
+ }
+ }
+ }
+ }
+}
diff --git a/example/src/main/java/org/xmtp/android/example/ui/screens/ChatScreen.kt b/example/src/main/java/org/xmtp/android/example/ui/screens/ChatScreen.kt
new file mode 100644
index 000000000..bfd5c91c6
--- /dev/null
+++ b/example/src/main/java/org/xmtp/android/example/ui/screens/ChatScreen.kt
@@ -0,0 +1,388 @@
+package org.xmtp.android.example.ui.screens
+
+import androidx.compose.animation.AnimatedVisibility
+import androidx.compose.animation.slideInVertically
+import androidx.compose.animation.slideOutVertically
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.PaddingValues
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.lazy.LazyColumn
+import androidx.compose.foundation.lazy.items
+import androidx.compose.foundation.lazy.rememberLazyListState
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.automirrored.filled.ArrowBack
+import androidx.compose.material.icons.filled.Info
+import androidx.compose.material.icons.filled.Search
+import androidx.compose.material3.CircularProgressIndicator
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.Icon
+import androidx.compose.material3.IconButton
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Scaffold
+import androidx.compose.material3.Text
+import androidx.compose.material3.TopAppBar
+import androidx.compose.material3.TopAppBarDefaults
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.derivedStateOf
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.saveable.rememberSaveable
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.text.style.TextOverflow
+import androidx.compose.ui.tooling.preview.Preview
+import androidx.compose.ui.unit.dp
+import org.xmtp.android.example.ui.components.DeliveryStatus
+import org.xmtp.android.example.ui.components.MessageBubble
+import org.xmtp.android.example.ui.components.MessageComposer
+import org.xmtp.android.example.ui.components.MessageSearchBar
+import org.xmtp.android.example.ui.components.SearchResult
+import org.xmtp.android.example.ui.components.SearchResultsList
+import org.xmtp.android.example.ui.components.SystemMessage
+import org.xmtp.android.example.ui.theme.XMTPTheme
+
+data class MessageItem(
+ val id: String,
+ val content: String,
+ val timestamp: String,
+ val isMe: Boolean,
+ val senderName: String? = null,
+ val senderColor: Color? = null,
+ val deliveryStatus: DeliveryStatus = DeliveryStatus.SENT,
+ val reactions: List>? = null,
+ val replyPreview: String? = null,
+ val replyAuthor: String? = null,
+ val isDeleted: Boolean = false,
+ val isSystemMessage: Boolean = false,
+)
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+fun ChatScreen(
+ conversationName: String,
+ messages: List,
+ isLoading: Boolean = false,
+ isGroup: Boolean = false,
+ memberCount: Int? = null,
+ replyingTo: MessageItem? = null,
+ modifier: Modifier = Modifier,
+ onBackClick: () -> Unit = {},
+ onInfoClick: () -> Unit = {},
+ onSendMessage: (String) -> Unit = {},
+ onAttachmentClick: () -> Unit = {},
+ onEmojiClick: () -> Unit = {},
+ onCancelReply: () -> Unit = {},
+ onMessageLongClick: (String) -> Unit = {},
+ onReplyClick: (String) -> Unit = {},
+ onScrollToMessage: (String) -> Unit = {},
+) {
+ val listState = rememberLazyListState()
+ var messageText by rememberSaveable { mutableStateOf("") }
+ var isSearching by rememberSaveable { mutableStateOf(false) }
+ var searchText by rememberSaveable { mutableStateOf("") }
+
+ // Filter messages for search results (exclude deleted and system messages)
+ val searchResults by remember(searchText, messages) {
+ derivedStateOf {
+ if (searchText.isEmpty()) {
+ emptyList()
+ } else {
+ val lowercaseSearch = searchText.lowercase()
+ messages
+ .filter { msg ->
+ !msg.isSystemMessage &&
+ !msg.isDeleted &&
+ msg.content.isNotEmpty() &&
+ msg.content.lowercase().contains(lowercaseSearch)
+ }.map { msg ->
+ SearchResult(
+ id = msg.id,
+ senderName = msg.senderName ?: "You",
+ content = msg.content,
+ timestamp = msg.timestamp,
+ isDeleted = msg.isDeleted,
+ )
+ }
+ }
+ }
+ }
+
+ // Scroll to bottom when new messages arrive
+ LaunchedEffect(messages.size) {
+ if (messages.isNotEmpty() && !isSearching) {
+ listState.animateScrollToItem(messages.size - 1)
+ }
+ }
+
+ Scaffold(
+ modifier = modifier.fillMaxSize(),
+ topBar = {
+ Column {
+ TopAppBar(
+ title = {
+ Column {
+ Text(
+ text = conversationName,
+ style = MaterialTheme.typography.titleMedium,
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis,
+ )
+ if (isGroup && memberCount != null) {
+ Text(
+ text = "$memberCount members",
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+ }
+ }
+ },
+ navigationIcon = {
+ IconButton(onClick = onBackClick) {
+ Icon(
+ imageVector = Icons.AutoMirrored.Filled.ArrowBack,
+ contentDescription = "Back",
+ )
+ }
+ },
+ actions = {
+ IconButton(onClick = { isSearching = !isSearching }) {
+ Icon(
+ imageVector = Icons.Default.Search,
+ contentDescription = "Search",
+ )
+ }
+ if (isGroup) {
+ IconButton(onClick = onInfoClick) {
+ Icon(
+ imageVector = Icons.Default.Info,
+ contentDescription = "Group Info",
+ )
+ }
+ }
+ },
+ colors =
+ TopAppBarDefaults.topAppBarColors(
+ containerColor = MaterialTheme.colorScheme.surface,
+ ),
+ )
+
+ // Search bar
+ MessageSearchBar(
+ searchText = searchText,
+ onSearchTextChange = { searchText = it },
+ isSearching = isSearching,
+ onSearchToggle = { isSearching = it },
+ )
+ }
+ },
+ bottomBar = {
+ AnimatedVisibility(
+ visible = !isSearching,
+ enter = slideInVertically { it },
+ exit = slideOutVertically { it },
+ ) {
+ MessageComposer(
+ value = messageText,
+ onValueChange = { messageText = it },
+ onSendClick = {
+ if (messageText.isNotBlank()) {
+ onSendMessage(messageText)
+ messageText = ""
+ }
+ },
+ replyTo = replyingTo?.content,
+ replyAuthor = replyingTo?.senderName,
+ onClearReply = onCancelReply,
+ onAttachmentClick = onAttachmentClick,
+ onEmojiClick = onEmojiClick,
+ )
+ }
+ },
+ ) { paddingValues ->
+ Box(
+ modifier =
+ Modifier
+ .fillMaxSize()
+ .padding(paddingValues),
+ ) {
+ if (isLoading) {
+ CircularProgressIndicator(
+ modifier = Modifier.align(Alignment.Center),
+ )
+ } else if (isSearching && searchText.isNotEmpty()) {
+ // Show search results
+ SearchResultsList(
+ searchText = searchText,
+ results = searchResults,
+ onResultClick = { messageId ->
+ // Scroll to message and close search
+ onScrollToMessage(messageId)
+ isSearching = false
+ searchText = ""
+
+ // Find the index of the message and scroll to it
+ val index = messages.indexOfFirst { it.id == messageId }
+ if (index >= 0) {
+ // Using launched effect won't work here, so we handle it via callback
+ }
+ },
+ )
+ } else {
+ LazyColumn(
+ modifier = Modifier.fillMaxSize(),
+ state = listState,
+ contentPadding = PaddingValues(vertical = 8.dp),
+ reverseLayout = false,
+ ) {
+ items(
+ items = messages,
+ key = { it.id },
+ ) { message ->
+ if (message.isSystemMessage) {
+ SystemMessage(text = message.content)
+ } else {
+ MessageBubble(
+ message = message.content,
+ timestamp = message.timestamp,
+ isMe = message.isMe,
+ senderName = message.senderName,
+ senderColor = message.senderColor,
+ deliveryStatus = message.deliveryStatus,
+ reactions = message.reactions,
+ replyPreview = message.replyPreview,
+ replyAuthor = message.replyAuthor,
+ isDeleted = message.isDeleted,
+ onLongClick = { onMessageLongClick(message.id) },
+ onReplyClick = { onReplyClick(message.id) },
+ )
+ }
+ }
+ }
+ }
+ }
+ }
+}
+
+@Preview(showBackground = true)
+@Composable
+private fun ChatScreenPreview() {
+ XMTPTheme {
+ ChatScreen(
+ conversationName = "0x1234...5678",
+ messages =
+ listOf(
+ MessageItem(
+ id = "1",
+ content = "Hey! How are you?",
+ timestamp = "10:30 AM",
+ isMe = false,
+ senderName = "0x1234...5678",
+ ),
+ MessageItem(
+ id = "2",
+ content = "I'm doing great! Just shipped a new feature.",
+ timestamp = "10:31 AM",
+ isMe = true,
+ ),
+ MessageItem(
+ id = "3",
+ content = "That's awesome! Can you show me?",
+ timestamp = "10:32 AM",
+ isMe = false,
+ senderName = "0x1234...5678",
+ ),
+ MessageItem(
+ id = "4",
+ content = "Sure, let me send you a screenshot!",
+ timestamp = "10:33 AM",
+ isMe = true,
+ reactions = listOf("👍" to 1, "🔥" to 2),
+ ),
+ ),
+ )
+ }
+}
+
+@Preview(showBackground = true)
+@Composable
+private fun GroupChatScreenPreview() {
+ XMTPTheme {
+ ChatScreen(
+ conversationName = "XMTP Dev Team",
+ isGroup = true,
+ memberCount = 5,
+ messages =
+ listOf(
+ MessageItem(
+ id = "sys1",
+ content = "Alice joined the group",
+ timestamp = "",
+ isMe = false,
+ isSystemMessage = true,
+ ),
+ MessageItem(
+ id = "1",
+ content = "Welcome to the team!",
+ timestamp = "10:30 AM",
+ isMe = false,
+ senderName = "Bob",
+ senderColor = Color(0xFF5856D6),
+ ),
+ MessageItem(
+ id = "2",
+ content = "Thanks! Excited to be here.",
+ timestamp = "10:31 AM",
+ isMe = true,
+ ),
+ MessageItem(
+ id = "3",
+ content = "Let's ship some features!",
+ timestamp = "10:32 AM",
+ isMe = false,
+ senderName = "Charlie",
+ senderColor = Color(0xFFFF9500),
+ ),
+ ),
+ )
+ }
+}
+
+@Preview(showBackground = true)
+@Composable
+private fun ChatWithReplyPreview() {
+ XMTPTheme {
+ ChatScreen(
+ conversationName = "Alice",
+ messages =
+ listOf(
+ MessageItem(
+ id = "1",
+ content = "Hey, did you see the new update?",
+ timestamp = "10:30 AM",
+ isMe = false,
+ ),
+ MessageItem(
+ id = "2",
+ content = "Yes! It looks amazing!",
+ timestamp = "10:31 AM",
+ isMe = true,
+ replyPreview = "Hey, did you see the new update?",
+ replyAuthor = "Alice",
+ ),
+ ),
+ replyingTo =
+ MessageItem(
+ id = "1",
+ content = "Hey, did you see the new update?",
+ timestamp = "10:30 AM",
+ isMe = false,
+ ),
+ )
+ }
+}
diff --git a/example/src/main/java/org/xmtp/android/example/ui/screens/ConnectWalletScreen.kt b/example/src/main/java/org/xmtp/android/example/ui/screens/ConnectWalletScreen.kt
new file mode 100644
index 000000000..b25bae707
--- /dev/null
+++ b/example/src/main/java/org/xmtp/android/example/ui/screens/ConnectWalletScreen.kt
@@ -0,0 +1,217 @@
+package org.xmtp.android.example.ui.screens
+
+import androidx.compose.foundation.Image
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.rememberScrollState
+import androidx.compose.foundation.verticalScroll
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Surface
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.res.painterResource
+import androidx.compose.ui.text.style.TextAlign
+import androidx.compose.ui.tooling.preview.Preview
+import androidx.compose.ui.unit.dp
+import org.xmtp.android.example.R
+import org.xmtp.android.example.connect.ConnectWalletViewModel
+import org.xmtp.android.example.ui.components.DropdownSelector
+import org.xmtp.android.example.ui.components.LoadingButton
+import org.xmtp.android.example.ui.theme.XMTPTheme
+import org.xmtp.android.library.XMTPEnvironment
+import uniffi.xmtpv3.FfiLogLevel
+
+data class EnvironmentOption(
+ val environment: XMTPEnvironment,
+ val displayName: String,
+)
+
+data class LogLevelOption(
+ val logLevel: FfiLogLevel?,
+ val displayName: String,
+)
+
+private val environmentOptions = listOf(
+ EnvironmentOption(XMTPEnvironment.DEV, "Dev"),
+ EnvironmentOption(XMTPEnvironment.PRODUCTION, "Production"),
+ EnvironmentOption(XMTPEnvironment.LOCAL, "Local"),
+)
+
+private val logLevelOptions = listOf(
+ LogLevelOption(null, "Off"),
+ LogLevelOption(FfiLogLevel.ERROR, "Error"),
+ LogLevelOption(FfiLogLevel.WARN, "Warn"),
+ LogLevelOption(FfiLogLevel.INFO, "Info"),
+ LogLevelOption(FfiLogLevel.DEBUG, "Debug"),
+ LogLevelOption(FfiLogLevel.TRACE, "Trace"),
+)
+
+@Composable
+fun ConnectWalletScreen(
+ uiState: ConnectWalletViewModel.ConnectUiState,
+ onGenerateWallet: (XMTPEnvironment, FfiLogLevel?) -> Unit,
+ onConnectSuccess: (String) -> Unit,
+ modifier: Modifier = Modifier,
+) {
+ var selectedEnvironment by remember { mutableStateOf(environmentOptions[0]) }
+ var selectedLogLevel by remember { mutableStateOf(logLevelOptions[0]) }
+
+ val isLoading = uiState is ConnectWalletViewModel.ConnectUiState.Loading
+ val errorMessage = (uiState as? ConnectWalletViewModel.ConnectUiState.Error)?.message
+
+ // Handle success navigation
+ LaunchedEffect(uiState) {
+ if (uiState is ConnectWalletViewModel.ConnectUiState.Success) {
+ onConnectSuccess(uiState.address)
+ }
+ }
+
+ Surface(
+ modifier = modifier.fillMaxSize(),
+ color = MaterialTheme.colorScheme.background,
+ ) {
+ Box(
+ modifier = Modifier
+ .fillMaxSize()
+ .padding(24.dp),
+ ) {
+ Column(
+ modifier = Modifier
+ .fillMaxSize()
+ .verticalScroll(rememberScrollState()),
+ horizontalAlignment = Alignment.CenterHorizontally,
+ verticalArrangement = Arrangement.Center,
+ ) {
+ // XMTP Logo
+ Image(
+ painter = painterResource(id = R.drawable.ic_xmtp_white),
+ contentDescription = "XMTP Logo",
+ modifier = Modifier.size(120.dp),
+ )
+
+ Spacer(modifier = Modifier.height(24.dp))
+
+ // Title
+ Text(
+ text = "Welcome to XMTP",
+ style = MaterialTheme.typography.headlineMedium,
+ color = MaterialTheme.colorScheme.onBackground,
+ textAlign = TextAlign.Center,
+ )
+
+ Spacer(modifier = Modifier.height(8.dp))
+
+ // Subtitle
+ Text(
+ text = "Connect your wallet to start messaging",
+ style = MaterialTheme.typography.bodyLarge,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ textAlign = TextAlign.Center,
+ )
+
+ Spacer(modifier = Modifier.height(48.dp))
+
+ // Environment Selector
+ DropdownSelector(
+ label = "Environment",
+ options = environmentOptions,
+ selectedOption = selectedEnvironment,
+ onOptionSelected = { selectedEnvironment = it },
+ optionLabel = { it.displayName },
+ modifier = Modifier.fillMaxWidth(),
+ )
+
+ Spacer(modifier = Modifier.height(16.dp))
+
+ // Log Level Selector
+ DropdownSelector(
+ label = "Log Level",
+ options = logLevelOptions,
+ selectedOption = selectedLogLevel,
+ onOptionSelected = { selectedLogLevel = it },
+ optionLabel = { it.displayName },
+ modifier = Modifier.fillMaxWidth(),
+ )
+
+ Spacer(modifier = Modifier.height(32.dp))
+
+ // Generate Wallet Button
+ LoadingButton(
+ text = "Generate Wallet",
+ onClick = {
+ onGenerateWallet(
+ selectedEnvironment.environment,
+ selectedLogLevel.logLevel,
+ )
+ },
+ isLoading = isLoading,
+ modifier = Modifier.fillMaxWidth(),
+ )
+
+ // Error Message
+ if (errorMessage != null) {
+ Spacer(modifier = Modifier.height(16.dp))
+ Text(
+ text = errorMessage,
+ style = MaterialTheme.typography.bodyMedium,
+ color = MaterialTheme.colorScheme.error,
+ textAlign = TextAlign.Center,
+ modifier = Modifier.fillMaxWidth(),
+ )
+ }
+
+ Spacer(modifier = Modifier.height(48.dp))
+ }
+ }
+ }
+}
+
+@Preview(showBackground = true)
+@Composable
+private fun ConnectWalletScreenPreview() {
+ XMTPTheme {
+ ConnectWalletScreen(
+ uiState = ConnectWalletViewModel.ConnectUiState.Unknown,
+ onGenerateWallet = { _, _ -> },
+ onConnectSuccess = {},
+ )
+ }
+}
+
+@Preview(showBackground = true)
+@Composable
+private fun ConnectWalletScreenLoadingPreview() {
+ XMTPTheme {
+ ConnectWalletScreen(
+ uiState = ConnectWalletViewModel.ConnectUiState.Loading,
+ onGenerateWallet = { _, _ -> },
+ onConnectSuccess = {},
+ )
+ }
+}
+
+@Preview(showBackground = true)
+@Composable
+private fun ConnectWalletScreenErrorPreview() {
+ XMTPTheme {
+ ConnectWalletScreen(
+ uiState = ConnectWalletViewModel.ConnectUiState.Error("Failed to connect to network"),
+ onGenerateWallet = { _, _ -> },
+ onConnectSuccess = {},
+ )
+ }
+}
diff --git a/example/src/main/java/org/xmtp/android/example/ui/screens/GroupManagementScreen.kt b/example/src/main/java/org/xmtp/android/example/ui/screens/GroupManagementScreen.kt
new file mode 100644
index 000000000..f033d8611
--- /dev/null
+++ b/example/src/main/java/org/xmtp/android/example/ui/screens/GroupManagementScreen.kt
@@ -0,0 +1,357 @@
+package org.xmtp.android.example.ui.screens
+
+import androidx.compose.foundation.background
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.rememberScrollState
+import androidx.compose.foundation.shape.CircleShape
+import androidx.compose.foundation.verticalScroll
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.automirrored.filled.ArrowBack
+import androidx.compose.material.icons.automirrored.filled.ExitToApp
+import androidx.compose.material.icons.filled.Person
+import androidx.compose.material.icons.filled.PersonAdd
+import androidx.compose.material3.Button
+import androidx.compose.material3.ButtonDefaults
+import androidx.compose.material3.Card
+import androidx.compose.material3.CardDefaults
+import androidx.compose.material3.CircularProgressIndicator
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.Icon
+import androidx.compose.material3.IconButton
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.OutlinedButton
+import androidx.compose.material3.Scaffold
+import androidx.compose.material3.Text
+import androidx.compose.material3.TextButton
+import androidx.compose.material3.TopAppBar
+import androidx.compose.material3.TopAppBarDefaults
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.tooling.preview.Preview
+import androidx.compose.ui.unit.dp
+import org.xmtp.android.example.ui.components.MemberPermissionLevel
+import org.xmtp.android.example.ui.components.MemberRow
+import org.xmtp.android.example.ui.components.MemberRowData
+import org.xmtp.android.example.ui.theme.XMTPTheme
+import kotlin.math.abs
+
+data class GroupDetails(
+ val groupId: String,
+ val groupName: String,
+ val createdAt: String,
+ val memberCount: Int,
+ val currentUserRole: MemberPermissionLevel,
+)
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+fun GroupManagementScreen(
+ groupDetails: GroupDetails,
+ members: List,
+ isLoading: Boolean = false,
+ modifier: Modifier = Modifier,
+ onBackClick: () -> Unit = {},
+ onAddMemberClick: () -> Unit = {},
+ onMemberClick: (String) -> Unit = {},
+ onPromoteToAdmin: (String) -> Unit = {},
+ onDemoteFromAdmin: (String) -> Unit = {},
+ onRemoveMember: (String) -> Unit = {},
+ onLeaveGroup: () -> Unit = {},
+) {
+ val canManageMembers = groupDetails.currentUserRole == MemberPermissionLevel.SUPER_ADMIN ||
+ groupDetails.currentUserRole == MemberPermissionLevel.ADMIN
+
+ val avatarColors = listOf(
+ Color(0xFFFC4F37),
+ Color(0xFF5856D6),
+ Color(0xFF34C759),
+ Color(0xFFFF9500),
+ Color(0xFF007AFF),
+ Color(0xFFAF52DE),
+ Color(0xFF00C7BE),
+ Color(0xFFFF2D55),
+ )
+ val avatarColor = avatarColors[abs(groupDetails.groupId.hashCode()) % avatarColors.size]
+
+ Scaffold(
+ modifier = modifier.fillMaxSize(),
+ topBar = {
+ TopAppBar(
+ title = { Text("Group Info") },
+ navigationIcon = {
+ IconButton(onClick = onBackClick) {
+ Icon(
+ imageVector = Icons.AutoMirrored.Filled.ArrowBack,
+ contentDescription = "Back",
+ )
+ }
+ },
+ colors = TopAppBarDefaults.topAppBarColors(
+ containerColor = MaterialTheme.colorScheme.primary,
+ titleContentColor = Color.White,
+ navigationIconContentColor = Color.White,
+ ),
+ )
+ },
+ ) { paddingValues ->
+ Box(
+ modifier = Modifier
+ .fillMaxSize()
+ .padding(paddingValues),
+ ) {
+ if (isLoading) {
+ CircularProgressIndicator(
+ modifier = Modifier.align(Alignment.Center),
+ )
+ } else {
+ Column(
+ modifier = Modifier
+ .fillMaxSize()
+ .verticalScroll(rememberScrollState())
+ .padding(bottom = 24.dp),
+ horizontalAlignment = Alignment.CenterHorizontally,
+ ) {
+ Spacer(modifier = Modifier.height(24.dp))
+
+ // Group Avatar
+ Box(
+ modifier = Modifier
+ .size(80.dp)
+ .clip(CircleShape)
+ .background(avatarColor),
+ contentAlignment = Alignment.Center,
+ ) {
+ Text(
+ text = groupDetails.groupName
+ .removePrefix("0x")
+ .take(2)
+ .uppercase(),
+ style = MaterialTheme.typography.headlineMedium,
+ fontWeight = FontWeight.Bold,
+ color = Color.White,
+ )
+ }
+
+ Spacer(modifier = Modifier.height(12.dp))
+
+ // Group ID
+ Text(
+ text = groupDetails.groupId,
+ style = MaterialTheme.typography.bodyMedium,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+
+ Spacer(modifier = Modifier.height(24.dp))
+
+ // Group Details Card
+ Card(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(horizontal = 16.dp),
+ colors = CardDefaults.cardColors(
+ containerColor = MaterialTheme.colorScheme.surface,
+ ),
+ elevation = CardDefaults.cardElevation(defaultElevation = 0.dp),
+ ) {
+ Column(
+ modifier = Modifier.padding(16.dp),
+ ) {
+ Text(
+ text = "Group Details",
+ style = MaterialTheme.typography.titleMedium,
+ fontWeight = FontWeight.Bold,
+ )
+
+ Spacer(modifier = Modifier.height(16.dp))
+
+ InfoRow(label = "Created", value = groupDetails.createdAt)
+ Spacer(modifier = Modifier.height(12.dp))
+ InfoRow(label = "Members", value = "${groupDetails.memberCount} members")
+ Spacer(modifier = Modifier.height(12.dp))
+ InfoRow(
+ label = "Your Role",
+ value = when (groupDetails.currentUserRole) {
+ MemberPermissionLevel.SUPER_ADMIN -> "Super Admin"
+ MemberPermissionLevel.ADMIN -> "Admin"
+ MemberPermissionLevel.MEMBER -> "Member"
+ },
+ valueColor = MaterialTheme.colorScheme.primary,
+ )
+ }
+ }
+
+ Spacer(modifier = Modifier.height(16.dp))
+
+ // Members Card
+ Card(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(horizontal = 16.dp),
+ colors = CardDefaults.cardColors(
+ containerColor = MaterialTheme.colorScheme.surface,
+ ),
+ elevation = CardDefaults.cardElevation(defaultElevation = 0.dp),
+ ) {
+ Column(
+ modifier = Modifier.padding(16.dp),
+ ) {
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ Text(
+ text = "Members",
+ style = MaterialTheme.typography.titleMedium,
+ fontWeight = FontWeight.Bold,
+ modifier = Modifier.weight(1f),
+ )
+
+ if (canManageMembers) {
+ TextButton(onClick = onAddMemberClick) {
+ Icon(
+ imageVector = Icons.Default.PersonAdd,
+ contentDescription = "Add member",
+ modifier = Modifier.size(18.dp),
+ )
+ Text(
+ text = "Add",
+ modifier = Modifier.padding(start = 4.dp),
+ )
+ }
+ }
+ }
+
+ Spacer(modifier = Modifier.height(8.dp))
+
+ members.forEach { member ->
+ MemberRow(
+ member = member,
+ canManageMembers = canManageMembers && !member.isCurrentUser,
+ onMemberClick = { onMemberClick(member.inboxId) },
+ onPromoteToAdmin = { onPromoteToAdmin(member.inboxId) },
+ onDemoteFromAdmin = { onDemoteFromAdmin(member.inboxId) },
+ onRemoveMember = { onRemoveMember(member.inboxId) },
+ )
+ }
+ }
+ }
+
+ Spacer(modifier = Modifier.height(24.dp))
+
+ // Leave Group Button
+ OutlinedButton(
+ onClick = onLeaveGroup,
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(horizontal = 16.dp)
+ .height(52.dp),
+ colors = ButtonDefaults.outlinedButtonColors(
+ contentColor = MaterialTheme.colorScheme.error,
+ ),
+ ) {
+ Icon(
+ imageVector = Icons.AutoMirrored.Filled.ExitToApp,
+ contentDescription = "Leave group",
+ modifier = Modifier.size(20.dp),
+ )
+ Text(
+ text = "Leave Group",
+ modifier = Modifier.padding(start = 8.dp),
+ )
+ }
+ }
+ }
+ }
+ }
+}
+
+@Composable
+private fun InfoRow(
+ label: String,
+ value: String,
+ modifier: Modifier = Modifier,
+ valueColor: Color = MaterialTheme.colorScheme.onSurface,
+) {
+ Row(
+ modifier = modifier.fillMaxWidth(),
+ ) {
+ Text(
+ text = label,
+ style = MaterialTheme.typography.bodyMedium,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ modifier = Modifier.weight(1f),
+ )
+ Text(
+ text = value,
+ style = MaterialTheme.typography.bodyMedium,
+ color = valueColor,
+ fontWeight = if (valueColor != MaterialTheme.colorScheme.onSurface) FontWeight.Bold else FontWeight.Normal,
+ )
+ }
+}
+
+@Preview(showBackground = true)
+@Composable
+private fun GroupManagementScreenPreview() {
+ XMTPTheme {
+ GroupManagementScreen(
+ groupDetails = GroupDetails(
+ groupId = "0x1234...abcd",
+ groupName = "XMTP Dev Team",
+ createdAt = "Jan 15, 2024",
+ memberCount = 3,
+ currentUserRole = MemberPermissionLevel.SUPER_ADMIN,
+ ),
+ members = listOf(
+ MemberRowData(
+ inboxId = "inbox1",
+ displayAddress = "0x1234...5678",
+ permissionLevel = MemberPermissionLevel.SUPER_ADMIN,
+ isCurrentUser = true,
+ ),
+ MemberRowData(
+ inboxId = "inbox2",
+ displayAddress = "0xabcd...efgh",
+ permissionLevel = MemberPermissionLevel.ADMIN,
+ isCurrentUser = false,
+ ),
+ MemberRowData(
+ inboxId = "inbox3",
+ displayAddress = "0x9876...5432",
+ permissionLevel = MemberPermissionLevel.MEMBER,
+ isCurrentUser = false,
+ ),
+ ),
+ )
+ }
+}
+
+@Preview(showBackground = true)
+@Composable
+private fun GroupManagementScreenLoadingPreview() {
+ XMTPTheme {
+ GroupManagementScreen(
+ groupDetails = GroupDetails(
+ groupId = "",
+ groupName = "",
+ createdAt = "",
+ memberCount = 0,
+ currentUserRole = MemberPermissionLevel.MEMBER,
+ ),
+ members = emptyList(),
+ isLoading = true,
+ )
+ }
+}
diff --git a/example/src/main/java/org/xmtp/android/example/ui/screens/HomeScreen.kt b/example/src/main/java/org/xmtp/android/example/ui/screens/HomeScreen.kt
new file mode 100644
index 000000000..8049f92a8
--- /dev/null
+++ b/example/src/main/java/org/xmtp/android/example/ui/screens/HomeScreen.kt
@@ -0,0 +1,223 @@
+package org.xmtp.android.example.ui.screens
+
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.PaddingValues
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.lazy.LazyColumn
+import androidx.compose.foundation.lazy.items
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.Add
+import androidx.compose.material.icons.filled.Search
+import androidx.compose.material3.CircularProgressIndicator
+import androidx.compose.material3.Divider
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.FloatingActionButton
+import androidx.compose.material3.Icon
+import androidx.compose.material3.IconButton
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Scaffold
+import androidx.compose.material3.Text
+import androidx.compose.material3.TopAppBar
+import androidx.compose.material3.TopAppBarDefaults
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.text.style.TextAlign
+import androidx.compose.ui.tooling.preview.Preview
+import androidx.compose.ui.unit.dp
+import org.xmtp.android.example.ui.components.ConversationRow
+import org.xmtp.android.example.ui.theme.XMTPTheme
+
+data class ConversationItem(
+ val id: String,
+ val name: String,
+ val lastMessage: String,
+ val timestamp: String,
+ val isGroup: Boolean = false,
+ val memberCount: Int? = null,
+ val unreadCount: Int = 0,
+)
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+fun HomeScreen(
+ conversations: List,
+ isLoading: Boolean = false,
+ modifier: Modifier = Modifier,
+ onConversationClick: (String) -> Unit = {},
+ onNewConversationClick: () -> Unit = {},
+ onSearchClick: () -> Unit = {},
+) {
+ Scaffold(
+ modifier = modifier.fillMaxSize(),
+ topBar = {
+ TopAppBar(
+ title = {
+ Text(
+ text = "Messages",
+ style = MaterialTheme.typography.headlineMedium,
+ )
+ },
+ actions = {
+ IconButton(onClick = onSearchClick) {
+ Icon(
+ imageVector = Icons.Default.Search,
+ contentDescription = "Search",
+ )
+ }
+ },
+ colors =
+ TopAppBarDefaults.topAppBarColors(
+ containerColor = MaterialTheme.colorScheme.surface,
+ ),
+ )
+ },
+ floatingActionButton = {
+ FloatingActionButton(
+ onClick = onNewConversationClick,
+ containerColor = MaterialTheme.colorScheme.primary,
+ ) {
+ Icon(
+ imageVector = Icons.Default.Add,
+ contentDescription = "New Conversation",
+ )
+ }
+ },
+ ) { paddingValues ->
+ Box(
+ modifier =
+ Modifier
+ .fillMaxSize()
+ .padding(paddingValues),
+ ) {
+ when {
+ isLoading -> {
+ CircularProgressIndicator(
+ modifier = Modifier.align(Alignment.Center),
+ )
+ }
+
+ conversations.isEmpty() -> {
+ EmptyConversationsView(
+ modifier = Modifier.align(Alignment.Center),
+ )
+ }
+
+ else -> {
+ ConversationsList(
+ conversations = conversations,
+ onConversationClick = onConversationClick,
+ )
+ }
+ }
+ }
+ }
+}
+
+@Composable
+private fun ConversationsList(
+ conversations: List,
+ onConversationClick: (String) -> Unit,
+) {
+ LazyColumn(
+ modifier = Modifier.fillMaxSize(),
+ contentPadding = PaddingValues(vertical = 8.dp),
+ ) {
+ items(
+ items = conversations,
+ key = { it.id },
+ ) { conversation ->
+ ConversationRow(
+ name = conversation.name,
+ lastMessage = conversation.lastMessage,
+ timestamp = conversation.timestamp,
+ isGroup = conversation.isGroup,
+ memberCount = conversation.memberCount,
+ unreadCount = conversation.unreadCount,
+ onClick = { onConversationClick(conversation.id) },
+ )
+ Divider(
+ modifier = Modifier.padding(start = 80.dp),
+ color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f),
+ )
+ }
+ }
+}
+
+@Composable
+private fun EmptyConversationsView(modifier: Modifier = Modifier) {
+ Column(
+ modifier = modifier.padding(32.dp),
+ horizontalAlignment = Alignment.CenterHorizontally,
+ ) {
+ Text(
+ text = "No Conversations Yet",
+ style = MaterialTheme.typography.headlineSmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+ Text(
+ text = "Start a new conversation using the + button",
+ style = MaterialTheme.typography.bodyMedium,
+ color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f),
+ textAlign = TextAlign.Center,
+ modifier = Modifier.padding(top = 8.dp),
+ )
+ }
+}
+
+@Preview(showBackground = true)
+@Composable
+private fun HomeScreenPreview() {
+ XMTPTheme {
+ HomeScreen(
+ conversations =
+ listOf(
+ ConversationItem(
+ id = "1",
+ name = "0x1234...5678",
+ lastMessage = "Hey, how are you?",
+ timestamp = "10:30 AM",
+ isGroup = false,
+ ),
+ ConversationItem(
+ id = "2",
+ name = "XMTP Dev Team",
+ lastMessage = "Alice: Let's ship this!",
+ timestamp = "Yesterday",
+ isGroup = true,
+ memberCount = 5,
+ unreadCount = 3,
+ ),
+ ConversationItem(
+ id = "3",
+ name = "Bob",
+ lastMessage = "Check out this update!",
+ timestamp = "2:45 PM",
+ isGroup = false,
+ unreadCount = 1,
+ ),
+ ),
+ )
+ }
+}
+
+@Preview(showBackground = true)
+@Composable
+private fun EmptyHomeScreenPreview() {
+ XMTPTheme {
+ HomeScreen(conversations = emptyList())
+ }
+}
+
+@Preview(showBackground = true)
+@Composable
+private fun LoadingHomeScreenPreview() {
+ XMTPTheme {
+ HomeScreen(
+ conversations = emptyList(),
+ isLoading = true,
+ )
+ }
+}
diff --git a/example/src/main/java/org/xmtp/android/example/ui/screens/MainScreen.kt b/example/src/main/java/org/xmtp/android/example/ui/screens/MainScreen.kt
new file mode 100644
index 000000000..86d733c35
--- /dev/null
+++ b/example/src/main/java/org/xmtp/android/example/ui/screens/MainScreen.kt
@@ -0,0 +1,283 @@
+package org.xmtp.android.example.ui.screens
+
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.PaddingValues
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.lazy.LazyColumn
+import androidx.compose.foundation.lazy.items
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.Add
+import androidx.compose.material.icons.filled.Menu
+import androidx.compose.material.icons.filled.Search
+import androidx.compose.material3.CircularProgressIndicator
+import androidx.compose.material3.DrawerValue
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.FloatingActionButton
+import androidx.compose.material3.HorizontalDivider
+import androidx.compose.material3.Icon
+import androidx.compose.material3.IconButton
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.ModalNavigationDrawer
+import androidx.compose.material3.Scaffold
+import androidx.compose.material3.Text
+import androidx.compose.material3.TopAppBar
+import androidx.compose.material3.TopAppBarDefaults
+import androidx.compose.material3.rememberDrawerState
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.rememberCoroutineScope
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.text.style.TextAlign
+import androidx.compose.ui.tooling.preview.Preview
+import androidx.compose.ui.unit.dp
+import kotlinx.coroutines.launch
+import org.xmtp.android.example.ui.components.ConversationRow
+import org.xmtp.android.example.ui.components.DrawerState
+import org.xmtp.android.example.ui.components.NavigationDrawerContent
+import org.xmtp.android.example.ui.theme.XMTPTheme
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+fun MainScreen(
+ conversations: List,
+ drawerState: DrawerState,
+ isLoading: Boolean = false,
+ modifier: Modifier = Modifier,
+ onConversationClick: (String) -> Unit = {},
+ onNewConversationClick: () -> Unit = {},
+ onSearchClick: () -> Unit = {},
+ onWalletInfoClick: () -> Unit = {},
+ onNewGroupClick: () -> Unit = {},
+ onHideDeletedMessagesToggle: (Boolean) -> Unit = {},
+ onViewLogsClick: () -> Unit = {},
+ onToggleLogsClick: (Boolean) -> Unit = {},
+ onCopyAddressClick: () -> Unit = {},
+ onDisconnectClick: () -> Unit = {},
+) {
+ val drawerStateCompose = rememberDrawerState(DrawerValue.Closed)
+ val scope = rememberCoroutineScope()
+
+ ModalNavigationDrawer(
+ drawerState = drawerStateCompose,
+ drawerContent = {
+ NavigationDrawerContent(
+ drawerState = drawerState,
+ onWalletInfoClick = {
+ scope.launch { drawerStateCompose.close() }
+ onWalletInfoClick()
+ },
+ onNewConversationClick = {
+ scope.launch { drawerStateCompose.close() }
+ onNewConversationClick()
+ },
+ onNewGroupClick = {
+ scope.launch { drawerStateCompose.close() }
+ onNewGroupClick()
+ },
+ onHideDeletedMessagesToggle = onHideDeletedMessagesToggle,
+ onViewLogsClick = {
+ scope.launch { drawerStateCompose.close() }
+ onViewLogsClick()
+ },
+ onToggleLogsClick = onToggleLogsClick,
+ onCopyAddressClick = {
+ scope.launch { drawerStateCompose.close() }
+ onCopyAddressClick()
+ },
+ onDisconnectClick = {
+ scope.launch { drawerStateCompose.close() }
+ onDisconnectClick()
+ },
+ )
+ },
+ modifier = modifier,
+ ) {
+ Scaffold(
+ topBar = {
+ TopAppBar(
+ title = {
+ Text(
+ text = "Messages",
+ style = MaterialTheme.typography.headlineMedium,
+ )
+ },
+ navigationIcon = {
+ IconButton(onClick = { scope.launch { drawerStateCompose.open() } }) {
+ Icon(
+ imageVector = Icons.Default.Menu,
+ contentDescription = "Open menu",
+ )
+ }
+ },
+ actions = {
+ IconButton(onClick = onSearchClick) {
+ Icon(
+ imageVector = Icons.Default.Search,
+ contentDescription = "Search",
+ )
+ }
+ },
+ colors = TopAppBarDefaults.topAppBarColors(
+ containerColor = MaterialTheme.colorScheme.surface,
+ ),
+ )
+ },
+ floatingActionButton = {
+ FloatingActionButton(
+ onClick = onNewConversationClick,
+ containerColor = MaterialTheme.colorScheme.primary,
+ ) {
+ Icon(
+ imageVector = Icons.Default.Add,
+ contentDescription = "New Conversation",
+ )
+ }
+ },
+ ) { paddingValues ->
+ Box(
+ modifier = Modifier
+ .fillMaxSize()
+ .padding(paddingValues),
+ ) {
+ when {
+ isLoading -> {
+ CircularProgressIndicator(
+ modifier = Modifier.align(Alignment.Center),
+ )
+ }
+
+ conversations.isEmpty() -> {
+ EmptyStateView(
+ modifier = Modifier.align(Alignment.Center),
+ )
+ }
+
+ else -> {
+ ConversationsList(
+ conversations = conversations,
+ onConversationClick = onConversationClick,
+ )
+ }
+ }
+ }
+ }
+ }
+}
+
+@Composable
+private fun ConversationsList(
+ conversations: List,
+ onConversationClick: (String) -> Unit,
+) {
+ LazyColumn(
+ modifier = Modifier.fillMaxSize(),
+ contentPadding = PaddingValues(vertical = 8.dp),
+ ) {
+ items(
+ items = conversations,
+ key = { it.id },
+ ) { conversation ->
+ ConversationRow(
+ name = conversation.name,
+ lastMessage = conversation.lastMessage,
+ timestamp = conversation.timestamp,
+ isGroup = conversation.isGroup,
+ memberCount = conversation.memberCount,
+ unreadCount = conversation.unreadCount,
+ onClick = { onConversationClick(conversation.id) },
+ )
+ HorizontalDivider(
+ modifier = Modifier.padding(start = 80.dp),
+ color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f),
+ )
+ }
+ }
+}
+
+@Composable
+private fun EmptyStateView(modifier: Modifier = Modifier) {
+ androidx.compose.foundation.layout.Column(
+ modifier = modifier.padding(32.dp),
+ horizontalAlignment = Alignment.CenterHorizontally,
+ ) {
+ Text(
+ text = "No Conversations Yet",
+ style = MaterialTheme.typography.headlineSmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+ Text(
+ text = "Start a new conversation using the + button or from the menu",
+ style = MaterialTheme.typography.bodyMedium,
+ color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f),
+ textAlign = TextAlign.Center,
+ modifier = Modifier.padding(top = 8.dp),
+ )
+ }
+}
+
+@Preview(showBackground = true)
+@Composable
+private fun MainScreenPreview() {
+ XMTPTheme {
+ MainScreen(
+ conversations = listOf(
+ ConversationItem(
+ id = "1",
+ name = "0x1234...5678",
+ lastMessage = "Hey, how are you?",
+ timestamp = "10:30 AM",
+ isGroup = false,
+ ),
+ ConversationItem(
+ id = "2",
+ name = "XMTP Dev Team",
+ lastMessage = "Alice: Let's ship this!",
+ timestamp = "Yesterday",
+ isGroup = true,
+ memberCount = 5,
+ unreadCount = 3,
+ ),
+ ),
+ drawerState = DrawerState(
+ walletAddress = "0x1234567890abcdef1234567890abcdef12345678",
+ environment = "DEV",
+ isLogsEnabled = false,
+ hideDeletedMessages = false,
+ ),
+ )
+ }
+}
+
+@Preview(showBackground = true)
+@Composable
+private fun MainScreenEmptyPreview() {
+ XMTPTheme {
+ MainScreen(
+ conversations = emptyList(),
+ drawerState = DrawerState(
+ walletAddress = "0x1234567890abcdef1234567890abcdef12345678",
+ environment = "DEV",
+ isLogsEnabled = false,
+ hideDeletedMessages = false,
+ ),
+ )
+ }
+}
+
+@Preview(showBackground = true)
+@Composable
+private fun MainScreenLoadingPreview() {
+ XMTPTheme {
+ MainScreen(
+ conversations = emptyList(),
+ drawerState = DrawerState(
+ walletAddress = "0x1234567890abcdef1234567890abcdef12345678",
+ environment = "DEV",
+ isLogsEnabled = false,
+ hideDeletedMessages = false,
+ ),
+ isLoading = true,
+ )
+ }
+}
diff --git a/example/src/main/java/org/xmtp/android/example/ui/screens/NewConversationScreen.kt b/example/src/main/java/org/xmtp/android/example/ui/screens/NewConversationScreen.kt
new file mode 100644
index 000000000..ec12c2fd8
--- /dev/null
+++ b/example/src/main/java/org/xmtp/android/example/ui/screens/NewConversationScreen.kt
@@ -0,0 +1,393 @@
+package org.xmtp.android.example.ui.screens
+
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.ExperimentalLayoutApi
+import androidx.compose.foundation.layout.FlowRow
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.lazy.LazyColumn
+import androidx.compose.foundation.lazy.items
+import androidx.compose.foundation.rememberScrollState
+import androidx.compose.foundation.verticalScroll
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.Add
+import androidx.compose.material.icons.filled.Clear
+import androidx.compose.material.icons.filled.Close
+import androidx.compose.material.icons.filled.Groups
+import androidx.compose.material.icons.filled.Person
+import androidx.compose.material3.Button
+import androidx.compose.material3.Card
+import androidx.compose.material3.CardDefaults
+import androidx.compose.material3.CircularProgressIndicator
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.FilterChip
+import androidx.compose.material3.FilterChipDefaults
+import androidx.compose.material3.Icon
+import androidx.compose.material3.IconButton
+import androidx.compose.material3.InputChip
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.OutlinedTextField
+import androidx.compose.material3.Scaffold
+import androidx.compose.material3.SegmentedButton
+import androidx.compose.material3.SegmentedButtonDefaults
+import androidx.compose.material3.SingleChoiceSegmentedButtonRow
+import androidx.compose.material3.Text
+import androidx.compose.material3.TopAppBar
+import androidx.compose.material3.TopAppBarDefaults
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.tooling.preview.Preview
+import androidx.compose.ui.unit.dp
+import org.xmtp.android.example.ui.components.ContactRow
+import org.xmtp.android.example.ui.components.RecentContactData
+import org.xmtp.android.example.ui.theme.XMTPTheme
+
+enum class ConversationType {
+ DM,
+ GROUP,
+}
+
+@OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class)
+@Composable
+fun NewConversationScreen(
+ addressInput: String,
+ groupName: String = "",
+ selectedMembers: List = emptyList(),
+ recentContacts: List = emptyList(),
+ conversationType: ConversationType = ConversationType.DM,
+ isLoading: Boolean = false,
+ canCreate: Boolean = false,
+ modifier: Modifier = Modifier,
+ onAddressChange: (String) -> Unit = {},
+ onGroupNameChange: (String) -> Unit = {},
+ onConversationTypeChange: (ConversationType) -> Unit = {},
+ onAddMember: () -> Unit = {},
+ onRemoveMember: (String) -> Unit = {},
+ onRecentContactClick: (String) -> Unit = {},
+ onClose: () -> Unit = {},
+ onCreate: () -> Unit = {},
+) {
+ Scaffold(
+ modifier = modifier.fillMaxSize(),
+ topBar = {
+ TopAppBar(
+ title = { Text("New Message") },
+ navigationIcon = {
+ IconButton(onClick = onClose) {
+ Icon(
+ imageVector = Icons.Default.Close,
+ contentDescription = "Close",
+ )
+ }
+ },
+ colors = TopAppBarDefaults.topAppBarColors(
+ containerColor = MaterialTheme.colorScheme.primary,
+ titleContentColor = Color.White,
+ navigationIconContentColor = Color.White,
+ ),
+ )
+ },
+ bottomBar = {
+ Button(
+ onClick = onCreate,
+ enabled = canCreate && !isLoading,
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(16.dp)
+ .height(52.dp),
+ ) {
+ if (isLoading) {
+ CircularProgressIndicator(
+ modifier = Modifier.size(24.dp),
+ color = MaterialTheme.colorScheme.onPrimary,
+ )
+ } else {
+ Text(
+ text = when (conversationType) {
+ ConversationType.DM -> "Start Conversation"
+ ConversationType.GROUP -> "Create Group"
+ },
+ )
+ }
+ }
+ },
+ ) { paddingValues ->
+ Column(
+ modifier = Modifier
+ .fillMaxSize()
+ .padding(paddingValues)
+ .verticalScroll(rememberScrollState()),
+ ) {
+ Spacer(modifier = Modifier.height(16.dp))
+
+ // Conversation type selector
+ SingleChoiceSegmentedButtonRow(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(horizontal = 16.dp),
+ ) {
+ SegmentedButton(
+ selected = conversationType == ConversationType.DM,
+ onClick = { onConversationTypeChange(ConversationType.DM) },
+ shape = SegmentedButtonDefaults.itemShape(index = 0, count = 2),
+ icon = {
+ Icon(
+ imageVector = Icons.Default.Person,
+ contentDescription = "DM",
+ modifier = Modifier.size(18.dp),
+ )
+ },
+ ) {
+ Text("Direct Message")
+ }
+ SegmentedButton(
+ selected = conversationType == ConversationType.GROUP,
+ onClick = { onConversationTypeChange(ConversationType.GROUP) },
+ shape = SegmentedButtonDefaults.itemShape(index = 1, count = 2),
+ icon = {
+ Icon(
+ imageVector = Icons.Default.Groups,
+ contentDescription = "Group",
+ modifier = Modifier.size(18.dp),
+ )
+ },
+ ) {
+ Text("Group Chat")
+ }
+ }
+
+ // Group name input (only for groups)
+ if (conversationType == ConversationType.GROUP) {
+ Spacer(modifier = Modifier.height(16.dp))
+ Card(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(horizontal = 16.dp),
+ colors = CardDefaults.cardColors(
+ containerColor = MaterialTheme.colorScheme.surfaceVariant,
+ ),
+ ) {
+ OutlinedTextField(
+ value = groupName,
+ onValueChange = onGroupNameChange,
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(4.dp),
+ placeholder = { Text("Group name (optional)") },
+ leadingIcon = {
+ Icon(
+ imageVector = Icons.Default.Groups,
+ contentDescription = "Group",
+ )
+ },
+ singleLine = true,
+ )
+ }
+
+ // Selected members chips
+ if (selectedMembers.isNotEmpty()) {
+ Spacer(modifier = Modifier.height(12.dp))
+ FlowRow(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(horizontal = 16.dp),
+ ) {
+ selectedMembers.forEach { member ->
+ InputChip(
+ selected = false,
+ onClick = { onRemoveMember(member) },
+ label = {
+ Text(
+ text = if (member.length > 12) {
+ member.take(6) + "..." + member.takeLast(4)
+ } else {
+ member
+ },
+ )
+ },
+ trailingIcon = {
+ Icon(
+ imageVector = Icons.Default.Close,
+ contentDescription = "Remove",
+ modifier = Modifier.size(18.dp),
+ )
+ },
+ modifier = Modifier.padding(end = 8.dp, bottom = 4.dp),
+ )
+ }
+ }
+ }
+ }
+
+ Spacer(modifier = Modifier.height(16.dp))
+
+ // Address input
+ Card(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(horizontal = 16.dp),
+ colors = CardDefaults.cardColors(
+ containerColor = MaterialTheme.colorScheme.surfaceVariant,
+ ),
+ ) {
+ Row(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(4.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ OutlinedTextField(
+ value = addressInput,
+ onValueChange = onAddressChange,
+ modifier = Modifier.weight(1f),
+ placeholder = { Text("Enter wallet address") },
+ leadingIcon = {
+ Icon(
+ imageVector = Icons.Default.Person,
+ contentDescription = "Address",
+ )
+ },
+ trailingIcon = {
+ if (addressInput.isNotEmpty()) {
+ IconButton(onClick = { onAddressChange("") }) {
+ Icon(
+ imageVector = if (conversationType == ConversationType.GROUP) {
+ Icons.Default.Add
+ } else {
+ Icons.Default.Clear
+ },
+ contentDescription = if (conversationType == ConversationType.GROUP) {
+ "Add member"
+ } else {
+ "Clear"
+ },
+ )
+ }
+ }
+ },
+ singleLine = true,
+ )
+
+ if (conversationType == ConversationType.GROUP && addressInput.isNotEmpty()) {
+ IconButton(onClick = onAddMember) {
+ Icon(
+ imageVector = Icons.Default.Add,
+ contentDescription = "Add member",
+ tint = MaterialTheme.colorScheme.primary,
+ )
+ }
+ }
+ }
+ }
+
+ // Helper text
+ Text(
+ text = "Enter a valid Ethereum address (0x...)",
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ modifier = Modifier.padding(horizontal = 20.dp, vertical = 8.dp),
+ )
+
+ if (conversationType == ConversationType.GROUP && selectedMembers.isNotEmpty()) {
+ Text(
+ text = "${selectedMembers.size} member(s) added",
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.primary,
+ modifier = Modifier.padding(horizontal = 20.dp),
+ )
+ }
+
+ // Recent contacts section
+ if (recentContacts.isNotEmpty()) {
+ Spacer(modifier = Modifier.height(24.dp))
+ Text(
+ text = "RECENT CONTACTS",
+ style = MaterialTheme.typography.labelMedium,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ modifier = Modifier.padding(horizontal = 16.dp),
+ )
+ Spacer(modifier = Modifier.height(8.dp))
+
+ recentContacts.forEach { contact ->
+ ContactRow(
+ contact = contact,
+ isGroupMode = conversationType == ConversationType.GROUP,
+ isSelected = selectedMembers.contains(contact.address),
+ onClick = { onRecentContactClick(contact.address) },
+ onAddClick = { onRecentContactClick(contact.address) },
+ )
+ }
+ }
+ }
+ }
+}
+
+@Preview(showBackground = true)
+@Composable
+private fun NewConversationScreenDMPreview() {
+ XMTPTheme {
+ NewConversationScreen(
+ addressInput = "0x1234567890abcdef",
+ conversationType = ConversationType.DM,
+ recentContacts = listOf(
+ RecentContactData(
+ address = "0xabcd1234efgh5678",
+ lastActivityTimeNs = System.currentTimeMillis() * 1_000_000,
+ ),
+ RecentContactData(
+ address = "0x9876543210fedcba",
+ lastActivityTimeNs = (System.currentTimeMillis() - 3600000) * 1_000_000,
+ ),
+ ),
+ canCreate = true,
+ )
+ }
+}
+
+@Preview(showBackground = true)
+@Composable
+private fun NewConversationScreenGroupPreview() {
+ XMTPTheme {
+ NewConversationScreen(
+ addressInput = "",
+ groupName = "XMTP Dev Team",
+ conversationType = ConversationType.GROUP,
+ selectedMembers = listOf(
+ "0x1234567890abcdef1234567890abcdef12345678",
+ "0xabcdef1234567890abcdef1234567890abcdef12",
+ ),
+ recentContacts = listOf(
+ RecentContactData(
+ address = "0xabcd1234efgh5678",
+ lastActivityTimeNs = System.currentTimeMillis() * 1_000_000,
+ ),
+ ),
+ canCreate = true,
+ )
+ }
+}
+
+@Preview(showBackground = true)
+@Composable
+private fun NewConversationScreenLoadingPreview() {
+ XMTPTheme {
+ NewConversationScreen(
+ addressInput = "0x1234567890abcdef",
+ conversationType = ConversationType.DM,
+ isLoading = true,
+ canCreate = true,
+ )
+ }
+}
diff --git a/example/src/main/java/org/xmtp/android/example/ui/screens/ProfileScreen.kt b/example/src/main/java/org/xmtp/android/example/ui/screens/ProfileScreen.kt
new file mode 100644
index 000000000..bce72ca62
--- /dev/null
+++ b/example/src/main/java/org/xmtp/android/example/ui/screens/ProfileScreen.kt
@@ -0,0 +1,251 @@
+package org.xmtp.android.example.ui.screens
+
+import android.content.ClipData
+import android.content.ClipboardManager
+import android.content.Context
+import android.widget.Toast
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.rememberScrollState
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.foundation.verticalScroll
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.ContentCopy
+import androidx.compose.material.icons.filled.Logout
+import androidx.compose.material.icons.filled.QrCode
+import androidx.compose.material3.Button
+import androidx.compose.material3.ButtonDefaults
+import androidx.compose.material3.Card
+import androidx.compose.material3.CardDefaults
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.Icon
+import androidx.compose.material3.IconButton
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Scaffold
+import androidx.compose.material3.Text
+import androidx.compose.material3.TopAppBar
+import androidx.compose.material3.TopAppBarDefaults
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.text.font.FontFamily
+import androidx.compose.ui.text.style.TextOverflow
+import androidx.compose.ui.tooling.preview.Preview
+import androidx.compose.ui.unit.dp
+import org.xmtp.android.example.ui.components.Avatar
+import org.xmtp.android.example.ui.theme.XMTPTheme
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+fun ProfileScreen(
+ walletAddress: String,
+ inboxId: String,
+ installationId: String,
+ modifier: Modifier = Modifier,
+ onLogout: () -> Unit = {},
+ onShowQR: () -> Unit = {},
+) {
+ val context = LocalContext.current
+
+ Scaffold(
+ modifier = modifier.fillMaxSize(),
+ topBar = {
+ TopAppBar(
+ title = {
+ Text(
+ text = "Profile",
+ style = MaterialTheme.typography.headlineMedium,
+ )
+ },
+ colors =
+ TopAppBarDefaults.topAppBarColors(
+ containerColor = MaterialTheme.colorScheme.surface,
+ ),
+ )
+ },
+ ) { paddingValues ->
+ Column(
+ modifier =
+ Modifier
+ .fillMaxSize()
+ .padding(paddingValues)
+ .verticalScroll(rememberScrollState())
+ .padding(16.dp),
+ horizontalAlignment = Alignment.CenterHorizontally,
+ ) {
+ // Avatar
+ Avatar(
+ name = walletAddress,
+ size = 100.dp,
+ )
+
+ Spacer(modifier = Modifier.height(16.dp))
+
+ // Wallet Address
+ Text(
+ text = abbreviateAddress(walletAddress),
+ style = MaterialTheme.typography.headlineSmall,
+ color = MaterialTheme.colorScheme.onSurface,
+ )
+
+ Spacer(modifier = Modifier.height(24.dp))
+
+ // Info Cards
+ InfoCard(
+ title = "Wallet Address",
+ value = walletAddress,
+ onCopy = { copyToClipboard(context, "Wallet Address", walletAddress) },
+ )
+
+ Spacer(modifier = Modifier.height(12.dp))
+
+ InfoCard(
+ title = "Inbox ID",
+ value = inboxId,
+ onCopy = { copyToClipboard(context, "Inbox ID", inboxId) },
+ )
+
+ Spacer(modifier = Modifier.height(12.dp))
+
+ InfoCard(
+ title = "Installation ID",
+ value = installationId,
+ onCopy = { copyToClipboard(context, "Installation ID", installationId) },
+ )
+
+ Spacer(modifier = Modifier.height(24.dp))
+
+ // QR Code Button
+ Button(
+ onClick = onShowQR,
+ modifier = Modifier.fillMaxWidth(),
+ shape = RoundedCornerShape(12.dp),
+ colors =
+ ButtonDefaults.buttonColors(
+ containerColor = MaterialTheme.colorScheme.secondaryContainer,
+ contentColor = MaterialTheme.colorScheme.onSecondaryContainer,
+ ),
+ ) {
+ Icon(
+ imageVector = Icons.Default.QrCode,
+ contentDescription = null,
+ modifier = Modifier.size(20.dp),
+ )
+ Text(
+ text = "Show QR Code",
+ modifier = Modifier.padding(start = 8.dp),
+ )
+ }
+
+ Spacer(modifier = Modifier.weight(1f))
+
+ // Logout Button
+ Button(
+ onClick = onLogout,
+ modifier = Modifier.fillMaxWidth(),
+ shape = RoundedCornerShape(12.dp),
+ colors =
+ ButtonDefaults.buttonColors(
+ containerColor = MaterialTheme.colorScheme.errorContainer,
+ contentColor = MaterialTheme.colorScheme.onErrorContainer,
+ ),
+ ) {
+ Icon(
+ imageVector = Icons.Default.Logout,
+ contentDescription = null,
+ modifier = Modifier.size(20.dp),
+ )
+ Text(
+ text = "Logout",
+ modifier = Modifier.padding(start = 8.dp),
+ )
+ }
+ }
+ }
+}
+
+@Composable
+private fun InfoCard(
+ title: String,
+ value: String,
+ onCopy: () -> Unit,
+) {
+ Card(
+ modifier = Modifier.fillMaxWidth(),
+ shape = RoundedCornerShape(12.dp),
+ colors =
+ CardDefaults.cardColors(
+ containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f),
+ ),
+ ) {
+ Row(
+ modifier =
+ Modifier
+ .fillMaxWidth()
+ .padding(16.dp),
+ horizontalArrangement = Arrangement.SpaceBetween,
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ Column(modifier = Modifier.weight(1f)) {
+ Text(
+ text = title,
+ style = MaterialTheme.typography.labelMedium,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+ Text(
+ text = value,
+ style = MaterialTheme.typography.bodyMedium,
+ fontFamily = FontFamily.Monospace,
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis,
+ modifier = Modifier.padding(top = 4.dp),
+ )
+ }
+ IconButton(onClick = onCopy) {
+ Icon(
+ imageVector = Icons.Default.ContentCopy,
+ contentDescription = "Copy",
+ tint = MaterialTheme.colorScheme.primary,
+ )
+ }
+ }
+ }
+}
+
+private fun abbreviateAddress(address: String): String =
+ if (address.length > 12) {
+ "${address.take(6)}...${address.takeLast(4)}"
+ } else {
+ address
+ }
+
+private fun copyToClipboard(
+ context: Context,
+ label: String,
+ text: String,
+) {
+ val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
+ val clip = ClipData.newPlainText(label, text)
+ clipboard.setPrimaryClip(clip)
+ Toast.makeText(context, "$label copied to clipboard", Toast.LENGTH_SHORT).show()
+}
+
+@Preview(showBackground = true)
+@Composable
+private fun ProfileScreenPreview() {
+ XMTPTheme {
+ ProfileScreen(
+ walletAddress = "0x1234567890123456789012345678901234567890",
+ inboxId = "abcdef1234567890abcdef1234567890",
+ installationId = "installation-id-12345",
+ )
+ }
+}
diff --git a/example/src/main/java/org/xmtp/android/example/ui/screens/SettingsScreen.kt b/example/src/main/java/org/xmtp/android/example/ui/screens/SettingsScreen.kt
new file mode 100644
index 000000000..504dd4d71
--- /dev/null
+++ b/example/src/main/java/org/xmtp/android/example/ui/screens/SettingsScreen.kt
@@ -0,0 +1,313 @@
+package org.xmtp.android.example.ui.screens
+
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.layout.width
+import androidx.compose.foundation.rememberScrollState
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.foundation.verticalScroll
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight
+import androidx.compose.material.icons.filled.BugReport
+import androidx.compose.material.icons.filled.DarkMode
+import androidx.compose.material.icons.filled.DeleteForever
+import androidx.compose.material.icons.filled.Info
+import androidx.compose.material.icons.filled.Notifications
+import androidx.compose.material.icons.filled.Security
+import androidx.compose.material.icons.filled.Storage
+import androidx.compose.material3.Card
+import androidx.compose.material3.CardDefaults
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.Icon
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Scaffold
+import androidx.compose.material3.Switch
+import androidx.compose.material3.Text
+import androidx.compose.material3.TopAppBar
+import androidx.compose.material3.TopAppBarDefaults
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.graphics.vector.ImageVector
+import androidx.compose.ui.tooling.preview.Preview
+import androidx.compose.ui.unit.dp
+import org.xmtp.android.example.ui.theme.XMTPTheme
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+fun SettingsScreen(
+ hideDeletedMessages: Boolean = false,
+ notificationsEnabled: Boolean = true,
+ darkModeEnabled: Boolean = false,
+ modifier: Modifier = Modifier,
+ onHideDeletedMessagesChange: (Boolean) -> Unit = {},
+ onNotificationsChange: (Boolean) -> Unit = {},
+ onDarkModeChange: (Boolean) -> Unit = {},
+ onPrivacyClick: () -> Unit = {},
+ onStorageClick: () -> Unit = {},
+ onViewLogsClick: () -> Unit = {},
+ onAboutClick: () -> Unit = {},
+ onClearDataClick: () -> Unit = {},
+) {
+ Scaffold(
+ modifier = modifier.fillMaxSize(),
+ topBar = {
+ TopAppBar(
+ title = {
+ Text(
+ text = "Settings",
+ style = MaterialTheme.typography.headlineMedium,
+ )
+ },
+ colors =
+ TopAppBarDefaults.topAppBarColors(
+ containerColor = MaterialTheme.colorScheme.surface,
+ ),
+ )
+ },
+ ) { paddingValues ->
+ Column(
+ modifier =
+ Modifier
+ .fillMaxSize()
+ .padding(paddingValues)
+ .verticalScroll(rememberScrollState())
+ .padding(16.dp),
+ ) {
+ // Appearance Section
+ SectionTitle(text = "Appearance")
+
+ SettingsCard {
+ SettingsToggleRow(
+ icon = Icons.Default.DarkMode,
+ title = "Dark Mode",
+ subtitle = "Use dark theme",
+ isChecked = darkModeEnabled,
+ onCheckedChange = onDarkModeChange,
+ )
+ }
+
+ Spacer(modifier = Modifier.height(24.dp))
+
+ // Messages Section
+ SectionTitle(text = "Messages")
+
+ SettingsCard {
+ SettingsToggleRow(
+ icon = Icons.Default.DeleteForever,
+ title = "Hide Deleted Messages",
+ subtitle = "Don't show deleted messages in conversations",
+ isChecked = hideDeletedMessages,
+ onCheckedChange = onHideDeletedMessagesChange,
+ )
+ }
+
+ Spacer(modifier = Modifier.height(24.dp))
+
+ // Notifications Section
+ SectionTitle(text = "Notifications")
+
+ SettingsCard {
+ SettingsToggleRow(
+ icon = Icons.Default.Notifications,
+ title = "Push Notifications",
+ subtitle = "Receive notifications for new messages",
+ isChecked = notificationsEnabled,
+ onCheckedChange = onNotificationsChange,
+ )
+ }
+
+ Spacer(modifier = Modifier.height(24.dp))
+
+ // General Section
+ SectionTitle(text = "General")
+
+ SettingsCard {
+ SettingsNavigationRow(
+ icon = Icons.Default.Security,
+ title = "Privacy & Security",
+ onClick = onPrivacyClick,
+ )
+ SettingsNavigationRow(
+ icon = Icons.Default.Storage,
+ title = "Storage",
+ onClick = onStorageClick,
+ )
+ SettingsNavigationRow(
+ icon = Icons.Default.BugReport,
+ title = "View Logs",
+ onClick = onViewLogsClick,
+ )
+ SettingsNavigationRow(
+ icon = Icons.Default.Info,
+ title = "About",
+ onClick = onAboutClick,
+ )
+ }
+
+ Spacer(modifier = Modifier.height(24.dp))
+
+ // Danger Zone
+ SectionTitle(text = "Danger Zone", color = MaterialTheme.colorScheme.error)
+
+ SettingsCard(
+ containerColor = MaterialTheme.colorScheme.errorContainer.copy(alpha = 0.3f),
+ ) {
+ SettingsNavigationRow(
+ icon = Icons.Default.DeleteForever,
+ title = "Clear All Data",
+ iconTint = MaterialTheme.colorScheme.error,
+ onClick = onClearDataClick,
+ )
+ }
+
+ Spacer(modifier = Modifier.height(32.dp))
+
+ // Version info
+ Text(
+ text = "XMTP Example App v1.0.0",
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ modifier = Modifier.align(Alignment.CenterHorizontally),
+ )
+ }
+ }
+}
+
+@Composable
+private fun SectionTitle(
+ text: String,
+ color: Color = MaterialTheme.colorScheme.primary,
+) {
+ Text(
+ text = text,
+ style = MaterialTheme.typography.titleSmall,
+ color = color,
+ modifier = Modifier.padding(bottom = 8.dp, start = 4.dp),
+ )
+}
+
+@Composable
+private fun SettingsCard(
+ containerColor: Color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f),
+ content: @Composable () -> Unit,
+) {
+ Card(
+ modifier = Modifier.fillMaxWidth(),
+ shape = RoundedCornerShape(12.dp),
+ colors = CardDefaults.cardColors(containerColor = containerColor),
+ ) {
+ Column {
+ content()
+ }
+ }
+}
+
+@Composable
+private fun SettingsToggleRow(
+ icon: ImageVector,
+ title: String,
+ subtitle: String,
+ isChecked: Boolean,
+ onCheckedChange: (Boolean) -> Unit,
+) {
+ Row(
+ modifier =
+ Modifier
+ .fillMaxWidth()
+ .clickable { onCheckedChange(!isChecked) }
+ .padding(16.dp),
+ horizontalArrangement = Arrangement.SpaceBetween,
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ Row(
+ verticalAlignment = Alignment.CenterVertically,
+ modifier = Modifier.weight(1f),
+ ) {
+ Icon(
+ imageVector = icon,
+ contentDescription = null,
+ tint = MaterialTheme.colorScheme.primary,
+ modifier = Modifier.size(24.dp),
+ )
+ Spacer(modifier = Modifier.width(16.dp))
+ Column {
+ Text(
+ text = title,
+ style = MaterialTheme.typography.bodyLarge,
+ )
+ Text(
+ text = subtitle,
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+ }
+ }
+ Switch(
+ checked = isChecked,
+ onCheckedChange = onCheckedChange,
+ )
+ }
+}
+
+@Composable
+private fun SettingsNavigationRow(
+ icon: ImageVector,
+ title: String,
+ iconTint: Color = MaterialTheme.colorScheme.primary,
+ onClick: () -> Unit,
+) {
+ Row(
+ modifier =
+ Modifier
+ .fillMaxWidth()
+ .clickable(onClick = onClick)
+ .padding(16.dp),
+ horizontalArrangement = Arrangement.SpaceBetween,
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ Row(verticalAlignment = Alignment.CenterVertically) {
+ Icon(
+ imageVector = icon,
+ contentDescription = null,
+ tint = iconTint,
+ modifier = Modifier.size(24.dp),
+ )
+ Spacer(modifier = Modifier.width(16.dp))
+ Text(
+ text = title,
+ style = MaterialTheme.typography.bodyLarge,
+ )
+ }
+ Icon(
+ imageVector = Icons.AutoMirrored.Filled.KeyboardArrowRight,
+ contentDescription = null,
+ tint = MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+ }
+}
+
+@Preview(showBackground = true)
+@Composable
+private fun SettingsScreenPreview() {
+ XMTPTheme {
+ SettingsScreen()
+ }
+}
+
+@Preview(showBackground = true)
+@Composable
+private fun SettingsScreenDarkPreview() {
+ XMTPTheme(darkTheme = true) {
+ SettingsScreen(darkModeEnabled = true)
+ }
+}
diff --git a/example/src/main/java/org/xmtp/android/example/ui/sheets/AttachmentPickerSheet.kt b/example/src/main/java/org/xmtp/android/example/ui/sheets/AttachmentPickerSheet.kt
new file mode 100644
index 000000000..3e0d25058
--- /dev/null
+++ b/example/src/main/java/org/xmtp/android/example/ui/sheets/AttachmentPickerSheet.kt
@@ -0,0 +1,219 @@
+package org.xmtp.android.example.ui.sheets
+
+import androidx.compose.foundation.background
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.shape.CircleShape
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.AttachFile
+import androidx.compose.material.icons.filled.CameraAlt
+import androidx.compose.material.icons.filled.Gif
+import androidx.compose.material.icons.filled.Image
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.Icon
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.ModalBottomSheet
+import androidx.compose.material3.Text
+import androidx.compose.material3.rememberModalBottomSheetState
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.graphics.vector.ImageVector
+import androidx.compose.ui.tooling.preview.Preview
+import androidx.compose.ui.unit.dp
+import org.xmtp.android.example.ui.theme.XMTPTheme
+
+sealed class AttachmentOption {
+ object Camera : AttachmentOption()
+ object Gallery : AttachmentOption()
+ object File : AttachmentOption()
+ object Gif : AttachmentOption()
+}
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+fun AttachmentPickerSheet(
+ onDismiss: () -> Unit,
+ onOptionSelected: (AttachmentOption) -> Unit,
+ modifier: Modifier = Modifier,
+) {
+ val sheetState = rememberModalBottomSheetState()
+
+ ModalBottomSheet(
+ onDismissRequest = onDismiss,
+ sheetState = sheetState,
+ modifier = modifier,
+ containerColor = MaterialTheme.colorScheme.surface,
+ ) {
+ Column(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(horizontal = 16.dp)
+ .padding(bottom = 32.dp),
+ ) {
+ Text(
+ text = "Send Attachment",
+ style = MaterialTheme.typography.titleMedium,
+ modifier = Modifier.padding(bottom = 16.dp),
+ )
+
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.SpaceEvenly,
+ ) {
+ AttachmentOptionItem(
+ icon = Icons.Default.CameraAlt,
+ label = "Camera",
+ backgroundColor = Color(0xFFE3F2FD),
+ iconTint = Color(0xFF1976D2),
+ onClick = {
+ onOptionSelected(AttachmentOption.Camera)
+ onDismiss()
+ },
+ )
+
+ AttachmentOptionItem(
+ icon = Icons.Default.Image,
+ label = "Gallery",
+ backgroundColor = Color(0xFFE8F5E9),
+ iconTint = Color(0xFF388E3C),
+ onClick = {
+ onOptionSelected(AttachmentOption.Gallery)
+ onDismiss()
+ },
+ )
+
+ AttachmentOptionItem(
+ icon = Icons.Default.AttachFile,
+ label = "File",
+ backgroundColor = Color(0xFFFFF3E0),
+ iconTint = Color(0xFFF57C00),
+ onClick = {
+ onOptionSelected(AttachmentOption.File)
+ onDismiss()
+ },
+ )
+
+ AttachmentOptionItem(
+ icon = Icons.Default.Gif,
+ label = "GIF",
+ backgroundColor = Color(0xFFFCE4EC),
+ iconTint = Color(0xFFC2185B),
+ onClick = {
+ onOptionSelected(AttachmentOption.Gif)
+ onDismiss()
+ },
+ )
+ }
+
+ Spacer(modifier = Modifier.height(16.dp))
+ }
+ }
+}
+
+@Composable
+private fun AttachmentOptionItem(
+ icon: ImageVector,
+ label: String,
+ backgroundColor: Color,
+ iconTint: Color,
+ onClick: () -> Unit,
+ modifier: Modifier = Modifier,
+) {
+ Column(
+ modifier = modifier
+ .clip(MaterialTheme.shapes.medium)
+ .clickable(onClick = onClick)
+ .padding(12.dp),
+ horizontalAlignment = Alignment.CenterHorizontally,
+ ) {
+ Box(
+ modifier = Modifier
+ .size(56.dp)
+ .clip(CircleShape)
+ .background(backgroundColor),
+ contentAlignment = Alignment.Center,
+ ) {
+ Icon(
+ imageVector = icon,
+ contentDescription = label,
+ tint = iconTint,
+ modifier = Modifier.size(28.dp),
+ )
+ }
+
+ Spacer(modifier = Modifier.height(8.dp))
+
+ Text(
+ text = label,
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.onSurface,
+ )
+ }
+}
+
+@Preview(showBackground = true)
+@Composable
+private fun AttachmentPickerContentPreview() {
+ XMTPTheme {
+ Column(
+ modifier = Modifier
+ .fillMaxWidth()
+ .background(MaterialTheme.colorScheme.surface)
+ .padding(16.dp),
+ ) {
+ Text(
+ text = "Send Attachment",
+ style = MaterialTheme.typography.titleMedium,
+ modifier = Modifier.padding(bottom = 16.dp),
+ )
+
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.SpaceEvenly,
+ ) {
+ AttachmentOptionItem(
+ icon = Icons.Default.CameraAlt,
+ label = "Camera",
+ backgroundColor = Color(0xFFE3F2FD),
+ iconTint = Color(0xFF1976D2),
+ onClick = {},
+ )
+
+ AttachmentOptionItem(
+ icon = Icons.Default.Image,
+ label = "Gallery",
+ backgroundColor = Color(0xFFE8F5E9),
+ iconTint = Color(0xFF388E3C),
+ onClick = {},
+ )
+
+ AttachmentOptionItem(
+ icon = Icons.Default.AttachFile,
+ label = "File",
+ backgroundColor = Color(0xFFFFF3E0),
+ iconTint = Color(0xFFF57C00),
+ onClick = {},
+ )
+
+ AttachmentOptionItem(
+ icon = Icons.Default.Gif,
+ label = "GIF",
+ backgroundColor = Color(0xFFFCE4EC),
+ iconTint = Color(0xFFC2185B),
+ onClick = {},
+ )
+ }
+ }
+ }
+}
diff --git a/example/src/main/java/org/xmtp/android/example/ui/sheets/LogViewerSheet.kt b/example/src/main/java/org/xmtp/android/example/ui/sheets/LogViewerSheet.kt
new file mode 100644
index 000000000..90df229a4
--- /dev/null
+++ b/example/src/main/java/org/xmtp/android/example/ui/sheets/LogViewerSheet.kt
@@ -0,0 +1,198 @@
+package org.xmtp.android.example.ui.sheets
+
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.width
+import androidx.compose.foundation.lazy.LazyColumn
+import androidx.compose.foundation.lazy.items
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.Description
+import androidx.compose.material.icons.filled.Share
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.HorizontalDivider
+import androidx.compose.material3.Icon
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.ModalBottomSheet
+import androidx.compose.material3.Text
+import androidx.compose.material3.rememberModalBottomSheetState
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.text.style.TextOverflow
+import androidx.compose.ui.tooling.preview.Preview
+import androidx.compose.ui.unit.dp
+import org.xmtp.android.example.ui.theme.XMTPTheme
+import java.io.File
+
+data class LogFileInfo(
+ val file: File,
+ val name: String,
+ val size: String,
+)
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+fun LogViewerSheet(
+ logFiles: List,
+ onDismiss: () -> Unit,
+ onShareFile: (File) -> Unit,
+ modifier: Modifier = Modifier,
+) {
+ val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
+
+ ModalBottomSheet(
+ onDismissRequest = onDismiss,
+ sheetState = sheetState,
+ modifier = modifier,
+ ) {
+ Column(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(horizontal = 24.dp)
+ .padding(bottom = 32.dp),
+ ) {
+ // Title
+ Text(
+ text = "Log Files",
+ style = MaterialTheme.typography.headlineSmall,
+ color = MaterialTheme.colorScheme.onSurface,
+ )
+
+ Spacer(modifier = Modifier.height(16.dp))
+
+ if (logFiles.isEmpty()) {
+ // Empty state
+ Box(
+ modifier = Modifier
+ .fillMaxWidth()
+ .height(200.dp),
+ contentAlignment = Alignment.Center,
+ ) {
+ Column(
+ horizontalAlignment = Alignment.CenterHorizontally,
+ ) {
+ Icon(
+ imageVector = Icons.Default.Description,
+ contentDescription = null,
+ tint = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f),
+ )
+ Spacer(modifier = Modifier.height(16.dp))
+ Text(
+ text = "No log files found",
+ style = MaterialTheme.typography.bodyLarge,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+ Spacer(modifier = Modifier.height(8.dp))
+ Text(
+ text = "Enable logs to start collecting debug information",
+ style = MaterialTheme.typography.bodyMedium,
+ color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f),
+ )
+ }
+ }
+ } else {
+ // Log files list
+ LazyColumn(
+ modifier = Modifier.height(300.dp),
+ ) {
+ items(logFiles) { logFile ->
+ LogFileRow(
+ logFileInfo = logFile,
+ onClick = { onShareFile(logFile.file) },
+ )
+ HorizontalDivider()
+ }
+ }
+ }
+ }
+ }
+}
+
+@Composable
+private fun LogFileRow(
+ logFileInfo: LogFileInfo,
+ onClick: () -> Unit,
+ modifier: Modifier = Modifier,
+) {
+ Row(
+ modifier = modifier
+ .fillMaxWidth()
+ .clickable(onClick = onClick)
+ .padding(vertical = 12.dp),
+ horizontalArrangement = Arrangement.SpaceBetween,
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ Row(
+ modifier = Modifier.weight(1f),
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ Icon(
+ imageVector = Icons.Default.Description,
+ contentDescription = null,
+ tint = MaterialTheme.colorScheme.primary,
+ )
+ Spacer(modifier = Modifier.width(12.dp))
+ Column {
+ Text(
+ text = logFileInfo.name,
+ style = MaterialTheme.typography.bodyMedium,
+ color = MaterialTheme.colorScheme.onSurface,
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis,
+ )
+ Text(
+ text = logFileInfo.size,
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+ }
+ }
+ Icon(
+ imageVector = Icons.Default.Share,
+ contentDescription = "Share log file",
+ tint = MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+ }
+}
+
+fun formatFileSize(size: Long): String =
+ when {
+ size < 1024 -> "$size B"
+ size < 1024 * 1024 -> "${size / 1024} KB"
+ else -> "${size / (1024 * 1024)} MB"
+ }
+
+@Preview(showBackground = true)
+@Composable
+private fun LogViewerSheetEmptyPreview() {
+ XMTPTheme {
+ LogViewerSheet(
+ logFiles = emptyList(),
+ onDismiss = {},
+ onShareFile = {},
+ )
+ }
+}
+
+@Preview(showBackground = true)
+@Composable
+private fun LogViewerSheetWithFilesPreview() {
+ XMTPTheme {
+ LogViewerSheet(
+ logFiles = listOf(
+ LogFileInfo(File(""), "xmtp_2024_01_15_10_30.log", "256 KB"),
+ LogFileInfo(File(""), "xmtp_2024_01_14_15_45.log", "512 KB"),
+ LogFileInfo(File(""), "xmtp_2024_01_13_09_20.log", "1.2 MB"),
+ ),
+ onDismiss = {},
+ onShareFile = {},
+ )
+ }
+}
diff --git a/example/src/main/java/org/xmtp/android/example/ui/sheets/MessageOptionsSheet.kt b/example/src/main/java/org/xmtp/android/example/ui/sheets/MessageOptionsSheet.kt
new file mode 100644
index 000000000..fd09d5cae
--- /dev/null
+++ b/example/src/main/java/org/xmtp/android/example/ui/sheets/MessageOptionsSheet.kt
@@ -0,0 +1,285 @@
+package org.xmtp.android.example.ui.sheets
+
+import androidx.compose.foundation.background
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.layout.width
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.automirrored.filled.Reply
+import androidx.compose.material.icons.filled.Delete
+import androidx.compose.material.icons.filled.Edit
+import androidx.compose.material3.Card
+import androidx.compose.material3.CardDefaults
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.HorizontalDivider
+import androidx.compose.material3.Icon
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.ModalBottomSheet
+import androidx.compose.material3.Surface
+import androidx.compose.material3.Text
+import androidx.compose.material3.rememberModalBottomSheetState
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.graphics.vector.ImageVector
+import androidx.compose.ui.text.style.TextAlign
+import androidx.compose.ui.tooling.preview.Preview
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.sp
+import org.xmtp.android.example.ui.theme.XMTPTheme
+
+data class MessageOptionsState(
+ val messageId: String,
+ val canEdit: Boolean,
+ val canDelete: Boolean,
+ val currentReaction: String? = null,
+)
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+fun MessageOptionsSheet(
+ state: MessageOptionsState,
+ onDismiss: () -> Unit,
+ onReply: () -> Unit,
+ onEdit: () -> Unit,
+ onDelete: () -> Unit,
+ onReaction: (String) -> Unit,
+ modifier: Modifier = Modifier,
+) {
+ val sheetState = rememberModalBottomSheetState()
+
+ ModalBottomSheet(
+ onDismissRequest = onDismiss,
+ sheetState = sheetState,
+ modifier = modifier,
+ containerColor = MaterialTheme.colorScheme.surface,
+ ) {
+ Column(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(horizontal = 16.dp)
+ .padding(bottom = 32.dp),
+ horizontalAlignment = Alignment.CenterHorizontally,
+ ) {
+ // Quick reactions bar
+ QuickReactionsBar(
+ currentReaction = state.currentReaction,
+ onReactionClick = { emoji ->
+ onReaction(emoji)
+ onDismiss()
+ },
+ )
+
+ Spacer(modifier = Modifier.height(16.dp))
+
+ // Actions card
+ Card(
+ modifier = Modifier.fillMaxWidth(),
+ colors = CardDefaults.cardColors(
+ containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f),
+ ),
+ shape = RoundedCornerShape(12.dp),
+ ) {
+ Column {
+ // Reply button
+ ActionItem(
+ icon = Icons.AutoMirrored.Filled.Reply,
+ label = "Reply",
+ onClick = {
+ onReply()
+ onDismiss()
+ },
+ )
+
+ // Edit button (conditional)
+ if (state.canEdit) {
+ HorizontalDivider(
+ modifier = Modifier.padding(start = 56.dp),
+ color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f),
+ )
+ ActionItem(
+ icon = Icons.Default.Edit,
+ label = "Edit",
+ onClick = {
+ onEdit()
+ onDismiss()
+ },
+ )
+ }
+
+ // Delete button (conditional)
+ if (state.canDelete) {
+ HorizontalDivider(
+ modifier = Modifier.padding(start = 56.dp),
+ color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f),
+ )
+ ActionItem(
+ icon = Icons.Default.Delete,
+ label = "Delete",
+ tint = MaterialTheme.colorScheme.error,
+ onClick = {
+ onDelete()
+ onDismiss()
+ },
+ )
+ }
+ }
+ }
+ }
+ }
+}
+
+@Composable
+private fun QuickReactionsBar(
+ currentReaction: String?,
+ onReactionClick: (String) -> Unit,
+ modifier: Modifier = Modifier,
+) {
+ val reactions = listOf("👍", "❤️", "😂", "😮", "😢", "😡")
+
+ Card(
+ modifier = modifier,
+ colors = CardDefaults.cardColors(
+ containerColor = MaterialTheme.colorScheme.surface,
+ ),
+ elevation = CardDefaults.cardElevation(defaultElevation = 4.dp),
+ shape = RoundedCornerShape(24.dp),
+ ) {
+ Row(
+ modifier = Modifier.padding(horizontal = 8.dp, vertical = 6.dp),
+ horizontalArrangement = Arrangement.spacedBy(4.dp),
+ ) {
+ reactions.forEach { emoji ->
+ ReactionButton(
+ emoji = emoji,
+ isSelected = emoji == currentReaction,
+ onClick = { onReactionClick(emoji) },
+ )
+ }
+ }
+ }
+}
+
+@Composable
+private fun ReactionButton(
+ emoji: String,
+ isSelected: Boolean,
+ onClick: () -> Unit,
+ modifier: Modifier = Modifier,
+) {
+ Surface(
+ modifier = modifier
+ .size(44.dp)
+ .clip(RoundedCornerShape(22.dp))
+ .clickable(onClick = onClick),
+ color = if (isSelected) {
+ MaterialTheme.colorScheme.primaryContainer
+ } else {
+ MaterialTheme.colorScheme.surface
+ },
+ shape = RoundedCornerShape(22.dp),
+ ) {
+ Text(
+ text = emoji,
+ fontSize = 24.sp,
+ textAlign = TextAlign.Center,
+ modifier = Modifier.padding(6.dp),
+ )
+ }
+}
+
+@Composable
+private fun ActionItem(
+ icon: ImageVector,
+ label: String,
+ onClick: () -> Unit,
+ modifier: Modifier = Modifier,
+ tint: androidx.compose.ui.graphics.Color = MaterialTheme.colorScheme.onSurface,
+) {
+ Row(
+ modifier = modifier
+ .fillMaxWidth()
+ .clickable(onClick = onClick)
+ .padding(horizontal = 16.dp, vertical = 14.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ Icon(
+ imageVector = icon,
+ contentDescription = label,
+ tint = tint,
+ modifier = Modifier.size(24.dp),
+ )
+ Spacer(modifier = Modifier.width(16.dp))
+ Text(
+ text = label,
+ style = MaterialTheme.typography.bodyLarge,
+ color = tint,
+ )
+ }
+}
+
+@Preview(showBackground = true)
+@Composable
+private fun MessageOptionsSheetPreview() {
+ XMTPTheme {
+ Column(
+ modifier = Modifier
+ .fillMaxWidth()
+ .background(MaterialTheme.colorScheme.background)
+ .padding(16.dp),
+ horizontalAlignment = Alignment.CenterHorizontally,
+ ) {
+ // Preview content (simulating sheet content)
+ QuickReactionsBar(
+ currentReaction = "👍",
+ onReactionClick = {},
+ )
+
+ Spacer(modifier = Modifier.height(16.dp))
+
+ Card(
+ modifier = Modifier.fillMaxWidth(),
+ colors = CardDefaults.cardColors(
+ containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f),
+ ),
+ shape = RoundedCornerShape(12.dp),
+ ) {
+ Column {
+ ActionItem(
+ icon = Icons.AutoMirrored.Filled.Reply,
+ label = "Reply",
+ onClick = {},
+ )
+ HorizontalDivider(
+ modifier = Modifier.padding(start = 56.dp),
+ color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f),
+ )
+ ActionItem(
+ icon = Icons.Default.Edit,
+ label = "Edit",
+ onClick = {},
+ )
+ HorizontalDivider(
+ modifier = Modifier.padding(start = 56.dp),
+ color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f),
+ )
+ ActionItem(
+ icon = Icons.Default.Delete,
+ label = "Delete",
+ tint = MaterialTheme.colorScheme.error,
+ onClick = {},
+ )
+ }
+ }
+ }
+ }
+}
diff --git a/example/src/main/java/org/xmtp/android/example/ui/sheets/NewConversationSheet.kt b/example/src/main/java/org/xmtp/android/example/ui/sheets/NewConversationSheet.kt
new file mode 100644
index 000000000..6321925e6
--- /dev/null
+++ b/example/src/main/java/org/xmtp/android/example/ui/sheets/NewConversationSheet.kt
@@ -0,0 +1,207 @@
+package org.xmtp.android.example.ui.sheets
+
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.Clear
+import androidx.compose.material.icons.filled.Person
+import androidx.compose.material3.Button
+import androidx.compose.material3.Card
+import androidx.compose.material3.CardDefaults
+import androidx.compose.material3.CircularProgressIndicator
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.Icon
+import androidx.compose.material3.IconButton
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.ModalBottomSheet
+import androidx.compose.material3.OutlinedTextField
+import androidx.compose.material3.Text
+import androidx.compose.material3.rememberModalBottomSheetState
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.tooling.preview.Preview
+import androidx.compose.ui.unit.dp
+import org.xmtp.android.example.ui.components.ContactRow
+import org.xmtp.android.example.ui.components.RecentContactData
+import org.xmtp.android.example.ui.theme.XMTPTheme
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+fun NewConversationSheet(
+ addressInput: String,
+ recentContacts: List = emptyList(),
+ isLoading: Boolean = false,
+ canCreate: Boolean = false,
+ onDismiss: () -> Unit,
+ onAddressChange: (String) -> Unit,
+ onRecentContactClick: (String) -> Unit,
+ onCreate: () -> Unit,
+ modifier: Modifier = Modifier,
+) {
+ val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
+
+ ModalBottomSheet(
+ onDismissRequest = onDismiss,
+ sheetState = sheetState,
+ modifier = modifier,
+ ) {
+ Column(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(horizontal = 16.dp)
+ .padding(bottom = 32.dp),
+ ) {
+ Text(
+ text = "New Conversation",
+ style = MaterialTheme.typography.titleLarge,
+ )
+
+ Spacer(modifier = Modifier.height(16.dp))
+
+ // Address input
+ Card(
+ colors = CardDefaults.cardColors(
+ containerColor = MaterialTheme.colorScheme.surfaceVariant,
+ ),
+ ) {
+ OutlinedTextField(
+ value = addressInput,
+ onValueChange = onAddressChange,
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(4.dp),
+ placeholder = { Text("Enter wallet address") },
+ leadingIcon = {
+ Icon(
+ imageVector = Icons.Default.Person,
+ contentDescription = "Address",
+ )
+ },
+ trailingIcon = {
+ if (addressInput.isNotEmpty()) {
+ IconButton(onClick = { onAddressChange("") }) {
+ Icon(
+ imageVector = Icons.Default.Clear,
+ contentDescription = "Clear",
+ )
+ }
+ }
+ },
+ singleLine = true,
+ )
+ }
+
+ Text(
+ text = "Enter a valid Ethereum address (0x...)",
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ modifier = Modifier.padding(vertical = 8.dp),
+ )
+
+ // Recent contacts
+ if (recentContacts.isNotEmpty()) {
+ Spacer(modifier = Modifier.height(16.dp))
+ Text(
+ text = "RECENT CONTACTS",
+ style = MaterialTheme.typography.labelMedium,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+ Spacer(modifier = Modifier.height(8.dp))
+
+ recentContacts.take(5).forEach { contact ->
+ ContactRow(
+ contact = contact,
+ onClick = { onRecentContactClick(contact.address) },
+ )
+ }
+ }
+
+ Spacer(modifier = Modifier.height(24.dp))
+
+ // Create button
+ Button(
+ onClick = {
+ onCreate()
+ onDismiss()
+ },
+ enabled = canCreate && !isLoading,
+ modifier = Modifier
+ .fillMaxWidth()
+ .height(52.dp),
+ ) {
+ if (isLoading) {
+ CircularProgressIndicator(
+ modifier = Modifier.size(24.dp),
+ color = MaterialTheme.colorScheme.onPrimary,
+ )
+ } else {
+ Text("Start Conversation")
+ }
+ }
+ }
+ }
+}
+
+@Preview(showBackground = true)
+@Composable
+private fun NewConversationSheetContentPreview() {
+ XMTPTheme {
+ Column(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(16.dp),
+ ) {
+ Text(
+ text = "New Conversation",
+ style = MaterialTheme.typography.titleLarge,
+ )
+
+ Spacer(modifier = Modifier.height(16.dp))
+
+ Card(
+ colors = CardDefaults.cardColors(
+ containerColor = MaterialTheme.colorScheme.surfaceVariant,
+ ),
+ ) {
+ OutlinedTextField(
+ value = "0x1234567890abcdef",
+ onValueChange = {},
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(4.dp),
+ placeholder = { Text("Enter wallet address") },
+ leadingIcon = {
+ Icon(
+ imageVector = Icons.Default.Person,
+ contentDescription = "Address",
+ )
+ },
+ singleLine = true,
+ )
+ }
+
+ Text(
+ text = "Enter a valid Ethereum address (0x...)",
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ modifier = Modifier.padding(vertical = 8.dp),
+ )
+
+ Spacer(modifier = Modifier.height(24.dp))
+
+ Button(
+ onClick = {},
+ enabled = true,
+ modifier = Modifier
+ .fillMaxWidth()
+ .height(52.dp),
+ ) {
+ Text("Start Conversation")
+ }
+ }
+ }
+}
diff --git a/example/src/main/java/org/xmtp/android/example/ui/sheets/NewGroupSheet.kt b/example/src/main/java/org/xmtp/android/example/ui/sheets/NewGroupSheet.kt
new file mode 100644
index 000000000..5ed5c1ac1
--- /dev/null
+++ b/example/src/main/java/org/xmtp/android/example/ui/sheets/NewGroupSheet.kt
@@ -0,0 +1,328 @@
+package org.xmtp.android.example.ui.sheets
+
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.ExperimentalLayoutApi
+import androidx.compose.foundation.layout.FlowRow
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.Add
+import androidx.compose.material.icons.filled.Close
+import androidx.compose.material.icons.filled.Groups
+import androidx.compose.material.icons.filled.Person
+import androidx.compose.material3.Button
+import androidx.compose.material3.Card
+import androidx.compose.material3.CardDefaults
+import androidx.compose.material3.CircularProgressIndicator
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.Icon
+import androidx.compose.material3.IconButton
+import androidx.compose.material3.InputChip
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.ModalBottomSheet
+import androidx.compose.material3.OutlinedTextField
+import androidx.compose.material3.Text
+import androidx.compose.material3.rememberModalBottomSheetState
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.tooling.preview.Preview
+import androidx.compose.ui.unit.dp
+import org.xmtp.android.example.ui.components.ContactRow
+import org.xmtp.android.example.ui.components.RecentContactData
+import org.xmtp.android.example.ui.theme.XMTPTheme
+
+@OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class)
+@Composable
+fun NewGroupSheet(
+ groupName: String,
+ addressInput: String,
+ selectedMembers: List = emptyList(),
+ recentContacts: List = emptyList(),
+ isLoading: Boolean = false,
+ canCreate: Boolean = false,
+ onDismiss: () -> Unit,
+ onGroupNameChange: (String) -> Unit,
+ onAddressChange: (String) -> Unit,
+ onAddMember: () -> Unit,
+ onRemoveMember: (String) -> Unit,
+ onRecentContactClick: (String) -> Unit,
+ onCreate: () -> Unit,
+ modifier: Modifier = Modifier,
+) {
+ val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
+
+ ModalBottomSheet(
+ onDismissRequest = onDismiss,
+ sheetState = sheetState,
+ modifier = modifier,
+ ) {
+ Column(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(horizontal = 16.dp)
+ .padding(bottom = 32.dp),
+ ) {
+ Text(
+ text = "New Group",
+ style = MaterialTheme.typography.titleLarge,
+ )
+
+ Spacer(modifier = Modifier.height(16.dp))
+
+ // Group name input
+ Card(
+ colors = CardDefaults.cardColors(
+ containerColor = MaterialTheme.colorScheme.surfaceVariant,
+ ),
+ ) {
+ OutlinedTextField(
+ value = groupName,
+ onValueChange = onGroupNameChange,
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(4.dp),
+ placeholder = { Text("Group name (optional)") },
+ leadingIcon = {
+ Icon(
+ imageVector = Icons.Default.Groups,
+ contentDescription = "Group",
+ )
+ },
+ singleLine = true,
+ )
+ }
+
+ Spacer(modifier = Modifier.height(16.dp))
+
+ // Selected members chips
+ if (selectedMembers.isNotEmpty()) {
+ FlowRow(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.spacedBy(8.dp),
+ ) {
+ selectedMembers.forEach { member ->
+ InputChip(
+ selected = false,
+ onClick = { onRemoveMember(member) },
+ label = {
+ Text(
+ text = if (member.length > 12) {
+ member.take(6) + "..." + member.takeLast(4)
+ } else {
+ member
+ },
+ )
+ },
+ trailingIcon = {
+ Icon(
+ imageVector = Icons.Default.Close,
+ contentDescription = "Remove",
+ modifier = Modifier.size(18.dp),
+ )
+ },
+ )
+ }
+ }
+
+ Spacer(modifier = Modifier.height(8.dp))
+
+ Text(
+ text = "${selectedMembers.size} member(s) added",
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.primary,
+ )
+
+ Spacer(modifier = Modifier.height(16.dp))
+ }
+
+ // Address input
+ Card(
+ colors = CardDefaults.cardColors(
+ containerColor = MaterialTheme.colorScheme.surfaceVariant,
+ ),
+ ) {
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ OutlinedTextField(
+ value = addressInput,
+ onValueChange = onAddressChange,
+ modifier = Modifier
+ .weight(1f)
+ .padding(4.dp),
+ placeholder = { Text("Add member address") },
+ leadingIcon = {
+ Icon(
+ imageVector = Icons.Default.Person,
+ contentDescription = "Address",
+ )
+ },
+ singleLine = true,
+ )
+
+ if (addressInput.isNotEmpty()) {
+ IconButton(onClick = onAddMember) {
+ Icon(
+ imageVector = Icons.Default.Add,
+ contentDescription = "Add member",
+ tint = MaterialTheme.colorScheme.primary,
+ )
+ }
+ }
+ }
+ }
+
+ Text(
+ text = "Enter a valid Ethereum address (0x...)",
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ modifier = Modifier.padding(vertical = 8.dp),
+ )
+
+ // Recent contacts
+ if (recentContacts.isNotEmpty()) {
+ Spacer(modifier = Modifier.height(16.dp))
+ Text(
+ text = "RECENT CONTACTS",
+ style = MaterialTheme.typography.labelMedium,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+ Spacer(modifier = Modifier.height(8.dp))
+
+ recentContacts.take(5).forEach { contact ->
+ ContactRow(
+ contact = contact,
+ isGroupMode = true,
+ isSelected = selectedMembers.contains(contact.address),
+ onClick = { onRecentContactClick(contact.address) },
+ onAddClick = { onRecentContactClick(contact.address) },
+ )
+ }
+ }
+
+ Spacer(modifier = Modifier.height(24.dp))
+
+ // Create button
+ Button(
+ onClick = {
+ onCreate()
+ onDismiss()
+ },
+ enabled = canCreate && !isLoading,
+ modifier = Modifier
+ .fillMaxWidth()
+ .height(52.dp),
+ ) {
+ if (isLoading) {
+ CircularProgressIndicator(
+ modifier = Modifier.size(24.dp),
+ color = MaterialTheme.colorScheme.onPrimary,
+ )
+ } else {
+ Text("Create Group")
+ }
+ }
+ }
+ }
+}
+
+@OptIn(ExperimentalLayoutApi::class)
+@Preview(showBackground = true)
+@Composable
+private fun NewGroupSheetContentPreview() {
+ XMTPTheme {
+ Column(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(16.dp),
+ ) {
+ Text(
+ text = "New Group",
+ style = MaterialTheme.typography.titleLarge,
+ )
+
+ Spacer(modifier = Modifier.height(16.dp))
+
+ Card(
+ colors = CardDefaults.cardColors(
+ containerColor = MaterialTheme.colorScheme.surfaceVariant,
+ ),
+ ) {
+ OutlinedTextField(
+ value = "XMTP Dev Team",
+ onValueChange = {},
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(4.dp),
+ placeholder = { Text("Group name (optional)") },
+ leadingIcon = {
+ Icon(
+ imageVector = Icons.Default.Groups,
+ contentDescription = "Group",
+ )
+ },
+ singleLine = true,
+ )
+ }
+
+ Spacer(modifier = Modifier.height(16.dp))
+
+ FlowRow(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.spacedBy(8.dp),
+ ) {
+ InputChip(
+ selected = false,
+ onClick = {},
+ label = { Text("0x1234...5678") },
+ trailingIcon = {
+ Icon(
+ imageVector = Icons.Default.Close,
+ contentDescription = "Remove",
+ modifier = Modifier.size(18.dp),
+ )
+ },
+ )
+ InputChip(
+ selected = false,
+ onClick = {},
+ label = { Text("0xabcd...efgh") },
+ trailingIcon = {
+ Icon(
+ imageVector = Icons.Default.Close,
+ contentDescription = "Remove",
+ modifier = Modifier.size(18.dp),
+ )
+ },
+ )
+ }
+
+ Spacer(modifier = Modifier.height(8.dp))
+
+ Text(
+ text = "2 member(s) added",
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.primary,
+ )
+
+ Spacer(modifier = Modifier.height(24.dp))
+
+ Button(
+ onClick = {},
+ enabled = true,
+ modifier = Modifier
+ .fillMaxWidth()
+ .height(52.dp),
+ ) {
+ Text("Create Group")
+ }
+ }
+ }
+}
diff --git a/example/src/main/java/org/xmtp/android/example/ui/sheets/WalletInfoSheet.kt b/example/src/main/java/org/xmtp/android/example/ui/sheets/WalletInfoSheet.kt
new file mode 100644
index 000000000..311e93e73
--- /dev/null
+++ b/example/src/main/java/org/xmtp/android/example/ui/sheets/WalletInfoSheet.kt
@@ -0,0 +1,226 @@
+package org.xmtp.android.example.ui.sheets
+
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.width
+import androidx.compose.foundation.rememberScrollState
+import androidx.compose.foundation.verticalScroll
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.ContentCopy
+import androidx.compose.material3.Button
+import androidx.compose.material3.ButtonDefaults
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.HorizontalDivider
+import androidx.compose.material3.Icon
+import androidx.compose.material3.IconButton
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.ModalBottomSheet
+import androidx.compose.material3.Switch
+import androidx.compose.material3.Text
+import androidx.compose.material3.rememberModalBottomSheetState
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.text.font.FontFamily
+import androidx.compose.ui.text.style.TextOverflow
+import androidx.compose.ui.tooling.preview.Preview
+import androidx.compose.ui.unit.dp
+import org.xmtp.android.example.ui.theme.XMTPTheme
+
+data class WalletInfo(
+ val walletAddress: String,
+ val inboxId: String,
+ val installationId: String,
+ val environment: String,
+ val libXmtpVersion: String,
+)
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+fun WalletInfoSheet(
+ walletInfo: WalletInfo,
+ isLogsEnabled: Boolean,
+ onDismiss: () -> Unit,
+ onCopyValue: (label: String, value: String) -> Unit,
+ onLogsToggled: (Boolean) -> Unit,
+ onDisconnect: () -> Unit,
+ modifier: Modifier = Modifier,
+) {
+ val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
+
+ ModalBottomSheet(
+ onDismissRequest = onDismiss,
+ sheetState = sheetState,
+ modifier = modifier,
+ ) {
+ Column(
+ modifier = Modifier
+ .fillMaxWidth()
+ .verticalScroll(rememberScrollState())
+ .padding(horizontal = 24.dp)
+ .padding(bottom = 32.dp),
+ ) {
+ // Title
+ Text(
+ text = "Wallet Info",
+ style = MaterialTheme.typography.headlineSmall,
+ color = MaterialTheme.colorScheme.onSurface,
+ )
+
+ Spacer(modifier = Modifier.height(24.dp))
+
+ // Wallet Address
+ InfoRow(
+ label = "Wallet Address",
+ value = walletInfo.walletAddress,
+ onCopy = { onCopyValue("Wallet Address", walletInfo.walletAddress) },
+ )
+
+ HorizontalDivider(modifier = Modifier.padding(vertical = 12.dp))
+
+ // Inbox ID
+ InfoRow(
+ label = "Inbox ID",
+ value = walletInfo.inboxId,
+ onCopy = { onCopyValue("Inbox ID", walletInfo.inboxId) },
+ )
+
+ HorizontalDivider(modifier = Modifier.padding(vertical = 12.dp))
+
+ // Installation ID
+ InfoRow(
+ label = "Installation ID",
+ value = walletInfo.installationId,
+ onCopy = { onCopyValue("Installation ID", walletInfo.installationId) },
+ )
+
+ HorizontalDivider(modifier = Modifier.padding(vertical = 12.dp))
+
+ // Environment
+ InfoRow(
+ label = "Environment",
+ value = walletInfo.environment,
+ onCopy = { onCopyValue("Environment", walletInfo.environment) },
+ )
+
+ HorizontalDivider(modifier = Modifier.padding(vertical = 12.dp))
+
+ // LibXMTP Version
+ InfoRow(
+ label = "LibXMTP Version",
+ value = walletInfo.libXmtpVersion,
+ onCopy = { onCopyValue("LibXMTP Version", walletInfo.libXmtpVersion) },
+ )
+
+ HorizontalDivider(modifier = Modifier.padding(vertical = 12.dp))
+
+ // Logs Toggle
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.SpaceBetween,
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ Column {
+ Text(
+ text = "Enable Logs",
+ style = MaterialTheme.typography.bodyLarge,
+ color = MaterialTheme.colorScheme.onSurface,
+ )
+ Text(
+ text = "Persist debug logs to file",
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+ }
+ Switch(
+ checked = isLogsEnabled,
+ onCheckedChange = onLogsToggled,
+ )
+ }
+
+ Spacer(modifier = Modifier.height(24.dp))
+
+ // Disconnect Button
+ Button(
+ onClick = onDisconnect,
+ modifier = Modifier
+ .fillMaxWidth()
+ .height(48.dp),
+ colors = ButtonDefaults.buttonColors(
+ containerColor = MaterialTheme.colorScheme.error,
+ contentColor = MaterialTheme.colorScheme.onError,
+ ),
+ ) {
+ Text("Disconnect Wallet")
+ }
+ }
+ }
+}
+
+@Composable
+private fun InfoRow(
+ label: String,
+ value: String,
+ onCopy: () -> Unit,
+ modifier: Modifier = Modifier,
+) {
+ Row(
+ modifier = modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.SpaceBetween,
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ Column(
+ modifier = Modifier.weight(1f),
+ ) {
+ Text(
+ text = label,
+ style = MaterialTheme.typography.labelMedium,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+ Spacer(modifier = Modifier.height(4.dp))
+ Text(
+ text = value,
+ style = MaterialTheme.typography.bodyMedium.copy(
+ fontFamily = FontFamily.Monospace,
+ ),
+ color = MaterialTheme.colorScheme.onSurface,
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis,
+ )
+ }
+ Spacer(modifier = Modifier.width(8.dp))
+ IconButton(onClick = onCopy) {
+ Icon(
+ imageVector = Icons.Default.ContentCopy,
+ contentDescription = "Copy $label",
+ tint = MaterialTheme.colorScheme.primary,
+ )
+ }
+ }
+}
+
+@Preview(showBackground = true)
+@Composable
+private fun WalletInfoSheetPreview() {
+ XMTPTheme {
+ WalletInfoSheet(
+ walletInfo = WalletInfo(
+ walletAddress = "0x1234567890abcdef1234567890abcdef12345678",
+ inboxId = "inbox-id-12345",
+ installationId = "installation-id-67890",
+ environment = "DEV",
+ libXmtpVersion = "1.0.0",
+ ),
+ isLogsEnabled = false,
+ onDismiss = {},
+ onCopyValue = { _, _ -> },
+ onLogsToggled = {},
+ onDisconnect = {},
+ )
+ }
+}
diff --git a/example/src/main/java/org/xmtp/android/example/ui/theme/Color.kt b/example/src/main/java/org/xmtp/android/example/ui/theme/Color.kt
new file mode 100644
index 000000000..266fee98b
--- /dev/null
+++ b/example/src/main/java/org/xmtp/android/example/ui/theme/Color.kt
@@ -0,0 +1,40 @@
+package org.xmtp.android.example.ui.theme
+
+import androidx.compose.ui.graphics.Color
+
+// Primary colors
+val Purple80 = Color(0xFFD0BCFF)
+val PurpleGrey80 = Color(0xFFCCC2DC)
+val Pink80 = Color(0xFFEFB8C8)
+
+val Purple40 = Color(0xFF6650a4)
+val PurpleGrey40 = Color(0xFF625b71)
+val Pink40 = Color(0xFF7D5260)
+
+// XMTP Brand Colors
+val XMTPBlue = Color(0xFF0052FF)
+val XMTPBlueLight = Color(0xFF4D8AFF)
+val XMTPBlueDark = Color(0xFF0041CC)
+
+// Message bubble colors
+val SentMessageBackground = Color(0xFF0052FF)
+val ReceivedMessageBackground = Color(0xFFE8E8ED)
+val ReceivedMessageBackgroundDark = Color(0xFF2C2C2E)
+
+// Status colors
+val OnlineGreen = Color(0xFF34C759)
+val WarningOrange = Color(0xFFFF9500)
+val ErrorRed = Color(0xFFFF3B30)
+
+// Avatar colors for consistent sender identification
+val AvatarColors =
+ listOf(
+ Color(0xFFFC4F37),
+ Color(0xFF5856D6),
+ Color(0xFF34C759),
+ Color(0xFFFF9500),
+ Color(0xFF007AFF),
+ Color(0xFFAF52DE),
+ Color(0xFF00C7BE),
+ Color(0xFFFF2D55),
+ )
diff --git a/example/src/main/java/org/xmtp/android/example/ui/theme/Theme.kt b/example/src/main/java/org/xmtp/android/example/ui/theme/Theme.kt
new file mode 100644
index 000000000..dc43a1c43
--- /dev/null
+++ b/example/src/main/java/org/xmtp/android/example/ui/theme/Theme.kt
@@ -0,0 +1,80 @@
+package org.xmtp.android.example.ui.theme
+
+import android.os.Build
+import androidx.compose.foundation.isSystemInDarkTheme
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.darkColorScheme
+import androidx.compose.material3.dynamicDarkColorScheme
+import androidx.compose.material3.dynamicLightColorScheme
+import androidx.compose.material3.lightColorScheme
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.platform.LocalContext
+
+private val DarkColorScheme =
+ darkColorScheme(
+ primary = XMTPBlue,
+ onPrimary = Color.White,
+ primaryContainer = XMTPBlueDark,
+ onPrimaryContainer = Color.White,
+ secondary = PurpleGrey80,
+ onSecondary = Color.Black,
+ secondaryContainer = PurpleGrey40,
+ onSecondaryContainer = Color.White,
+ tertiary = Pink80,
+ onTertiary = Color.Black,
+ background = Color(0xFF121212),
+ onBackground = Color.White,
+ surface = Color(0xFF1E1E1E),
+ onSurface = Color.White,
+ surfaceVariant = Color(0xFF2C2C2E),
+ onSurfaceVariant = Color(0xFFCAC4D0),
+ outline = Color(0xFF938F99),
+ outlineVariant = Color(0xFF49454F),
+ )
+
+private val LightColorScheme =
+ lightColorScheme(
+ primary = XMTPBlue,
+ onPrimary = Color.White,
+ primaryContainer = XMTPBlueLight,
+ onPrimaryContainer = Color.White,
+ secondary = PurpleGrey40,
+ onSecondary = Color.White,
+ secondaryContainer = PurpleGrey80,
+ onSecondaryContainer = Color.Black,
+ tertiary = Pink40,
+ onTertiary = Color.White,
+ background = Color(0xFFF8F8F8),
+ onBackground = Color.Black,
+ surface = Color.White,
+ onSurface = Color.Black,
+ surfaceVariant = Color(0xFFE8E8ED),
+ onSurfaceVariant = Color(0xFF49454F),
+ outline = Color(0xFF79747E),
+ outlineVariant = Color(0xFFCAC4D0),
+ )
+
+@Composable
+fun XMTPTheme(
+ darkTheme: Boolean = isSystemInDarkTheme(),
+ dynamicColor: Boolean = true,
+ content: @Composable () -> Unit,
+) {
+ val colorScheme =
+ when {
+ dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> {
+ val context = LocalContext.current
+ if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context)
+ }
+
+ darkTheme -> DarkColorScheme
+ else -> LightColorScheme
+ }
+
+ MaterialTheme(
+ colorScheme = colorScheme,
+ typography = Typography,
+ content = content,
+ )
+}
diff --git a/example/src/main/java/org/xmtp/android/example/ui/theme/Type.kt b/example/src/main/java/org/xmtp/android/example/ui/theme/Type.kt
new file mode 100644
index 000000000..2934d1754
--- /dev/null
+++ b/example/src/main/java/org/xmtp/android/example/ui/theme/Type.kt
@@ -0,0 +1,131 @@
+package org.xmtp.android.example.ui.theme
+
+import androidx.compose.material3.Typography
+import androidx.compose.ui.text.TextStyle
+import androidx.compose.ui.text.font.FontFamily
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.unit.sp
+
+val Typography =
+ Typography(
+ displayLarge =
+ TextStyle(
+ fontFamily = FontFamily.Default,
+ fontWeight = FontWeight.Normal,
+ fontSize = 57.sp,
+ lineHeight = 64.sp,
+ letterSpacing = (-0.25).sp,
+ ),
+ displayMedium =
+ TextStyle(
+ fontFamily = FontFamily.Default,
+ fontWeight = FontWeight.Normal,
+ fontSize = 45.sp,
+ lineHeight = 52.sp,
+ letterSpacing = 0.sp,
+ ),
+ displaySmall =
+ TextStyle(
+ fontFamily = FontFamily.Default,
+ fontWeight = FontWeight.Normal,
+ fontSize = 36.sp,
+ lineHeight = 44.sp,
+ letterSpacing = 0.sp,
+ ),
+ headlineLarge =
+ TextStyle(
+ fontFamily = FontFamily.Default,
+ fontWeight = FontWeight.Normal,
+ fontSize = 32.sp,
+ lineHeight = 40.sp,
+ letterSpacing = 0.sp,
+ ),
+ headlineMedium =
+ TextStyle(
+ fontFamily = FontFamily.Default,
+ fontWeight = FontWeight.Normal,
+ fontSize = 28.sp,
+ lineHeight = 36.sp,
+ letterSpacing = 0.sp,
+ ),
+ headlineSmall =
+ TextStyle(
+ fontFamily = FontFamily.Default,
+ fontWeight = FontWeight.Normal,
+ fontSize = 24.sp,
+ lineHeight = 32.sp,
+ letterSpacing = 0.sp,
+ ),
+ titleLarge =
+ TextStyle(
+ fontFamily = FontFamily.Default,
+ fontWeight = FontWeight.Bold,
+ fontSize = 22.sp,
+ lineHeight = 28.sp,
+ letterSpacing = 0.sp,
+ ),
+ titleMedium =
+ TextStyle(
+ fontFamily = FontFamily.Default,
+ fontWeight = FontWeight.Medium,
+ fontSize = 16.sp,
+ lineHeight = 24.sp,
+ letterSpacing = 0.15.sp,
+ ),
+ titleSmall =
+ TextStyle(
+ fontFamily = FontFamily.Default,
+ fontWeight = FontWeight.Medium,
+ fontSize = 14.sp,
+ lineHeight = 20.sp,
+ letterSpacing = 0.1.sp,
+ ),
+ bodyLarge =
+ TextStyle(
+ fontFamily = FontFamily.Default,
+ fontWeight = FontWeight.Normal,
+ fontSize = 16.sp,
+ lineHeight = 24.sp,
+ letterSpacing = 0.5.sp,
+ ),
+ bodyMedium =
+ TextStyle(
+ fontFamily = FontFamily.Default,
+ fontWeight = FontWeight.Normal,
+ fontSize = 14.sp,
+ lineHeight = 20.sp,
+ letterSpacing = 0.25.sp,
+ ),
+ bodySmall =
+ TextStyle(
+ fontFamily = FontFamily.Default,
+ fontWeight = FontWeight.Normal,
+ fontSize = 12.sp,
+ lineHeight = 16.sp,
+ letterSpacing = 0.4.sp,
+ ),
+ labelLarge =
+ TextStyle(
+ fontFamily = FontFamily.Default,
+ fontWeight = FontWeight.Medium,
+ fontSize = 14.sp,
+ lineHeight = 20.sp,
+ letterSpacing = 0.1.sp,
+ ),
+ labelMedium =
+ TextStyle(
+ fontFamily = FontFamily.Default,
+ fontWeight = FontWeight.Medium,
+ fontSize = 12.sp,
+ lineHeight = 16.sp,
+ letterSpacing = 0.5.sp,
+ ),
+ labelSmall =
+ TextStyle(
+ fontFamily = FontFamily.Default,
+ fontWeight = FontWeight.Medium,
+ fontSize = 11.sp,
+ lineHeight = 16.sp,
+ letterSpacing = 0.5.sp,
+ ),
+ )
diff --git a/example/src/main/java/org/xmtp/android/example/utils/KeyUtil.kt b/example/src/main/java/org/xmtp/android/example/utils/KeyUtil.kt
index 770b29b2b..104be8b2d 100644
--- a/example/src/main/java/org/xmtp/android/example/utils/KeyUtil.kt
+++ b/example/src/main/java/org/xmtp/android/example/utils/KeyUtil.kt
@@ -2,20 +2,114 @@ package org.xmtp.android.example.utils
import android.accounts.AccountManager
import android.content.Context
-import android.security.keystore.KeyGenParameterSpec
-import android.security.keystore.KeyProperties
+import android.content.SharedPreferences
import android.util.Base64.NO_WRAP
import android.util.Base64.decode
import android.util.Base64.encodeToString
+import androidx.security.crypto.EncryptedSharedPreferences
+import androidx.security.crypto.MasterKey
import org.xmtp.android.example.R
-import java.security.KeyStore
-import javax.crypto.KeyGenerator
-import javax.crypto.SecretKey
+import timber.log.Timber
class KeyUtil(
- val context: Context,
+ private val context: Context,
) {
- private val PREFS_NAME = "EncryptionPref"
+ private companion object {
+ const val ENCRYPTED_PREFS_NAME = "EncryptedKeyPref"
+ const val LEGACY_PREFS_NAME = "EncryptionPref" // Old name for migration
+ const val SETTINGS_PREFS = "SettingsPref"
+ const val KEY_ENVIRONMENT = "xmtp_environment"
+ const val KEY_HIDE_DELETED_MESSAGES = "hide_deleted_messages"
+ const val KEY_MIGRATION_COMPLETE = "migration_complete"
+
+ // Key prefixes
+ const val PREFIX_DB_KEY = "xmtp-dev-"
+ const val PREFIX_WALLET_KEY = "xmtp-wallet-"
+ }
+
+ private val masterKey: MasterKey by lazy {
+ MasterKey
+ .Builder(context)
+ .setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
+ .build()
+ }
+
+ private val encryptedPrefs: SharedPreferences by lazy {
+ try {
+ val prefs =
+ EncryptedSharedPreferences.create(
+ context,
+ ENCRYPTED_PREFS_NAME,
+ masterKey,
+ EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
+ EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM,
+ )
+ // Migrate from legacy prefs if needed
+ migrateLegacyPrefsIfNeeded(prefs)
+ prefs
+ } catch (e: Exception) {
+ Timber.e(e, "Failed to create EncryptedSharedPreferences, falling back to regular prefs")
+ // Fallback to regular SharedPreferences if encryption fails (e.g., on some devices)
+ context.getSharedPreferences(ENCRYPTED_PREFS_NAME, Context.MODE_PRIVATE)
+ }
+ }
+
+ /**
+ * Migrate keys from the legacy SharedPreferences name to the new one.
+ * This prevents data loss for existing users when the prefs filename was changed.
+ */
+ private fun migrateLegacyPrefsIfNeeded(newPrefs: SharedPreferences) {
+ // Check if migration is already complete
+ if (newPrefs.getBoolean(KEY_MIGRATION_COMPLETE, false)) {
+ return
+ }
+
+ try {
+ // Try to open the legacy prefs
+ val legacyPrefs =
+ EncryptedSharedPreferences.create(
+ context,
+ LEGACY_PREFS_NAME,
+ masterKey,
+ EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
+ EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM,
+ )
+
+ // Copy all entries from legacy to new
+ val allEntries = legacyPrefs.all
+ if (allEntries.isNotEmpty()) {
+ Timber.d("Migrating ${allEntries.size} keys from legacy prefs")
+ val editor = newPrefs.edit()
+ for ((key, value) in allEntries) {
+ when (value) {
+ is String -> editor.putString(key, value)
+ is Boolean -> editor.putBoolean(key, value)
+ is Int -> editor.putInt(key, value)
+ is Long -> editor.putLong(key, value)
+ is Float -> editor.putFloat(key, value)
+ is Set<*> -> {
+ @Suppress("UNCHECKED_CAST")
+ editor.putStringSet(key, value as Set)
+ }
+ }
+ }
+ editor.putBoolean(KEY_MIGRATION_COMPLETE, true)
+ editor.apply()
+ Timber.d("Migration complete")
+ } else {
+ // No legacy data, mark migration as complete
+ newPrefs.edit().putBoolean(KEY_MIGRATION_COMPLETE, true).apply()
+ }
+ } catch (e: Exception) {
+ // Legacy prefs don't exist or can't be read - that's fine for new users
+ Timber.d("No legacy prefs to migrate: ${e.message}")
+ newPrefs.edit().putBoolean(KEY_MIGRATION_COMPLETE, true).apply()
+ }
+ }
+
+ private val settingsPrefs: SharedPreferences by lazy {
+ context.getSharedPreferences(SETTINGS_PREFS, Context.MODE_PRIVATE)
+ }
fun loadKeys(): String? {
val accountManager = AccountManager.get(context)
@@ -29,23 +123,94 @@ class KeyUtil(
address: String,
dbEncryptionKey: ByteArray?,
) {
- val alias = "xmtp-dev-${address.lowercase()}"
-
- val prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
- val editor = prefs.edit()
- editor.putString(alias, encodeToString(dbEncryptionKey, NO_WRAP))
- editor.apply()
+ val alias = "$PREFIX_DB_KEY${address.lowercase()}"
+ encryptedPrefs
+ .edit()
+ .putString(alias, encodeToString(dbEncryptionKey, NO_WRAP))
+ .apply()
}
fun retrieveKey(address: String): ByteArray? {
- val alias = "xmtp-dev-${address.lowercase()}"
-
- val prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
- val keyString = prefs.getString(alias, null)
- return if (keyString != null) {
- decode(keyString, NO_WRAP)
- } else {
- null
+ val alias = "$PREFIX_DB_KEY${address.lowercase()}"
+ val keyString = encryptedPrefs.getString(alias, null)
+ return keyString?.let {
+ try {
+ decode(it, NO_WRAP)
+ } catch (e: Exception) {
+ Timber.e(e, "Failed to decode key")
+ null
+ }
}
}
+
+ /**
+ * Store the wallet private key for signing.
+ * Uses EncryptedSharedPreferences backed by Android Keystore.
+ */
+ fun storePrivateKey(
+ address: String,
+ privateKeyBytes: ByteArray,
+ ) {
+ val alias = "$PREFIX_WALLET_KEY${address.lowercase()}"
+ encryptedPrefs
+ .edit()
+ .putString(alias, encodeToString(privateKeyBytes, NO_WRAP))
+ .apply()
+ }
+
+ /**
+ * Retrieve the wallet private key for signing.
+ * Uses EncryptedSharedPreferences backed by Android Keystore.
+ */
+ fun retrievePrivateKey(address: String): ByteArray? {
+ val alias = "$PREFIX_WALLET_KEY${address.lowercase()}"
+ val keyString = encryptedPrefs.getString(alias, null)
+ return keyString?.let {
+ try {
+ decode(it, NO_WRAP)
+ } catch (e: Exception) {
+ Timber.e(e, "Failed to decode private key")
+ null
+ }
+ }
+ }
+
+ /**
+ * Clear the wallet private key.
+ */
+ fun clearPrivateKey(address: String) {
+ val alias = "$PREFIX_WALLET_KEY${address.lowercase()}"
+ encryptedPrefs.edit().remove(alias).apply()
+ }
+
+ /**
+ * Store the selected environment.
+ */
+ fun storeEnvironment(environment: String) {
+ settingsPrefs.edit().putString(KEY_ENVIRONMENT, environment).apply()
+ }
+
+ /**
+ * Retrieve the selected environment.
+ */
+ fun retrieveEnvironment(): String? = settingsPrefs.getString(KEY_ENVIRONMENT, null)
+
+ /**
+ * Clear the environment setting.
+ */
+ fun clearEnvironment() {
+ settingsPrefs.edit().remove(KEY_ENVIRONMENT).apply()
+ }
+
+ /**
+ * Store hide deleted messages setting.
+ */
+ fun setHideDeletedMessages(hide: Boolean) {
+ settingsPrefs.edit().putBoolean(KEY_HIDE_DELETED_MESSAGES, hide).apply()
+ }
+
+ /**
+ * Retrieve hide deleted messages setting.
+ */
+ fun getHideDeletedMessages(): Boolean = settingsPrefs.getBoolean(KEY_HIDE_DELETED_MESSAGES, false)
}
diff --git a/example/src/main/res/color/switch_thumb_color.xml b/example/src/main/res/color/switch_thumb_color.xml
new file mode 100644
index 000000000..c1db29c52
--- /dev/null
+++ b/example/src/main/res/color/switch_thumb_color.xml
@@ -0,0 +1,5 @@
+
+
+
+
+
diff --git a/example/src/main/res/color/switch_track_color.xml b/example/src/main/res/color/switch_track_color.xml
new file mode 100644
index 000000000..9ab59648f
--- /dev/null
+++ b/example/src/main/res/color/switch_track_color.xml
@@ -0,0 +1,5 @@
+
+
+
+
+
diff --git a/example/src/main/res/drawable/bottom_sheet_handle.xml b/example/src/main/res/drawable/bottom_sheet_handle.xml
new file mode 100644
index 000000000..a6b72dbc2
--- /dev/null
+++ b/example/src/main/res/drawable/bottom_sheet_handle.xml
@@ -0,0 +1,6 @@
+
+
+
+
+
diff --git a/example/src/main/res/drawable/drawer_avatar_background.xml b/example/src/main/res/drawable/drawer_avatar_background.xml
new file mode 100644
index 000000000..bc4f0561d
--- /dev/null
+++ b/example/src/main/res/drawable/drawer_avatar_background.xml
@@ -0,0 +1,5 @@
+
+
+
+
diff --git a/example/src/main/res/drawable/file_attachment_background.xml b/example/src/main/res/drawable/file_attachment_background.xml
new file mode 100644
index 000000000..930de6330
--- /dev/null
+++ b/example/src/main/res/drawable/file_attachment_background.xml
@@ -0,0 +1,6 @@
+
+
+
+
+
diff --git a/example/src/main/res/drawable/file_attachment_background_received.xml b/example/src/main/res/drawable/file_attachment_background_received.xml
new file mode 100644
index 000000000..678d124ac
--- /dev/null
+++ b/example/src/main/res/drawable/file_attachment_background_received.xml
@@ -0,0 +1,6 @@
+
+
+
+
+
diff --git a/example/src/main/res/drawable/ic_account_circle_24.xml b/example/src/main/res/drawable/ic_account_circle_24.xml
new file mode 100644
index 000000000..3cbd3c447
--- /dev/null
+++ b/example/src/main/res/drawable/ic_account_circle_24.xml
@@ -0,0 +1,10 @@
+
+
+
+
diff --git a/example/src/main/res/drawable/ic_arrow_back_24.xml b/example/src/main/res/drawable/ic_arrow_back_24.xml
new file mode 100644
index 000000000..bab545a70
--- /dev/null
+++ b/example/src/main/res/drawable/ic_arrow_back_24.xml
@@ -0,0 +1,10 @@
+
+
+
diff --git a/example/src/main/res/drawable/ic_attach_file_24.xml b/example/src/main/res/drawable/ic_attach_file_24.xml
new file mode 100644
index 000000000..b2943e262
--- /dev/null
+++ b/example/src/main/res/drawable/ic_attach_file_24.xml
@@ -0,0 +1,10 @@
+
+
+
+
diff --git a/example/src/main/res/drawable/ic_audio_24.xml b/example/src/main/res/drawable/ic_audio_24.xml
new file mode 100644
index 000000000..f92cc4cdd
--- /dev/null
+++ b/example/src/main/res/drawable/ic_audio_24.xml
@@ -0,0 +1,10 @@
+
+
+
+
diff --git a/example/src/main/res/drawable/ic_bug_report_24.xml b/example/src/main/res/drawable/ic_bug_report_24.xml
new file mode 100644
index 000000000..5e360b2e3
--- /dev/null
+++ b/example/src/main/res/drawable/ic_bug_report_24.xml
@@ -0,0 +1,10 @@
+
+
+
+
diff --git a/example/src/main/res/drawable/ic_camera_24.xml b/example/src/main/res/drawable/ic_camera_24.xml
new file mode 100644
index 000000000..0b57fb0aa
--- /dev/null
+++ b/example/src/main/res/drawable/ic_camera_24.xml
@@ -0,0 +1,12 @@
+
+
+
+
diff --git a/example/src/main/res/drawable/ic_check_24.xml b/example/src/main/res/drawable/ic_check_24.xml
new file mode 100644
index 000000000..0432fa69b
--- /dev/null
+++ b/example/src/main/res/drawable/ic_check_24.xml
@@ -0,0 +1,10 @@
+
+
+
diff --git a/example/src/main/res/drawable/ic_check_double.xml b/example/src/main/res/drawable/ic_check_double.xml
new file mode 100644
index 000000000..442a05083
--- /dev/null
+++ b/example/src/main/res/drawable/ic_check_double.xml
@@ -0,0 +1,11 @@
+
+
+
+
+
diff --git a/example/src/main/res/drawable/ic_close_24.xml b/example/src/main/res/drawable/ic_close_24.xml
new file mode 100644
index 000000000..d53ab5e47
--- /dev/null
+++ b/example/src/main/res/drawable/ic_close_24.xml
@@ -0,0 +1,10 @@
+
+
+
+
diff --git a/example/src/main/res/drawable/ic_content_copy_24.xml b/example/src/main/res/drawable/ic_content_copy_24.xml
new file mode 100644
index 000000000..8bf74a77d
--- /dev/null
+++ b/example/src/main/res/drawable/ic_content_copy_24.xml
@@ -0,0 +1,10 @@
+
+
+
diff --git a/example/src/main/res/drawable/ic_copy_24.xml b/example/src/main/res/drawable/ic_copy_24.xml
new file mode 100644
index 000000000..e03f61805
--- /dev/null
+++ b/example/src/main/res/drawable/ic_copy_24.xml
@@ -0,0 +1,10 @@
+
+
+
+
diff --git a/example/src/main/res/drawable/ic_delete_24.xml b/example/src/main/res/drawable/ic_delete_24.xml
new file mode 100644
index 000000000..3ce7304db
--- /dev/null
+++ b/example/src/main/res/drawable/ic_delete_24.xml
@@ -0,0 +1,10 @@
+
+
+
+
diff --git a/example/src/main/res/drawable/ic_edit_24.xml b/example/src/main/res/drawable/ic_edit_24.xml
new file mode 100644
index 000000000..2844bafeb
--- /dev/null
+++ b/example/src/main/res/drawable/ic_edit_24.xml
@@ -0,0 +1,10 @@
+
+
+
diff --git a/example/src/main/res/drawable/ic_emoji_24.xml b/example/src/main/res/drawable/ic_emoji_24.xml
new file mode 100644
index 000000000..c636d5bcd
--- /dev/null
+++ b/example/src/main/res/drawable/ic_emoji_24.xml
@@ -0,0 +1,10 @@
+
+
+
+
diff --git a/example/src/main/res/drawable/ic_gif_24.xml b/example/src/main/res/drawable/ic_gif_24.xml
new file mode 100644
index 000000000..9882f69ae
--- /dev/null
+++ b/example/src/main/res/drawable/ic_gif_24.xml
@@ -0,0 +1,9 @@
+
+
+
diff --git a/example/src/main/res/drawable/ic_group_24.xml b/example/src/main/res/drawable/ic_group_24.xml
new file mode 100644
index 000000000..3436691a2
--- /dev/null
+++ b/example/src/main/res/drawable/ic_group_24.xml
@@ -0,0 +1,10 @@
+
+
+
diff --git a/example/src/main/res/drawable/ic_image_24.xml b/example/src/main/res/drawable/ic_image_24.xml
new file mode 100644
index 000000000..8f7314354
--- /dev/null
+++ b/example/src/main/res/drawable/ic_image_24.xml
@@ -0,0 +1,9 @@
+
+
+
diff --git a/example/src/main/res/drawable/ic_info_24.xml b/example/src/main/res/drawable/ic_info_24.xml
new file mode 100644
index 000000000..2096c119b
--- /dev/null
+++ b/example/src/main/res/drawable/ic_info_24.xml
@@ -0,0 +1,11 @@
+
+
+
+
diff --git a/example/src/main/res/drawable/ic_keyboard_24.xml b/example/src/main/res/drawable/ic_keyboard_24.xml
new file mode 100644
index 000000000..45da6b6b1
--- /dev/null
+++ b/example/src/main/res/drawable/ic_keyboard_24.xml
@@ -0,0 +1,10 @@
+
+
+
+
diff --git a/example/src/main/res/drawable/ic_logout_24.xml b/example/src/main/res/drawable/ic_logout_24.xml
new file mode 100644
index 000000000..86971585a
--- /dev/null
+++ b/example/src/main/res/drawable/ic_logout_24.xml
@@ -0,0 +1,10 @@
+
+
+
+
diff --git a/example/src/main/res/drawable/ic_menu_24.xml b/example/src/main/res/drawable/ic_menu_24.xml
new file mode 100644
index 000000000..9dc1fca8a
--- /dev/null
+++ b/example/src/main/res/drawable/ic_menu_24.xml
@@ -0,0 +1,10 @@
+
+
+
+
diff --git a/example/src/main/res/drawable/ic_mic_24.xml b/example/src/main/res/drawable/ic_mic_24.xml
new file mode 100644
index 000000000..953071839
--- /dev/null
+++ b/example/src/main/res/drawable/ic_mic_24.xml
@@ -0,0 +1,10 @@
+
+
+
+
diff --git a/example/src/main/res/drawable/ic_more_vert_24.xml b/example/src/main/res/drawable/ic_more_vert_24.xml
new file mode 100644
index 000000000..3b2167ece
--- /dev/null
+++ b/example/src/main/res/drawable/ic_more_vert_24.xml
@@ -0,0 +1,10 @@
+
+
+
+
diff --git a/example/src/main/res/drawable/ic_pdf_24.xml b/example/src/main/res/drawable/ic_pdf_24.xml
new file mode 100644
index 000000000..e0940f5f7
--- /dev/null
+++ b/example/src/main/res/drawable/ic_pdf_24.xml
@@ -0,0 +1,10 @@
+
+
+
+
diff --git a/example/src/main/res/drawable/ic_reply_24.xml b/example/src/main/res/drawable/ic_reply_24.xml
new file mode 100644
index 000000000..5514cbf4e
--- /dev/null
+++ b/example/src/main/res/drawable/ic_reply_24.xml
@@ -0,0 +1,10 @@
+
+
+
+
diff --git a/example/src/main/res/drawable/ic_search_24.xml b/example/src/main/res/drawable/ic_search_24.xml
new file mode 100644
index 000000000..d9a217a17
--- /dev/null
+++ b/example/src/main/res/drawable/ic_search_24.xml
@@ -0,0 +1,10 @@
+
+
+
+
diff --git a/example/src/main/res/drawable/ic_video_24.xml b/example/src/main/res/drawable/ic_video_24.xml
new file mode 100644
index 000000000..57d3e17a6
--- /dev/null
+++ b/example/src/main/res/drawable/ic_video_24.xml
@@ -0,0 +1,10 @@
+
+
+
+
diff --git a/example/src/main/res/drawable/ic_visibility_off_24.xml b/example/src/main/res/drawable/ic_visibility_off_24.xml
new file mode 100644
index 000000000..a91f4ec69
--- /dev/null
+++ b/example/src/main/res/drawable/ic_visibility_off_24.xml
@@ -0,0 +1,10 @@
+
+
+
+
diff --git a/example/src/main/res/drawable/message_bubble_received.xml b/example/src/main/res/drawable/message_bubble_received.xml
new file mode 100644
index 000000000..08d766949
--- /dev/null
+++ b/example/src/main/res/drawable/message_bubble_received.xml
@@ -0,0 +1,33 @@
+
+
+
+ -
+
+
+
+
+
+
+ -
+
+
+
+
+
+
diff --git a/example/src/main/res/drawable/message_bubble_sent.xml b/example/src/main/res/drawable/message_bubble_sent.xml
new file mode 100644
index 000000000..7b127a3f7
--- /dev/null
+++ b/example/src/main/res/drawable/message_bubble_sent.xml
@@ -0,0 +1,33 @@
+
+
+
+ -
+
+
+
+
+
+
+ -
+
+
+
+
+
+
diff --git a/example/src/main/res/drawable/reaction_button_background.xml b/example/src/main/res/drawable/reaction_button_background.xml
new file mode 100644
index 000000000..bd49c6748
--- /dev/null
+++ b/example/src/main/res/drawable/reaction_button_background.xml
@@ -0,0 +1,9 @@
+
+
+ -
+
+
+
+
+
diff --git a/example/src/main/res/drawable/reaction_button_selected_background.xml b/example/src/main/res/drawable/reaction_button_selected_background.xml
new file mode 100644
index 000000000..9be313eb5
--- /dev/null
+++ b/example/src/main/res/drawable/reaction_button_selected_background.xml
@@ -0,0 +1,12 @@
+
+
+ -
+
+
+
+
+
+
diff --git a/example/src/main/res/drawable/reply_background.xml b/example/src/main/res/drawable/reply_background.xml
new file mode 100644
index 000000000..ccba3eced
--- /dev/null
+++ b/example/src/main/res/drawable/reply_background.xml
@@ -0,0 +1,6 @@
+
+
+
+
+
diff --git a/example/src/main/res/drawable/reply_background_received.xml b/example/src/main/res/drawable/reply_background_received.xml
new file mode 100644
index 000000000..53bef7468
--- /dev/null
+++ b/example/src/main/res/drawable/reply_background_received.xml
@@ -0,0 +1,21 @@
+
+
+
+ -
+
+
+
+
+
+
+ -
+
+
+
+
+
+
diff --git a/example/src/main/res/drawable/reply_background_sent.xml b/example/src/main/res/drawable/reply_background_sent.xml
new file mode 100644
index 000000000..04bf82d2a
--- /dev/null
+++ b/example/src/main/res/drawable/reply_background_sent.xml
@@ -0,0 +1,21 @@
+
+
+
+ -
+
+
+
+
+
+
+ -
+
+
+
+
+
+
diff --git a/example/src/main/res/drawable/system_message_background.xml b/example/src/main/res/drawable/system_message_background.xml
new file mode 100644
index 000000000..effd73d9e
--- /dev/null
+++ b/example/src/main/res/drawable/system_message_background.xml
@@ -0,0 +1,6 @@
+
+
+
+
+
diff --git a/example/src/main/res/drawable/thumbnail_border.xml b/example/src/main/res/drawable/thumbnail_border.xml
new file mode 100644
index 000000000..2e66ebaa2
--- /dev/null
+++ b/example/src/main/res/drawable/thumbnail_border.xml
@@ -0,0 +1,8 @@
+
+
+
+
+
diff --git a/example/src/main/res/layout/activity_connect_wallet.xml b/example/src/main/res/layout/activity_connect_wallet.xml
deleted file mode 100644
index 8b4cdd196..000000000
--- a/example/src/main/res/layout/activity_connect_wallet.xml
+++ /dev/null
@@ -1,23 +0,0 @@
-
-
-
-
-
-
-
-
diff --git a/example/src/main/res/layout/activity_conversation_detail.xml b/example/src/main/res/layout/activity_conversation_detail.xml
deleted file mode 100644
index 2c2eb9fff..000000000
--- a/example/src/main/res/layout/activity_conversation_detail.xml
+++ /dev/null
@@ -1,83 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
diff --git a/example/src/main/res/layout/activity_main.xml b/example/src/main/res/layout/activity_main.xml
deleted file mode 100644
index da5be9a7e..000000000
--- a/example/src/main/res/layout/activity_main.xml
+++ /dev/null
@@ -1,82 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
diff --git a/example/src/main/res/layout/bottom_sheet_log_viewer.xml b/example/src/main/res/layout/bottom_sheet_log_viewer.xml
deleted file mode 100644
index 0d79edeb1..000000000
--- a/example/src/main/res/layout/bottom_sheet_log_viewer.xml
+++ /dev/null
@@ -1,30 +0,0 @@
-
-
-
-
-
-
-
-
-
-
\ No newline at end of file
diff --git a/example/src/main/res/layout/bottom_sheet_new_conversation.xml b/example/src/main/res/layout/bottom_sheet_new_conversation.xml
deleted file mode 100644
index 9c1b0b504..000000000
--- a/example/src/main/res/layout/bottom_sheet_new_conversation.xml
+++ /dev/null
@@ -1,40 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
diff --git a/example/src/main/res/layout/bottom_sheet_new_group.xml b/example/src/main/res/layout/bottom_sheet_new_group.xml
deleted file mode 100644
index 3e9cfed93..000000000
--- a/example/src/main/res/layout/bottom_sheet_new_group.xml
+++ /dev/null
@@ -1,56 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
diff --git a/example/src/main/res/layout/fragment_connect_wallet.xml b/example/src/main/res/layout/fragment_connect_wallet.xml
deleted file mode 100644
index 8d93ca824..000000000
--- a/example/src/main/res/layout/fragment_connect_wallet.xml
+++ /dev/null
@@ -1,63 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
-
diff --git a/example/src/main/res/layout/list_item_conversation.xml b/example/src/main/res/layout/list_item_conversation.xml
deleted file mode 100644
index f11dcffa9..000000000
--- a/example/src/main/res/layout/list_item_conversation.xml
+++ /dev/null
@@ -1,34 +0,0 @@
-
-
-
-
-
-
-
-
diff --git a/example/src/main/res/layout/list_item_conversation_footer.xml b/example/src/main/res/layout/list_item_conversation_footer.xml
deleted file mode 100644
index 90a625375..000000000
--- a/example/src/main/res/layout/list_item_conversation_footer.xml
+++ /dev/null
@@ -1,18 +0,0 @@
-
-
-
-
-
-
diff --git a/example/src/main/res/layout/list_item_message.xml b/example/src/main/res/layout/list_item_message.xml
deleted file mode 100644
index 690a1a126..000000000
--- a/example/src/main/res/layout/list_item_message.xml
+++ /dev/null
@@ -1,40 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
diff --git a/example/src/main/res/menu/menu_conversation_detail.xml b/example/src/main/res/menu/menu_conversation_detail.xml
new file mode 100644
index 000000000..f55261859
--- /dev/null
+++ b/example/src/main/res/menu/menu_conversation_detail.xml
@@ -0,0 +1,9 @@
+
+
diff --git a/example/src/main/res/menu/menu_drawer.xml b/example/src/main/res/menu/menu_drawer.xml
new file mode 100644
index 000000000..f68990e7e
--- /dev/null
+++ b/example/src/main/res/menu/menu_drawer.xml
@@ -0,0 +1,60 @@
+
+
diff --git a/example/src/main/res/menu/menu_member.xml b/example/src/main/res/menu/menu_member.xml
new file mode 100644
index 000000000..82b8c272a
--- /dev/null
+++ b/example/src/main/res/menu/menu_member.xml
@@ -0,0 +1,12 @@
+
+
+
+
+
+
diff --git a/example/src/main/res/values/colors.xml b/example/src/main/res/values/colors.xml
index f8c6127d3..4c7738c0a 100644
--- a/example/src/main/res/values/colors.xml
+++ b/example/src/main/res/values/colors.xml
@@ -1,5 +1,49 @@
+
+ #FC4F37
+ #E0422D
+ #FF7A68
+
+
+ #FFFFFF
+ #F5F5F5
+ #FAFAFA
+
+
+ #1A1A1A
+ #666666
+ #999999
+ #FFFFFF
+
+
+ #34C759
+ #FF9500
+ #FF3B30
+ #5856D6
+
+
+ #E0E0E0
+
+
+ #EFFDDE
+ #1A1A1A
+ #6FB87C
+ #FFFFFF
+ #1A1A1A
+ #8E8E93
+ #E5DDD5
+
+
+ #4CAF50
+ #4A635B
+ #FC4F37
+ #666666
+
+
+ #FC4F37
+
+
#FFBB86FC
#FF6200EE
#FF3700B3
@@ -7,4 +51,4 @@
#FF018786
#FF000000
#FFFFFFFF
-
\ No newline at end of file
+
diff --git a/example/src/main/res/values/dimens.xml b/example/src/main/res/values/dimens.xml
index f5260bbc1..ede167765 100644
--- a/example/src/main/res/values/dimens.xml
+++ b/example/src/main/res/values/dimens.xml
@@ -2,4 +2,5 @@
16dp
48dp
+ 60dp
diff --git a/example/src/main/res/values/strings.xml b/example/src/main/res/values/strings.xml
index dbb35948f..d9d075c8c 100644
--- a/example/src/main/res/values/strings.xml
+++ b/example/src/main/res/values/strings.xml
@@ -7,20 +7,136 @@
Generate wallet
Connect wallet
No wallet apps installed
+ Select Environment
+ Production
+ Dev
+
+ - Dev
+ - Production
+ - Local
+
+ Log Level
+
+ - Off
+ - Error
+ - Warn
+ - Info
+ - Debug
+ - Trace
+
Disconnect wallet
Copy address
+
+ Wallet Information
+ Wallet Address
+ Inbox ID
+ Installation ID
+ Environment
+ LibXMTP Version
+ Copy Inbox ID
+ Copy Installation ID
+ Copy Environment
+ Copy LibXMTP Version
+ %1$s copied
+
Signed in as %1$s on %2$s
- New message
+ New Message
+ New Message
+ Start a new conversation
+ Start a conversation with someone
Enter Ethereum address
+ 0x…
+ Enter a valid Ethereum address (0x followed by 40 characters)
+ Start Conversation
Create conversation
No messages yet
You: %1$s
XMTP Direct Message
+ Direct Message
+ Group Chat
+ Info
+ Loading…
+
+
+ New Group
+ Add members to create a group chat
+ Add member
+ Add
+ %1$d member(s) added
+ Create Group
+ Add another member
+ Add at least 1 member to create a group
+ Invalid Ethereum address
+ Group name (optional)
+ Recent Contacts
+ Clear
+ Last messaged %1$s
+
+
+ Group Info
+ Group Details
+ Created
+ Members
+ %1$d members
+ Your Role
+ Members
+ Leave Group
+ Are you sure you want to leave this group?
+ Leave
+ Super Admin
+ Admin
+ Member
+ Inbox: %1$s
+ Promote to Admin
+ Demote from Admin
+ Remove Member
+ Promote
+ Demote
+ Remove
+ Promote %1$s to admin?
+ Demote %1$s from admin?
+ Remove %1$s from the group?
+
+
+ Profile
+ User Details
+ Linked Identities
+ Send Message
+ Unknown
- Type a message…
+ Message
+ Reply
+ Edit
+ Delete
+ Edit Message
+ Editing message
+ Replying to %1$s
+ Emoji
+ Attach file
+ Voice message
+ Search
+ Search messages
+ No results found
+ Cancel
+
+
+ Send Attachment
+ Camera
+ Gallery
+ File
+ GIF
+ Sending attachment…
+ File is too large (max 10MB)
+ Failed to send attachment
+ Loading…
+ Download
+ Preview
+ Add a caption…
+ Send
+ %1$d of %2$d
View Logs
@@ -28,5 +144,20 @@
No log files found
Activate Persistent Logs
Deactivate Persistent Logs
-
+
+
+ Settings
+ Persistent Logs
+ Enable debug logging to file
+
+
+ New Conversation
+ New Group
+ Settings
+ Logs
+ Account
+ Open navigation drawer
+ Close navigation drawer
+ Hide Deleted Messages
+
diff --git a/example/src/main/res/values/themes.xml b/example/src/main/res/values/themes.xml
index 88ae2efc5..cfb70da3b 100644
--- a/example/src/main/res/values/themes.xml
+++ b/example/src/main/res/values/themes.xml
@@ -1,5 +1,75 @@
-
-
\ No newline at end of file
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/example/src/main/res/xml/file_paths.xml b/example/src/main/res/xml/file_paths.xml
index a0f87037d..b8353e1a2 100644
--- a/example/src/main/res/xml/file_paths.xml
+++ b/example/src/main/res/xml/file_paths.xml
@@ -1,4 +1,5 @@
+
\ No newline at end of file
diff --git a/library/src/androidTest/java/org/xmtp/android/library/DeleteMessageTest.kt b/library/src/androidTest/java/org/xmtp/android/library/DeleteMessageTest.kt
index a85f2a7db..06f71c95a 100644
--- a/library/src/androidTest/java/org/xmtp/android/library/DeleteMessageTest.kt
+++ b/library/src/androidTest/java/org/xmtp/android/library/DeleteMessageTest.kt
@@ -251,6 +251,7 @@ class DeleteMessageTest : BaseInstrumentedTest() {
assertNotNull(deletedContent)
assertTrue(deletedContent?.deletedBy is DeletedBy.Sender)
+ // Verify the content type is now deletedMessage
assertEquals("xmtp.org", boEnrichedAfterDeletion?.contentTypeId?.authorityId)
assertEquals("deletedMessage", boEnrichedAfterDeletion?.contentTypeId?.typeId)
}
diff --git a/library/src/androidTest/java/org/xmtp/android/library/EditMessageTest.kt b/library/src/androidTest/java/org/xmtp/android/library/EditMessageTest.kt
new file mode 100644
index 000000000..6837f5ae8
--- /dev/null
+++ b/library/src/androidTest/java/org/xmtp/android/library/EditMessageTest.kt
@@ -0,0 +1,269 @@
+package org.xmtp.android.library
+
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import kotlinx.coroutines.runBlocking
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertNotNull
+import org.junit.Assert.assertThrows
+import org.junit.Before
+import org.junit.Ignore
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.xmtp.android.library.codecs.EditMessage
+import org.xmtp.android.library.codecs.TextCodec
+
+@RunWith(AndroidJUnit4::class)
+@Ignore("enable when edit message is released")
+class EditMessageTest : BaseInstrumentedTest() {
+ private lateinit var fixtures: TestFixtures
+ private lateinit var alixClient: Client
+ private lateinit var boClient: Client
+ private lateinit var caroClient: Client
+
+ @Before
+ override fun setUp() {
+ super.setUp()
+ fixtures = runBlocking { createFixtures() }
+ alixClient = fixtures.alixClient
+ boClient = fixtures.boClient
+ caroClient = fixtures.caroClient
+ }
+
+ @Test
+ fun testSenderCanEditOwnMessage() {
+ val alixGroup =
+ runBlocking {
+ alixClient.conversations.newGroup(listOf(boClient.inboxId))
+ }
+
+ val originalText = "Hello, this message will be edited"
+ val messageId =
+ runBlocking {
+ alixGroup.send(originalText)
+ }
+
+ runBlocking { alixGroup.sync() }
+ var messages = runBlocking { alixGroup.messages() }
+ assertTrue(messages.any { it.id == messageId })
+
+ val editedText = "Hello, this message has been edited"
+ val editedContent = TextCodec().encode(editedText)
+ val editMessageId =
+ runBlocking {
+ alixGroup.editMessage(messageId, editedContent.toByteArray())
+ }
+ assertNotNull(editMessageId)
+
+ runBlocking { alixGroup.sync() }
+ messages = runBlocking { alixGroup.messages() }
+ assertTrue(messages.any { it.id == editMessageId })
+ }
+
+ @Test
+ fun testRegularUserCannotEditOthersMessage() {
+ val alixGroup =
+ runBlocking {
+ alixClient.conversations.newGroup(listOf(boClient.inboxId))
+ }
+
+ runBlocking { boClient.conversations.sync() }
+ val boGroup =
+ runBlocking {
+ boClient.conversations.listGroups().first { it.id == alixGroup.id }
+ }
+
+ val messageId =
+ runBlocking {
+ alixGroup.send("Hello from Alix")
+ }
+
+ runBlocking {
+ alixGroup.sync()
+ boGroup.sync()
+ }
+
+ val editedContent = TextCodec().encode("Edited by Bo")
+
+ assertThrows(XMTPException::class.java) {
+ runBlocking {
+ boGroup.editMessage(messageId, editedContent.toByteArray())
+ }
+ }
+ }
+
+ @Test
+ fun testEditMessageInDm() {
+ val alixDm =
+ runBlocking {
+ alixClient.conversations.findOrCreateDm(boClient.inboxId)
+ }
+
+ val originalText = "Hello in DM"
+ val messageId =
+ runBlocking {
+ alixDm.send(originalText)
+ }
+
+ runBlocking { alixDm.sync() }
+ var messages = runBlocking { alixDm.messages() }
+ assertTrue(messages.any { it.id == messageId })
+
+ val editedText = "Edited hello in DM"
+ val editedContent = TextCodec().encode(editedText)
+ val editMessageId =
+ runBlocking {
+ alixDm.editMessage(messageId, editedContent.toByteArray())
+ }
+ assertNotNull(editMessageId)
+
+ runBlocking { alixDm.sync() }
+ messages = runBlocking { alixDm.messages() }
+ assertTrue(messages.any { it.id == editMessageId })
+ }
+
+ @Test
+ fun testEditMessageViaConversation() {
+ val alixGroup =
+ runBlocking {
+ alixClient.conversations.newGroup(listOf(boClient.inboxId))
+ }
+
+ val conversation: Conversation = Conversation.Group(alixGroup)
+
+ val messageId =
+ runBlocking {
+ conversation.send("Hello via conversation")
+ }
+
+ val editedContent = TextCodec().encode("Edited via conversation")
+ val editMessageId =
+ runBlocking {
+ conversation.editMessage(messageId, editedContent.toByteArray())
+ }
+ assertNotNull(editMessageId)
+ }
+
+ @Test
+ fun testEditMessageWithInvalidId() {
+ val alixGroup =
+ runBlocking {
+ alixClient.conversations.newGroup(listOf(boClient.inboxId))
+ }
+
+ val editedContent = TextCodec().encode("Edited content")
+
+ assertThrows(XMTPException::class.java) {
+ runBlocking {
+ alixGroup.editMessage(
+ "0000000000000000000000000000000000000000000000000000000000000000",
+ editedContent.toByteArray(),
+ )
+ }
+ }
+ }
+
+ @Test
+ fun testReceiverSeesEditedMessageContent() {
+ val alixGroup =
+ runBlocking {
+ alixClient.conversations.newGroup(listOf(boClient.inboxId))
+ }
+
+ runBlocking { boClient.conversations.sync() }
+ val boGroup =
+ runBlocking {
+ boClient.conversations.listGroups().first { it.id == alixGroup.id }
+ }
+
+ val originalText = "Original message content"
+ val messageId =
+ runBlocking {
+ alixGroup.send(originalText)
+ }
+
+ runBlocking {
+ alixGroup.sync()
+ boGroup.sync()
+ }
+
+ var boEnrichedMessages = runBlocking { boGroup.enrichedMessages() }
+ val boOriginalEnriched = boEnrichedMessages.find { it.id == messageId }
+ assertNotNull(boOriginalEnriched)
+ assertEquals(originalText, boOriginalEnriched?.content())
+
+ val editedText = "Edited message content"
+ val editedContent = TextCodec().encode(editedText)
+ runBlocking {
+ alixGroup.editMessage(messageId, editedContent.toByteArray())
+ alixGroup.sync()
+ }
+
+ runBlocking { boGroup.sync() }
+
+ boEnrichedMessages = runBlocking { boGroup.enrichedMessages() }
+ val boEnrichedAfterEdit = boEnrichedMessages.find { it.id == messageId }
+
+ assertNotNull(boEnrichedAfterEdit)
+
+ // The enriched message should now show the edited content
+ assertEquals(editedText, boEnrichedAfterEdit?.content())
+
+ // Verify the content type is still text (the original type is preserved)
+ assertEquals("xmtp.org", boEnrichedAfterEdit?.contentTypeId?.authorityId)
+ assertEquals("text", boEnrichedAfterEdit?.contentTypeId?.typeId)
+ }
+
+ @Test
+ fun testMultipleEditsShowLatest() {
+ val alixGroup =
+ runBlocking {
+ alixClient.conversations.newGroup(listOf(boClient.inboxId))
+ }
+
+ runBlocking { boClient.conversations.sync() }
+ val boGroup =
+ runBlocking {
+ boClient.conversations.listGroups().first { it.id == alixGroup.id }
+ }
+
+ val originalText = "Version 1"
+ val messageId =
+ runBlocking {
+ alixGroup.send(originalText)
+ }
+
+ runBlocking {
+ alixGroup.sync()
+ boGroup.sync()
+ }
+
+ // First edit
+ val edit1Text = "Version 2"
+ val edit1Content = TextCodec().encode(edit1Text)
+ runBlocking {
+ alixGroup.editMessage(messageId, edit1Content.toByteArray())
+ alixGroup.sync()
+ }
+
+ // Second edit
+ val edit2Text = "Version 3"
+ val edit2Content = TextCodec().encode(edit2Text)
+ runBlocking {
+ alixGroup.editMessage(messageId, edit2Content.toByteArray())
+ alixGroup.sync()
+ }
+
+ runBlocking { boGroup.sync() }
+
+ val boEnrichedMessages = runBlocking { boGroup.enrichedMessages() }
+ val boEnrichedMessage = boEnrichedMessages.find { it.id == messageId }
+
+ assertNotNull(boEnrichedMessage)
+ // Should show the latest edit
+ assertEquals(edit2Text, boEnrichedMessage?.content())
+ }
+
+ private fun assertTrue(condition: Boolean) {
+ org.junit.Assert.assertTrue(condition)
+ }
+}