IVS 聊天用戶端傳訊 SDK:Kotlin Coroutines 教學課程第 2 部分:訊息和事件 - Amazon IVS

IVS 聊天用戶端傳訊 SDK:Kotlin Coroutines 教學課程第 2 部分:訊息和事件

本教學課程的第二部分 (也是最後一部分) 分為幾個部分:

如需完整的 SDK 文件,請先閱讀 Amazon IVS 聊天用戶端傳訊 SDK (載於《Amazon IVS 聊天功能使用者指南》中) 和 Chat Client Messaging: SDK for Android Reference (聊天用戶端傳訊:Android 版 SDK 參考) (位於 GitHub 上)。

先決條件

請確定您已完成本教學課程的第 1 部分:聊天室

建立用於傳送訊息的 UI

現在我們已成功初始化聊天室連線,因此可傳送第一條訊息。對於此功能,需要 UI。我們將新增:

  • connect/disconnect 按鈕

  • send 按鈕的訊息輸入

  • 動態訊息清單。若要進行構建,我們可使用 Android Jetpack RecyclerView

UI 主要版面配置

請參閱 Android 開發人員文件中的 Android Jetpack 版面配置

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>

套用檢視綁定

我們利用 Android 檢視綁定功能,可以為 XML 版面配置參考綁定類別。若要啟用此功能,請在 ./app/build.gradle 中將 viewBinding 建置選項設定為 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 // ... class MainActivity : AppCompatActivity() { // ... private lateinit var binding: ActivityMainBinding override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) binding = ActivityMainBinding.inflate(layoutInflater) setContentView(binding.root) // Create room instance room = ChatRoom(REGION, ::fetchChatToken).apply { // ... } binding.sendButton.setOnClickListener(::sendButtonClick) binding.connectButton.setOnClickListener {connect()} setUpChatView() updateConnectionState(ChatRoom.State.DISCONNECTED) } private fun sendMessage(request: SendMessageRequest) { lifecycleScope.launch { try { binding.messageEditText.text.clear() room?.awaitSendMessage(request) } catch (exception: ChatException) { Log.e(TAG, "Message rejected: ${exception.message}") } catch (exception: Exception) { Log.e(TAG, exception.message ?: "Unknown error occurred") } } } 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) { lifecycleScope.launch { try { room?.awaitDeleteMessage(request) } catch (exception: ChatException) { Log.e(TAG, "Delete message rejected: ${exception.message}") } catch (exception: Exception) { Log.e(TAG, exception.message ?: "Unknown error occurred") } } } private fun disconnectUser(request: DisconnectUserRequest) { lifecycleScope.launch { try { room?.awaitDisconnectUser(request) } catch (exception: ChatException) { Log.e(TAG, "Disconnect user rejected: ${exception.message}") } catch (exception: Exception) { Log.e(TAG, exception.message ?: "Unknown error occurred") } } } }

管理聊天訊息請求

我們需要一種透過其所有可能狀態來管理聊天訊息請求的方法:

  • 待定 – 訊息已傳送至聊天室,但尚未確認或拒絕。

  • 已確認 – 聊天室已將訊息傳送給所有使用者 (包括我們)。

  • 已拒絕 – 訊息被聊天室拒絕,其中包含錯誤物件。

我們會將未解決的聊天請求和聊天訊息保留在清單中。清單的優點是具有單獨的類別,我們稱之為 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 fun setUpChatView() { 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) 的類別,因此我們已經準備好實作程式碼,以便在 roomListener 中操作 entries。我們會相應地將 entriesconnectionState 更新到我們正在回應的事件:

Kotlin:

// ./app/src/main/java/com/chatterbox/myapp/MainActivity.kt package com.chatterbox.myapp // ... class MainActivity : AppCompatActivity() { // ... override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) binding = ActivityMainBinding.inflate(layoutInflater) setContentView(binding.root) // Create room instance room = ChatRoom(REGION, ::fetchChatToken).apply { lifecycleScope.launch { stateChanges().collect { state -> Log.d(TAG, "state change to $state") updateConnectionState(state) if (state == ChatRoom.State.DISCONNECTED) { entries.removeAll() } } } lifecycleScope.launch { receivedMessages().collect { message -> Log.d(TAG, "messageReceived $message") entries.addReceivedMessage(message) } } lifecycleScope.launch { receivedEvents().collect { event -> Log.d(TAG, "eventReceived $event") } } lifecycleScope.launch { deletedMessages().collect { event -> Log.d(TAG, "messageDeleted $event") entries.removeMessage(event.messageId) } } lifecycleScope.launch { disconnectedUsers().collect { event -> Log.d(TAG, "userDisconnected $event") } } } binding.sendButton.setOnClickListener(::sendButtonClick) binding.connectButton.setOnClickListener {connect()} setUpChatView() updateConnectionState(ChatRoom.State.DISCONNECTED) } // ... }

現在您應該能夠執行您的應用程式了!(請參閱建置並執行您的應用程式。) 切記在使用應用程式時執行後端伺服器。您可以從專案根目錄的終端中使用 ./gradlew :auth-server:run 命令將其啟動,或者直接從 Android Studio 中執行 auth-server:run Gradle 任務來啟動。