IVS Chat Client Messaging SDK: Android のチュートリアルパート 2: メッセージとイベント - Amazon IVS

IVS Chat Client Messaging SDK: Android のチュートリアルパート 2: メッセージとイベント

本チュートリアルのパート 2 (最後のパート) は、複数のセクションに分かれています。

すべての SDK ドキュメントについては、まずこの「Amazon IVS Chat ユーザーガイド」の「Amazon IVS Chat Client Messaging SDK」および GitHub の「Chat Client Messaging: SDK for Android リファレンス」を参照してください。

前提条件

このチュートリアルのパート 1 である「チャットルーム」を完了してから、このパートに進んでください。

メッセージ送信のために UI を作成する

チャットルーム接続が正常に初期化されたので、最初のメッセージを送信します。この機能には、UI が必要です。次を追加します。

  • connect/disconnect ボタン

  • send ボタンを使用したメッセージ入力

  • 動的メッセージリスト。これを構築するために、Android Jetpack RecyclerView を使用します。

UI メインレイアウト

Android デベロッパー向けドキュメントの Android Jetpack の「Layouts」を参照してください。

XML:

// ./app/src/main/res/layout/activity_main.xml <?xml version="1.0" encoding="utf-8"?> <androidx.coordinatorlayout.widget.CoordinatorLayout xmlns:android="http://schemas.android.com/apk/res/android" xmlns:app="http://schemas.android.com/apk/res-auto" xmlns:tools="http://schemas.android.com/tools" android:layout_width="match_parent" android:layout_height="match_parent"> <LinearLayout xmlns:android="http://schemas.android.com/apk/res/android" xmlns:app="http://schemas.android.com/apk/res-auto" android:id="@+id/connect_view" android:layout_width="match_parent" android:layout_height="match_parent" android:gravity="center" android:orientation="vertical"> <androidx.cardview.widget.CardView android:id="@+id/connect_button" android:layout_width="match_parent" android:layout_height="48dp" android:layout_gravity="" android:layout_marginStart="16dp" android:layout_marginTop="4dp" android:layout_marginEnd="16dp" android:clickable="true" android:elevation="16dp" android:focusable="true" android:foreground="?android:attr/selectableItemBackground" app:cardBackgroundColor="@color/purple_500" app:cardCornerRadius="10dp"> <TextView android:id="@+id/connect_text" android:layout_width="wrap_content" android:layout_height="wrap_content" android:layout_alignParentEnd="true" android:layout_gravity="center" android:layout_weight="1" android:paddingHorizontal="12dp" android:text="Connect" android:textColor="@color/white" android:textSize="16sp"/> <ProgressBar android:id="@+id/activity_indicator" android:layout_width="20dp" android:layout_height="20dp" android:layout_gravity="center" android:layout_marginHorizontal="20dp" android:indeterminateOnly="true" android:indeterminateTint="@color/white" android:indeterminateTintMode="src_atop" android:keepScreenOn="true" android:visibility="gone"/> </androidx.cardview.widget.CardView> </LinearLayout> <androidx.constraintlayout.widget.ConstraintLayout android:id="@+id/chat_view" android:layout_width="match_parent" android:layout_height="match_parent" android:clipToPadding="false" android:visibility="visible" tools:context=".MainActivity"> <RelativeLayout android:layout_width="match_parent" android:layout_height="wrap_content" android:orientation="vertical" app:layout_constraintBottom_toTopOf="@+id/layout_message_input" app:layout_constraintEnd_toEndOf="parent" app:layout_constraintStart_toStartOf="parent"> <androidx.recyclerview.widget.RecyclerView android:id="@+id/recycler_view" android:layout_width="match_parent" android:layout_height="wrap_content" android:clipToPadding="false" android:paddingTop="70dp" android:paddingBottom="20dp"/> </RelativeLayout> <RelativeLayout android:id="@+id/layout_message_input" android:layout_width="wrap_content" android:layout_height="wrap_content" android:background="@android:color/white" android:clipToPadding="false" android:drawableTop="@android:color/black" android:elevation="18dp" app:layout_constraintBottom_toBottomOf="parent" app:layout_constraintStart_toStartOf="parent"> <EditText android:id="@+id/message_edit_text" android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_centerVertical="true" android:layout_marginStart="16dp" android:layout_toStartOf="@+id/send_button" android:background="@android:color/transparent" android:hint="Enter Message" android:inputType="text" android:maxLines="6" tools:ignore="Autofill"/> <Button android:id="@+id/send_button" android:layout_width="84dp" android:layout_height="48dp" android:layout_alignParentEnd="true" android:background="@color/black" android:foreground="?android:attr/selectableItemBackground" android:text="Send" android:textColor="@color/white" android:textSize="12dp"/> </RelativeLayout> </androidx.constraintlayout.widget.ConstraintLayout> </androidx.coordinatorlayout.widget.CoordinatorLayout>

テキストを一貫して表示するための UI 抽象化テキストセル

XML:

// ./app/src/main/res/layout/common_cell.xml <?xml version="1.0" encoding="utf-8"?> <LinearLayout xmlns:android="http://schemas.android.com/apk/res/android" android:id="@+id/layout_container" android:layout_width="wrap_content" android:layout_height="wrap_content" android:background="@color/light_gray" android:minWidth="100dp" android:orientation="vertical"> <LinearLayout android:layout_width="match_parent" android:layout_height="match_parent" android:orientation="horizontal"> <TextView android:id="@+id/card_message_me_text_view" android:layout_width="wrap_content" android:layout_height="match_parent" android:layout_marginBottom="8dp" android:maxWidth="260dp" android:paddingLeft="12dp" android:paddingTop="8dp" android:paddingRight="12dp" android:text="This is a Message" android:textColor="#ffffff" android:textSize="16sp"/> <TextView android:id="@+id/failed_mark" android:layout_width="40dp" android:layout_height="match_parent" android:paddingRight="5dp" android:src="@drawable/ic_launcher_background" android:text="!" android:textAlignment="viewEnd" android:textColor="@color/white" android:textSize="25dp" android:visibility="gone"/> </LinearLayout> </LinearLayout>

UI 左側チャットメッセージ

XML:

// ./app/src/main/res/layout/card_view_left.xml <?xml version="1.0" encoding="utf-8"?> <LinearLayout xmlns:android="http://schemas.android.com/apk/res/android" xmlns:app="http://schemas.android.com/apk/res-auto" android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_marginStart="8dp" android:layout_marginBottom="12dp" android:orientation="vertical"> <TextView android:id="@+id/username_edit_text" android:layout_width="wrap_content" android:layout_height="wrap_content" android:text="UserName"/> <androidx.constraintlayout.widget.ConstraintLayout android:layout_width="match_parent" android:layout_height="wrap_content"> <androidx.cardview.widget.CardView android:id="@+id/card_message_other" android:layout_width="wrap_content" android:layout_height="wrap_content" android:layout_gravity="left" android:layout_marginBottom="4dp" android:foreground="?android:attr/selectableItemBackground" app:cardBackgroundColor="@color/light_gray_2" app:cardCornerRadius="10dp" app:cardElevation="0dp" app:layout_constraintBottom_toBottomOf="parent" app:layout_constraintStart_toStartOf="parent"> <include layout="@layout/common_cell"/> </androidx.cardview.widget.CardView> <TextView android:id="@+id/dateText" android:layout_width="wrap_content" android:layout_height="wrap_content" android:layout_marginLeft="4dp" android:layout_marginBottom="4dp" android:text="10:00" app:layout_constraintBottom_toBottomOf="@+id/card_message_other" app:layout_constraintLeft_toRightOf="@+id/card_message_other"/> </androidx.constraintlayout.widget.ConstraintLayout> </LinearLayout>

UI 右側チャットメッセージ

XML:

// ./app/src/main/res/layout/card_view_right.xml <?xml version="1.0" encoding="utf-8"?> <androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android" xmlns:app="http://schemas.android.com/apk/res-auto" android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_marginEnd="8dp"> <androidx.cardview.widget.CardView android:id="@+id/card_message_me" android:layout_width="wrap_content" android:layout_height="wrap_content" android:layout_gravity="right" android:layout_marginBottom="10dp" android:foreground="?android:attr/selectableItemBackground" app:cardBackgroundColor="@color/purple_500" app:cardCornerRadius="10dp" app:cardElevation="0dp" app:cardPreventCornerOverlap="false" app:cardUseCompatPadding="true" app:layout_constraintBottom_toBottomOf="parent" app:layout_constraintEnd_toEndOf="parent"> <include layout="@layout/common_cell"/> </androidx.cardview.widget.CardView> <TextView android:id="@+id/dateText" android:layout_width="wrap_content" android:layout_height="wrap_content" android:layout_marginRight="12dp" android:layout_marginBottom="4dp" android:text="10:00" app:layout_constraintBottom_toBottomOf="@+id/card_message_me" app:layout_constraintRight_toLeftOf="@+id/card_message_me"/> </androidx.constraintlayout.widget.ConstraintLayout>

UI その他の色の値

XML:

// ./app/src/main/res/values/colors.xml <?xml version="1.0" encoding="utf-8"?> <resources> <!-- ...--> <color name="dark_gray">#4F4F4F</color> <color name="blue">#186ED3</color> <color name="dark_red">#b30000</color> <color name="light_gray">#B7B7B7</color> <color name="light_gray_2">#eef1f6</color> </resources>

ビューバインディングを適用する

XML レイアウト用にバインディングクラスを参照できるように、Android のビューバインディング機能を活用します。この機能を有効にするには、./app/build.gradleviewBinding ビルドオプションを true に設定します。

Kotlin スクリプト:

// ./app/build.gradle android { // ... buildFeatures { viewBinding = true } // ... }

ここで、UI を Kotlin コードに接続します。

Kotlin:

// ./app/src/main/java/com/chatterbox/myapp/MainActivity.kt package com.chatterbox.myapp // ... const val TAG = "Chatterbox-MyApp" class MainActivity : AppCompatActivity() { // ... private fun sendMessage(request: SendMessageRequest) { try { room?.sendMessage( request, object : SendMessageCallback { override fun onRejected(request: SendMessageRequest, error: ChatError) { runOnUiThread { entries.addFailedRequest(request) scrollToBottom() Log.e(TAG, "Message rejected: ${error.errorMessage}") } } } ) entries.addPendingRequest(request) binding.messageEditText.text.clear() scrollToBottom() } catch (error: Exception) { Log.e(TAG, error.message ?: "Unknown error occurred") } } private fun scrollToBottom() { binding.recyclerView.smoothScrollToPosition(entries.size - 1) } private fun sendButtonClick(view: View) { val content = binding.messageEditText.text.toString() if (content.trim().isEmpty()) { return } val request = SendMessageRequest(content) sendMessage(request) } }

また、メッセージを削除したり、ユーザーをチャットから切断したりするメソッドも追加します。これらのメソッドは、チャットメッセージのコンテキストメニューを使用して呼び出すことができます。

Kotlin:

// ./app/src/main/java/com/chatterbox/myapp/MainActivity.kt package com.chatterbox.myapp // ... class MainActivity : AppCompatActivity() { // ... private fun deleteMessage(request: DeleteMessageRequest) { room?.deleteMessage( request, object : DeleteMessageCallback { override fun onRejected(request: DeleteMessageRequest, error: ChatError) { runOnUiThread { Log.d(TAG, "Delete message rejected: ${error.errorMessage}") } } } ) } private fun disconnectUser(request: DisconnectUserRequest) { room?.disconnectUser( request, object : DisconnectUserCallback { override fun onRejected(request: DisconnectUserRequest, error: ChatError) { runOnUiThread { Log.d(TAG, "Disconnect user rejected: ${error.errorMessage}") } } } ) } }

チャットメッセージのリクエストを管理する

考えられるすべての状態を通じてチャットメッセージ リクエストを管理する方法が必要です。

  • Pending (保留中) — メッセージはチャットルームに送信されましたが、まだ確認または拒否されていません。

  • Confirmed (確認済み) — チャットルームから (私たちを含む) すべてのユーザーにメッセージが送信されました。

  • Rejected (拒否) — メッセージはエラーオブジェクトによってチャットルームに拒否されました。

未解決のチャットリクエストとチャットメッセージはリストに保持されます。このリストは別のクラスにする価値があり、私たちは ChatEntries.kt と呼んでいます。

Kotlin:

// ./app/src/main/java/com/chatterbox/myapp/ChatEntries.kt package com.chatterbox.myapp import com.amazonaws.ivs.chat.messaging.entities.ChatMessage import com.amazonaws.ivs.chat.messaging.requests.SendMessageRequest sealed class ChatEntry() { class Message(val message: ChatMessage) : ChatEntry() class PendingRequest(val request: SendMessageRequest) : ChatEntry() class FailedRequest(val request: SendMessageRequest) : ChatEntry() } class ChatEntries { /* This list is kept in sorted order. ChatMessages are sorted by date, while pending and failed requests are kept in their original insertion point. */ val entries = mutableListOf<ChatEntry>() var adapter: ChatListAdapter? = null val size get() = entries.size /** * Insert pending request at the end. */ fun addPendingRequest(request: SendMessageRequest) { val insertIndex = entries.size entries.add(insertIndex, ChatEntry.PendingRequest(request)) adapter?.notifyItemInserted(insertIndex) } /** * Insert received message at proper place based on sendTime. This can cause removal of pending requests. */ fun addReceivedMessage(message: ChatMessage) { /* Skip if we have already handled that message. */ val existingIndex = entries.indexOfLast { it is ChatEntry.Message && it.message.id == message.id } if (existingIndex != -1) { return } val removeIndex = entries.indexOfLast { it is ChatEntry.PendingRequest && it.request.requestId == message.requestId } if (removeIndex != -1) { entries.removeAt(removeIndex) } val insertIndexRaw = entries.indexOfFirst { it is ChatEntry.Message && it.message.sendTime > message.sendTime } val insertIndex = if (insertIndexRaw == -1) entries.size else insertIndexRaw entries.add(insertIndex, ChatEntry.Message(message)) if (removeIndex == -1) { adapter?.notifyItemInserted(insertIndex) } else if (removeIndex == insertIndex) { adapter?.notifyItemChanged(insertIndex) } else { adapter?.notifyItemRemoved(removeIndex) adapter?.notifyItemInserted(insertIndex) } } fun addFailedRequest(request: SendMessageRequest) { val removeIndex = entries.indexOfLast { it is ChatEntry.PendingRequest && it.request.requestId == request.requestId } if (removeIndex != -1) { entries.removeAt(removeIndex) entries.add(removeIndex, ChatEntry.FailedRequest(request)) adapter?.notifyItemChanged(removeIndex) } else { val insertIndex = entries.size entries.add(insertIndex, ChatEntry.FailedRequest(request)) adapter?.notifyItemInserted(insertIndex) } } fun removeMessage(messageId: String) { val removeIndex = entries.indexOfFirst { it is ChatEntry.Message && it.message.id == messageId } entries.removeAt(removeIndex) adapter?.notifyItemRemoved(removeIndex) } fun removeFailedRequest(requestId: String) { val removeIndex = entries.indexOfFirst { it is ChatEntry.FailedRequest && it.request.requestId == requestId } entries.removeAt(removeIndex) adapter?.notifyItemRemoved(removeIndex) } fun removeAll() { entries.clear() } }

リストを UI に接続するには、アダプターを使用します。詳細については、「AdapterView を使用したデータへのバインディング」と「生成されたバインディングクラス」を参照してください。

Kotlin:

// ./app/src/main/java/com/chatterbox/myapp/ChatListAdapter.kt package com.chatterbox.myapp import android.content.Context import android.graphics.Color import android.view.LayoutInflater import android.view.View import android.view.ViewGroup import android.widget.LinearLayout import android.widget.TextView import androidx.core.content.ContextCompat import androidx.core.view.isGone import androidx.recyclerview.widget.RecyclerView import com.amazonaws.ivs.chat.messaging.requests.DisconnectUserRequest import java.text.DateFormat class ChatListAdapter( private val entries: ChatEntries, private val onDisconnectUser: (request: DisconnectUserRequest) -> Unit, ) : RecyclerView.Adapter<ChatListAdapter.ViewHolder>() { var context: Context? = null var userId: String? = null class ViewHolder(view: View) : RecyclerView.ViewHolder(view) { val container: LinearLayout = view.findViewById(R.id.layout_container) val textView: TextView = view.findViewById(R.id.card_message_me_text_view) val failedMark: TextView = view.findViewById(R.id.failed_mark) val userNameText: TextView? = view.findViewById(R.id.username_edit_text) val dateText: TextView? = view.findViewById(R.id.dateText) } override fun onCreateViewHolder(viewGroup: ViewGroup, viewType: Int): ViewHolder { if (viewType == 0) { val rightView = LayoutInflater.from(viewGroup.context).inflate(R.layout.card_view_right, viewGroup, false) return ViewHolder(rightView) } val leftView = LayoutInflater.from(viewGroup.context).inflate(R.layout.card_view_left, viewGroup, false) return ViewHolder(leftView) } override fun getItemViewType(position: Int): Int { // Int 0 indicates to my message while Int 1 to other message val chatMessage = entries.entries[position] return if (chatMessage is ChatEntry.Message && chatMessage.message.sender.userId != userId) 1 else 0 } override fun onBindViewHolder(viewHolder: ViewHolder, position: Int) { return when (val entry = entries.entries[position]) { is ChatEntry.Message -> { viewHolder.textView.text = entry.message.content val bgColor = if (entry.message.sender.userId == userId) { R.color.purple_500 } else { R.color.light_gray_2 } viewHolder.container.setBackgroundColor(ContextCompat.getColor(context!!, bgColor)) if (entry.message.sender.userId != userId) { viewHolder.textView.setTextColor(Color.parseColor("#000000")) } viewHolder.failedMark.isGone = true viewHolder.itemView.setOnCreateContextMenuListener { menu, _, _ -> menu.add("Kick out").setOnMenuItemClickListener { val request = DisconnectUserRequest(entry.message.sender.userId, "Some reason") onDisconnectUser(request) true } } viewHolder.userNameText?.text = entry.message.sender.userId viewHolder.dateText?.text = DateFormat.getTimeInstance(DateFormat.SHORT).format(entry.message.sendTime) } is ChatEntry.PendingRequest -> { viewHolder.container.setBackgroundColor(ContextCompat.getColor(context!!, R.color.light_gray)) viewHolder.textView.text = entry.request.content viewHolder.failedMark.isGone = true viewHolder.itemView.setOnCreateContextMenuListener(null) viewHolder.dateText?.text = "Sending" } is ChatEntry.FailedRequest -> { viewHolder.textView.text = entry.request.content viewHolder.container.setBackgroundColor(ContextCompat.getColor(context!!, R.color.dark_red)) viewHolder.failedMark.isGone = false viewHolder.dateText?.text = "Failed" } } } override fun onAttachedToRecyclerView(recyclerView: RecyclerView) { super.onAttachedToRecyclerView(recyclerView) context = recyclerView.context } override fun getItemCount() = entries.entries.size }

最後のステップ

新しいアダプターを接続して、ChatEntries クラスを MainActivity にバインドします。

Kotlin:

// ./app/src/main/java/com/chatterbox/myapp/MainActivity.kt package com.chatterbox.myapp // ... import com.chatterbox.myapp.databinding.ActivityMainBinding import com.chatterbox.myapp.ChatListAdapter import com.chatterbox.myapp.ChatEntries class MainActivity : AppCompatActivity() { // ... private var entries = ChatEntries() private lateinit var adapter: ChatListAdapter private lateinit var binding: ActivityMainBinding /* see https://developer.android.com/topic/libraries/data-binding/generated-binding#create */ override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) binding = ActivityMainBinding.inflate(layoutInflater) setContentView(binding.root) /* Create room instance. */ room = ChatRoom(REGION, ::fetchChatToken).apply { listener = roomListener } binding.sendButton.setOnClickListener(::sendButtonClick) binding.connectButton.setOnClickListener { connect() } setUpChatView() updateConnectionState(ConnectionState.DISCONNECTED) } private fun setUpChatView() { /* Setup Android Jetpack RecyclerView - see https://developer.android.com/develop/ui/views/layout/recyclerview.*/ adapter = ChatListAdapter(entries, ::disconnectUser) entries.adapter = adapter val recyclerViewLayoutManager = LinearLayoutManager(this@MainActivity, LinearLayoutManager.VERTICAL, false) binding.recyclerView.layoutManager = recyclerViewLayoutManager binding.recyclerView.adapter = adapter binding.sendButton.setOnClickListener(::sendButtonClick) binding.messageEditText.setOnEditorActionListener { _, _, event -> val isEnterDown = (event.action == KeyEvent.ACTION_DOWN) && (event.keyCode == KeyEvent.KEYCODE_ENTER) if (!isEnterDown) { return@setOnEditorActionListener false } sendButtonClick(binding.sendButton) return@setOnEditorActionListener true } } }

チャットリクエストの追跡を行うクラス (ChatEntries) が既にあるので、roomListenerentries を操作するためのコードを実装する準備が整いました。対応しているイベントに応じて entriesconnectionState を更新します。

Kotlin:

// ./app/src/main/java/com/chatterbox/myapp/MainActivity.kt package com.chatterbox.myapp // ... class MainActivity : AppCompatActivity() { //... private fun sendMessage(request: SendMessageRequest) { //... } private fun scrollToBottom() { binding.recyclerView.smoothScrollToPosition(entries.size - 1) } private val roomListener = object : ChatRoomListener { override fun onConnecting(room: ChatRoom) { Log.d(TAG, "[${Thread.currentThread().name}] onConnecting") runOnUiThread { updateConnectionState(ConnectionState.LOADING) } } override fun onConnected(room: ChatRoom) { Log.d(TAG, "[${Thread.currentThread().name}] onConnected") runOnUiThread { updateConnectionState(ConnectionState.CONNECTED) } } override fun onDisconnected(room: ChatRoom, reason: DisconnectReason) { Log.d(TAG, "[${Thread.currentThread().name}] onDisconnected") runOnUiThread { updateConnectionState(ConnectionState.DISCONNECTED) entries.removeAll() } } override fun onMessageReceived(room: ChatRoom, message: ChatMessage) { Log.d(TAG, "[${Thread.currentThread().name}] onMessageReceived $message") runOnUiThread { entries.addReceivedMessage(message) scrollToBottom() } } override fun onEventReceived(room: ChatRoom, event: ChatEvent) { Log.d(TAG, "[${Thread.currentThread().name}] onEventReceived $event") } override fun onMessageDeleted(room: ChatRoom, event: DeleteMessageEvent) { Log.d(TAG, "[${Thread.currentThread().name}] onMessageDeleted $event") } override fun onUserDisconnected(room: ChatRoom, event: DisconnectUserEvent) { Log.d(TAG, "[${Thread.currentThread().name}] onUserDisconnected $event") } } }

これでアプリケーションを実行できます。(「アプリをビルドして実行する」を参照してください。) アプリを使用する際には、必ずバックエンドサーバーが稼働しているようにしてください。プロジェクトのルートにあるターミナルから起動するには、このコマンド (./gradlew :auth-server:run) を使用するか、または Android Studio から auth-server:run Gradle タスクを直接実行します。