一、項目結構
text
app/
├── src/main/
│ ├── java/com/example/bluetoothchat/
│ │ ├── MainActivity.kt # 設備列表頁
│ │ ├── ChatActivity.kt # 聊天頁
│ │ ├── service/
│ │ │ ├── BluetoothService.kt # 藍牙服務
│ │ │ └── ConnectionThread.kt # 藍牙連接線程
│ │ ├── adapter/
│ │ │ ├── DeviceAdapter.kt # 設備列表適配器
│ │ │ └── MessageAdapter.kt # 消息列表適配器
│ │ ├── model/
│ │ │ ├── BluetoothDeviceItem.kt # 設備模型
│ │ │ └── Message.kt # 消息模型
│ │ └── utils/
│ │ └── Constants.kt # 常量定義
│ ├── res/
│ │ ├── layout/
│ │ │ ├── activity_main.xml # 主頁面佈局
│ │ │ ├── activity_chat.xml # 聊天頁面佈局
│ │ │ ├── item_device.xml # 設備列表項佈局
│ │ │ └── item_message.xml # 消息列表項佈局
│ │ └── values/
│ │ └── strings.xml # 字符串資源
│ └── AndroidManifest.xml
二、Gradle配置
app/build.gradle.kts
kotlin
plugins {
id("com.android.application")
id("org.jetbrains.kotlin.android")
}
android {
namespace = "com.example.bluetoothchat"
compileSdk = 34
defaultConfig {
applicationId = "com.example.bluetoothchat"
minSdk = 21
targetSdk = 34
versionCode = 1
versionName = "1.0"
}
buildTypes {
release {
isMinifyEnabled = false
proguardFiles(
getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro"
)
}
}
buildFeatures {
viewBinding = true
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_1_8
targetCompatibility = JavaVersion.VERSION_1_8
}
kotlinOptions {
jvmTarget = "1.8"
}
}
dependencies {
implementation("androidx.core:core-ktx:1.12.0")
implementation("androidx.appcompat:appcompat:1.6.1")
implementation("com.google.android.material:material:1.11.0")
implementation("androidx.constraintlayout:constraintlayout:2.1.4")
// Lifecycle components
implementation("androidx.lifecycle:lifecycle-viewmodel-ktx:2.7.0")
implementation("androidx.lifecycle:lifecycle-livedata-ktx:2.7.0")
// RecyclerView
implementation("androidx.recyclerview:recyclerview:1.3.2")
// Coroutines
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.3")
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.7.3")
}
三、核心代碼實現
1. 權限和UUID定義
utils/Constants.kt
kotlin
object Constants {
// Bluetooth UUID
val MY_UUID: java.util.UUID = java.util.UUID.fromString("00001101-0000-1000-8000-00805F9B34FB")
// Request codes
const val REQUEST_ENABLE_BT = 1
const val REQUEST_LOCATION_PERMISSION = 2
const val REQUEST_BLUETOOTH_PERMISSION = 3
// Message types
const val MESSAGE_TYPE_SENT = 0
const val MESSAGE_TYPE_RECEIVED = 1
const val MESSAGE_TYPE_SYSTEM = 2
// Intent actions
const val ACTION_MESSAGE_RECEIVED = "MESSAGE_RECEIVED"
const val ACTION_CONNECTION_STATUS_CHANGED = "CONNECTION_STATUS_CHANGED"
const val ACTION_DATA_WRITTEN = "DATA_WRITTEN"
// Extras
const val EXTRA_MESSAGE = "message"
const val EXTRA_DEVICE = "device"
const val EXTRA_IS_CONNECTED = "is_connected"
// Quick phrases
val QUICK_PHRASES = arrayOf(
"你好",
"已收到",
"確認",
"開始",
"暫停",
"完成",
"錯誤",
"謝謝"
)
}
2. 數據模型
model/BluetoothDeviceItem.kt
kotlin
data class BluetoothDeviceItem(
val name: String,
val address: String,
var isConnected: Boolean = false,
var lastConnectedTime: Long = 0,
val messageHistory: MutableList<Message> = mutableListOf()
) {
companion object {
fun fromBluetoothDevice(device: android.bluetooth.BluetoothDevice): BluetoothDeviceItem {
return BluetoothDeviceItem(
name = device.name ?: "Unknown Device",
address = device.address
)
}
}
}
model/Message.kt
kotlin
data class Message(
val id: String,
val content: String,
val timestamp: Long,
val type: Int, // 0: sent, 1: received, 2: system
val deviceAddress: String
) {
companion object {
fun createSentMessage(content: String, deviceAddress: String): Message {
return Message(
id = System.currentTimeMillis().toString(),
content = content,
timestamp = System.currentTimeMillis(),
type = Constants.MESSAGE_TYPE_SENT,
deviceAddress = deviceAddress
)
}
fun createReceivedMessage(content: String, deviceAddress: String): Message {
return Message(
id = System.currentTimeMillis().toString(),
content = content,
timestamp = System.currentTimeMillis(),
type = Constants.MESSAGE_TYPE_RECEIVED,
deviceAddress = deviceAddress
)
}
fun createSystemMessage(content: String, deviceAddress: String): Message {
return Message(
id = System.currentTimeMillis().toString(),
content = content,
timestamp = System.currentTimeMillis(),
type = Constants.MESSAGE_TYPE_SYSTEM,
deviceAddress = deviceAddress
)
}
}
}
3. 主頁面 - 設備列表
MainActivity.kt
kotlin
package com.example.bluetoothchat
import android.Manifest
import android.bluetooth.BluetoothAdapter
import android.bluetooth.BluetoothDevice
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.content.IntentFilter
import android.content.pm.PackageManager
import android.os.Build
import android.os.Bundle
import android.view.View
import android.widget.Toast
import androidx.appcompat.app.AppCompatActivity
import androidx.core.app.ActivityCompat
import androidx.core.content.ContextCompat
import androidx.recyclerview.widget.LinearLayoutManager
import com.example.bluetoothchat.adapter.DeviceAdapter
import com.example.bluetoothchat.databinding.ActivityMainBinding
import com.example.bluetoothchat.model.BluetoothDeviceItem
class MainActivity : AppCompatActivity(), DeviceAdapter.OnDeviceClickListener {
private lateinit var binding: ActivityMainBinding
private lateinit var bluetoothAdapter: BluetoothAdapter
private lateinit var deviceAdapter: DeviceAdapter
private val devices = mutableListOf<BluetoothDeviceItem>()
private var isScanning = false
// Broadcast receiver for discovered devices
private val receiver = object : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) {
when (intent.action) {
BluetoothDevice.ACTION_FOUND -> {
val device: BluetoothDevice? = intent.getParcelableExtra(BluetoothDevice.EXTRA_DEVICE)
device?.let {
if (it.name != null) {
val deviceItem = BluetoothDeviceItem.fromBluetoothDevice(it)
if (devices.none { d -> d.address == deviceItem.address }) {
devices.add(deviceItem)
deviceAdapter.notifyDataSetChanged()
}
}
}
}
}
}
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
binding = ActivityMainBinding.inflate(layoutInflater)
setContentView(binding.root)
setupBluetooth()
setupRecyclerView()
setupClickListeners()
registerReceiver()
}
private fun setupBluetooth() {
bluetoothAdapter = BluetoothAdapter.getDefaultAdapter()
if (bluetoothAdapter == null) {
Toast.makeText(this, "設備不支持藍牙", Toast.LENGTH_SHORT).show()
finish()
}
if (!bluetoothAdapter.isEnabled) {
val enableBtIntent = Intent(BluetoothAdapter.ACTION_REQUEST_ENABLE)
startActivityForResult(enableBtIntent, Constants.REQUEST_ENABLE_BT)
}
}
private fun setupRecyclerView() {
deviceAdapter = DeviceAdapter(devices, this)
binding.deviceRecyclerView.apply {
layoutManager = LinearLayoutManager(this@MainActivity)
adapter = deviceAdapter
}
}
private fun setupClickListeners() {
binding.scanButton.setOnClickListener {
if (checkPermissions()) {
toggleScan()
} else {
requestPermissions()
}
}
}
private fun registerReceiver() {
val filter = IntentFilter(BluetoothDevice.ACTION_FOUND)
registerReceiver(receiver, filter)
}
private fun checkPermissions(): Boolean {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
return ContextCompat.checkSelfPermission(this, Manifest.permission.BLUETOOTH_SCAN) == PackageManager.PERMISSION_GRANTED &&
ContextCompat.checkSelfPermission(this, Manifest.permission.BLUETOOTH_CONNECT) == PackageManager.PERMISSION_GRANTED &&
ContextCompat.checkSelfPermission(this, Manifest.permission.ACCESS_FINE_LOCATION) == PackageManager.PERMISSION_GRANTED
} else {
return ContextCompat.checkSelfPermission(this, Manifest.permission.ACCESS_FINE_LOCATION) == PackageManager.PERMISSION_GRANTED
}
}
private fun requestPermissions() {
val permissions = mutableListOf<String>()
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
permissions.add(Manifest.permission.BLUETOOTH_SCAN)
permissions.add(Manifest.permission.BLUETOOTH_CONNECT)
}
permissions.add(Manifest.permission.ACCESS_FINE_LOCATION)
ActivityCompat.requestPermissions(this, permissions.toTypedArray(), Constants.REQUEST_LOCATION_PERMISSION)
}
private fun toggleScan() {
if (isScanning) {
stopScan()
} else {
startScan()
}
}
private fun startScan() {
if (bluetoothAdapter.isDiscovering) {
bluetoothAdapter.cancelDiscovery()
}
bluetoothAdapter.startDiscovery()
isScanning = true
binding.scanButton.text = "停止掃描"
binding.progressBar.visibility = View.VISIBLE
}
private fun stopScan() {
if (bluetoothAdapter.isDiscovering) {
bluetoothAdapter.cancelDiscovery()
}
isScanning = false
binding.scanButton.text = "開始掃描"
binding.progressBar.visibility = View.GONE
}
override fun onDeviceClick(device: BluetoothDeviceItem) {
val intent = Intent(this, ChatActivity::class.java).apply {
putExtra(Constants.EXTRA_DEVICE, device)
}
startActivity(intent)
}
override fun onRequestPermissionsResult(
requestCode: Int,
permissions: Array<out String>,
grantResults: IntArray
) {
super.onRequestPermissionsResult(requestCode, permissions, grantResults)
if (requestCode == Constants.REQUEST_LOCATION_PERMISSION) {
if (grantResults.all { it == PackageManager.PERMISSION_GRANTED }) {
startScan()
} else {
Toast.makeText(this, "需要權限才能掃描藍牙設備", Toast.LENGTH_SHORT).show()
}
}
}
override fun onDestroy() {
super.onDestroy()
unregisterReceiver(receiver)
stopScan()
}
}
4. 聊天頁面
ChatActivity.kt
kotlin
package com.example.bluetoothchat
import android.Manifest
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.content.IntentFilter
import android.os.Build
import android.os.Bundle
import android.view.View
import android.widget.Toast
import androidx.appcompat.app.AppCompatActivity
import androidx.core.content.ContextCompat
import androidx.recyclerview.widget.LinearLayoutManager
import com.example.bluetoothchat.adapter.MessageAdapter
import com.example.bluetoothchat.databinding.ActivityChatBinding
import com.example.bluetoothchat.model.BluetoothDeviceItem
import com.example.bluetoothchat.model.Message
import com.example.bluetoothchat.service.BluetoothService
class ChatActivity : AppCompatActivity() {
private lateinit var binding: ActivityChatBinding
private lateinit var device: BluetoothDeviceItem
private lateinit var messageAdapter: MessageAdapter
private val messages = mutableListOf<Message>()
private var isConnected = false
private var bluetoothService: BluetoothService? = null
private val messageReceiver = object : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) {
when (intent.action) {
Constants.ACTION_MESSAGE_RECEIVED -> {
val message = intent.getStringExtra(Constants.EXTRA_MESSAGE)
val deviceAddress = intent.getStringExtra("device_address")
if (deviceAddress == device.address) {
message?.let {
val receivedMessage = Message.createReceivedMessage(it, device.address)
addMessage(receivedMessage)
}
}
}
Constants.ACTION_CONNECTION_STATUS_CHANGED -> {
val connected = intent.getBooleanExtra(Constants.EXTRA_IS_CONNECTED, false)
val deviceAddress = intent.getStringExtra("device_address")
if (deviceAddress == device.address) {
isConnected = connected
updateConnectionStatus()
}
}
Constants.ACTION_DATA_WRITTEN -> {
val message = intent.getStringExtra(Constants.EXTRA_MESSAGE)
message?.let {
val sentMessage = Message.createSentMessage(it, device.address)
addMessage(sentMessage)
}
}
}
}
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
binding = ActivityChatBinding.inflate(layoutInflater)
setContentView(binding.root)
device = intent.getParcelableExtra(Constants.EXTRA_DEVICE)!!
setupUI()
setupBluetoothService()
registerReceivers()
}
private fun setupUI() {
title = device.name
messageAdapter = MessageAdapter(messages)
binding.messageRecyclerView.apply {
layoutManager = LinearLayoutManager(this@ChatActivity)
adapter = messageAdapter
}
setupQuickPhrases()
setupClickListeners()
updateConnectionStatus()
}
private fun setupQuickPhrases() {
Constants.QUICK_PHRASES.forEach { phrase ->
binding.quickPhrasesLayout.addView(
androidx.appcompat.widget.AppCompatButton(this).apply {
text = phrase
setOnClickListener { sendMessage(phrase) }
setBackgroundColor(ContextCompat.getColor(this@ChatActivity, R.color.quick_phrase_bg))
setTextColor(ContextCompat.getColor(this@ChatActivity, R.color.quick_phrase_text))
}
)
}
}
private fun setupClickListeners() {
binding.sendButton.setOnClickListener {
val message = binding.messageEditText.text.toString().trim()
if (message.isNotEmpty()) {
sendMessage(message)
binding.messageEditText.text.clear()
}
}
binding.connectButton.setOnClickListener {
if (isConnected) {
disconnectDevice()
} else {
connectDevice()
}
}
}
private fun setupBluetoothService() {
bluetoothService = BluetoothService(this)
connectDevice()
}
private fun connectDevice() {
if (checkBluetoothPermissions()) {
bluetoothService?.connect(device)
} else {
requestBluetoothPermissions()
}
}
private fun disconnectDevice() {
bluetoothService?.disconnect(device.address)
}
private fun checkBluetoothPermissions(): Boolean {
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
ContextCompat.checkSelfPermission(this, Manifest.permission.BLUETOOTH_CONNECT) ==
PackageManager.PERMISSION_GRANTED
} else {
true
}
}
private fun requestBluetoothPermissions() {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
requestPermissions(
arrayOf(Manifest.permission.BLUETOOTH_CONNECT),
Constants.REQUEST_BLUETOOTH_PERMISSION
)
}
}
private fun sendMessage(message: String) {
if (isConnected && message.isNotEmpty()) {
bluetoothService?.sendMessage(device.address, message)
} else {
Toast.makeText(this, "設備未連接", Toast.LENGTH_SHORT).show()
}
}
private fun addMessage(message: Message) {
messages.add(message)
messageAdapter.notifyItemInserted(messages.size - 1)
binding.messageRecyclerView.scrollToPosition(messages.size - 1)
// 保存到設備歷史
device.messageHistory.add(message)
}
private fun updateConnectionStatus() {
if (isConnected) {
binding.connectButton.text = "斷開連接"
binding.connectionStatus.text = "已連接"
binding.connectionStatus.setTextColor(ContextCompat.getColor(this, R.color.connected))
} else {
binding.connectButton.text = "連接設備"
binding.connectionStatus.text = "未連接"
binding.connectionStatus.setTextColor(ContextCompat.getColor(this, R.color.disconnected))
}
}
private fun registerReceivers() {
val filter = IntentFilter().apply {
addAction(Constants.ACTION_MESSAGE_RECEIVED)
addAction(Constants.ACTION_CONNECTION_STATUS_CHANGED)
addAction(Constants.ACTION_DATA_WRITTEN)
}
registerReceiver(messageReceiver, filter)
}
override fun onDestroy() {
super.onDestroy()
unregisterReceiver(messageReceiver)
bluetoothService?.stop()
}
}
5. 藍牙服務
service/BluetoothService.kt
kotlin
package com.example.bluetoothchat.service
import android.app.Service
import android.bluetooth.BluetoothAdapter
import android.bluetooth.BluetoothDevice
import android.bluetooth.BluetoothSocket
import android.content.Context
import android.content.Intent
import android.os.Binder
import android.os.IBinder
import android.util.Log
import com.example.bluetoothchat.Constants
import com.example.bluetoothchat.model.BluetoothDeviceItem
import java.io.IOException
import java.io.InputStream
import java.io.OutputStream
import java.util.UUID
class BluetoothService : Service() {
private val binder = LocalBinder()
private val bluetoothAdapter: BluetoothAdapter? = BluetoothAdapter.getDefaultAdapter()
private val connectedThreads = mutableMapOf<String, ConnectedThread>()
private val connectedSockets = mutableMapOf<String, BluetoothSocket>()
inner class LocalBinder : Binder() {
fun getService(): BluetoothService = this@BluetoothService
}
override fun onBind(intent: Intent?): IBinder {
return binder
}
fun connect(deviceItem: BluetoothDeviceItem) {
val device = bluetoothAdapter?.getRemoteDevice(deviceItem.address)
device?.let {
ConnectThread(it, deviceItem.address).start()
}
}
fun disconnect(deviceAddress: String) {
connectedThreads[deviceAddress]?.cancel()
connectedThreads.remove(deviceAddress)
connectedSockets.remove(deviceAddress)
sendConnectionStatus(deviceAddress, false)
}
fun sendMessage(deviceAddress: String, message: String) {
connectedThreads[deviceAddress]?.write(message.toByteArray())
// 通知UI消息已發送
val intent = Intent(Constants.ACTION_DATA_WRITTEN).apply {
putExtra(Constants.EXTRA_MESSAGE, message)
putExtra("device_address", deviceAddress)
}
sendBroadcast(intent)
}
fun stop() {
connectedThreads.values.forEach { it.cancel() }
connectedThreads.clear()
connectedSockets.values.forEach { it.close() }
connectedSockets.clear()
}
private inner class ConnectThread(
private val device: BluetoothDevice,
private val deviceAddress: String
) : Thread() {
private var socket: BluetoothSocket? = null
override fun run() {
try {
socket = device.createRfcommSocketToServiceRecord(Constants.MY_UUID)
socket?.connect()
connectedSockets[deviceAddress] = socket!!
// 啓動連接線程
val connectedThread = ConnectedThread(socket!!, deviceAddress)
connectedThreads[deviceAddress] = connectedThread
connectedThread.start()
sendConnectionStatus(deviceAddress, true)
} catch (e: IOException) {
Log.e("BluetoothService", "連接失敗", e)
sendConnectionStatus(deviceAddress, false)
}
}
}
private inner class ConnectedThread(
private val socket: BluetoothSocket,
private val deviceAddress: String
) : Thread() {
private val inputStream: InputStream = socket.inputStream
private val outputStream: OutputStream = socket.outputStream
private val buffer = ByteArray(1024)
override fun run() {
var bytes: Int
while (true) {
try {
bytes = inputStream.read(buffer)
val message = String(buffer, 0, bytes)
// 發送廣播通知收到消息
val intent = Intent(Constants.ACTION_MESSAGE_RECEIVED).apply {
putExtra(Constants.EXTRA_MESSAGE, message)
putExtra("device_address", deviceAddress)
}
sendBroadcast(intent)
} catch (e: IOException) {
Log.e("BluetoothService", "連接斷開", e)
disconnect(deviceAddress)
break
}
}
}
fun write(bytes: ByteArray) {
try {
outputStream.write(bytes)
} catch (e: IOException) {
Log.e("BluetoothService", "發送失敗", e)
}
}
fun cancel() {
try {
socket.close()
} catch (e: IOException) {
Log.e("BluetoothService", "關閉socket失敗", e)
}
}
}
private fun sendConnectionStatus(deviceAddress: String, isConnected: Boolean) {
val intent = Intent(Constants.ACTION_CONNECTION_STATUS_CHANGED).apply {
putExtra(Constants.EXTRA_IS_CONNECTED, isConnected)
putExtra("device_address", deviceAddress)
}
sendBroadcast(intent)
}
}
6. 適配器類
adapter/DeviceAdapter.kt
kotlin
package com.example.bluetoothchat.adapter
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.TextView
import androidx.recyclerview.widget.RecyclerView
import com.example.bluetoothchat.R
import com.example.bluetoothchat.model.BluetoothDeviceItem
class DeviceAdapter(
private val devices: List<BluetoothDeviceItem>,
private val listener: OnDeviceClickListener
) : RecyclerView.Adapter<DeviceAdapter.DeviceViewHolder>() {
interface OnDeviceClickListener {
fun onDeviceClick(device: BluetoothDeviceItem)
}
inner class DeviceViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) {
val nameTextView: TextView = itemView.findViewById(R.id.device_name)
val addressTextView: TextView = itemView.findViewById(R.id.device_address)
val statusTextView: TextView = itemView.findViewById(R.id.device_status)
init {
itemView.setOnClickListener {
val position = adapterPosition
if (position != RecyclerView.NO_POSITION) {
listener.onDeviceClick(devices[position])
}
}
}
}
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): DeviceViewHolder {
val view = LayoutInflater.from(parent.context)
.inflate(R.layout.item_device, parent, false)
return DeviceViewHolder(view)
}
override fun onBindViewHolder(holder: DeviceViewHolder, position: Int) {
val device = devices[position]
holder.nameTextView.text = device.name
holder.addressTextView.text = device.address
if (device.isConnected) {
holder.statusTextView.text = "已連接"
holder.statusTextView.setTextColor(holder.itemView.context.getColor(R.color.connected))
} else {
holder.statusTextView.text = "未連接"
holder.statusTextView.setTextColor(holder.itemView.context.getColor(R.color.disconnected))
}
}
override fun getItemCount(): Int = devices.size
}
adapter/MessageAdapter.kt
kotlin
package com.example.bluetoothchat.adapter
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.TextView
import androidx.constraintlayout.widget.ConstraintLayout
import androidx.core.content.ContextCompat
import androidx.recyclerview.widget.RecyclerView
import com.example.bluetoothchat.Constants
import com.example.bluetoothchat.R
import com.example.bluetoothchat.model.Message
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
class MessageAdapter(private val messages: List<Message>) :
RecyclerView.Adapter<MessageAdapter.MessageViewHolder>() {
inner class MessageViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) {
val messageText: TextView = itemView.findViewById(R.id.message_text)
val messageTime: TextView = itemView.findViewById(R.id.message_time)
val messageLayout: ConstraintLayout = itemView.findViewById(R.id.message_layout)
}
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): MessageViewHolder {
val view = LayoutInflater.from(parent.context)
.inflate(R.layout.item_message, parent, false)
return MessageViewHolder(view)
}
override fun onBindViewHolder(holder: MessageViewHolder, position: Int) {
val message = messages[position]
holder.messageText.text = message.content
val timeFormat = SimpleDateFormat("HH:mm", Locale.getDefault())
holder.messageTime.text = timeFormat.format(Date(message.timestamp))
when (message.type) {
Constants.MESSAGE_TYPE_SENT -> {
holder.messageLayout.setBackgroundResource(R.drawable.bubble_sent)
holder.messageText.setTextColor(ContextCompat.getColor(holder.itemView.context, R.color.sent_text))
holder.messageTime.setTextColor(ContextCompat.getColor(holder.itemView.context, R.color.sent_time))
val params = holder.messageLayout.layoutParams as ConstraintLayout.LayoutParams
params.horizontalBias = 1.0f
holder.messageLayout.layoutParams = params
}
Constants.MESSAGE_TYPE_RECEIVED -> {
holder.messageLayout.setBackgroundResource(R.drawable.bubble_received)
holder.messageText.setTextColor(ContextCompat.getColor(holder.itemView.context, R.color.received_text))
holder.messageTime.setTextColor(ContextCompat.getColor(holder.itemView.context, R.color.received_time))
val params = holder.messageLayout.layoutParams as ConstraintLayout.LayoutParams
params.horizontalBias = 0.0f
holder.messageLayout.layoutParams = params
}
Constants.MESSAGE_TYPE_SYSTEM -> {
holder.messageLayout.setBackgroundResource(R.drawable.bubble_system)
holder.messageText.setTextColor(ContextCompat.getColor(holder.itemView.context, R.color.system_text))
holder.messageTime.setTextColor(ContextCompat.getColor(holder.itemView.context, R.color.system_time))
val params = holder.messageLayout.layoutParams as ConstraintLayout.LayoutParams
params.horizontalBias = 0.5f
holder.messageLayout.layoutParams = params
}
}
}
override fun getItemCount(): Int = messages.size
}
四、佈局文件
1. 主頁面佈局 (activity_main.xml)
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="match_parent"
android:orientation="vertical">
<androidx.appcompat.widget.Toolbar
android:id="@+id/toolbar"
android:layout_width="match_parent"
android:layout_height="?attr/actionBarSize"
android:background="?attr/colorPrimary"
android:theme="?attr/actionBarTheme"
app:title="藍牙設備列表" />
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:padding="16dp">
<Button
android:id="@+id/scan_button"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:text="開始掃描" />
<ProgressBar
android:id="@+id/progress_bar"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="16dp"
android:visibility="gone" />
</LinearLayout>
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/device_recycler_view"
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_weight="1" />
</LinearLayout>
2. 聊天頁面佈局 (activity_chat.xml)
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="match_parent"
android:orientation="vertical">
<androidx.appcompat.widget.Toolbar
android:id="@+id/toolbar"
android:layout_width="match_parent"
android:layout_height="?attr/actionBarSize"
android:background="?attr/colorPrimary"
android:theme="?attr/actionBarTheme" />
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:padding="8dp"
android:background="@color/light_gray">
<TextView
android:id="@+id/connection_status"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:text="未連接"
android:textSize="14sp"
android:padding="8dp" />
<Button
android:id="@+id/connect_button"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="連接設備" />
</LinearLayout>
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/message_recycler_view"
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_weight="1"
android:padding="8dp" />
<HorizontalScrollView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:background="@color/light_gray">
<LinearLayout
android:id="@+id/quick_phrases_layout"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:padding="8dp"
android:orientation="horizontal" />
</HorizontalScrollView>
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:padding="8dp">
<EditText
android:id="@+id/message_edit_text"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:hint="輸入消息..."
android:maxLines="3"
android:inputType="textMultiLine" />
<Button
android:id="@+id/send_button"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="發送"
android:layout_marginStart="8dp" />
</LinearLayout>
</LinearLayout>
3. 設備列表項佈局 (item_device.xml)
xml
<?xml version="1.0" encoding="utf-8"?>
<androidx.cardview.widget.CardView 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_margin="8dp"
app:cardCornerRadius="8dp"
app:cardElevation="2dp">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:padding="16dp">
<TextView
android:id="@+id/device_name"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:textSize="16sp"
android:textStyle="bold" />
<TextView
android:id="@+id/device_address"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:textSize="14sp"
android:textColor="@color/gray" />
<TextView
android:id="@+id/device_status"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:textSize="12sp"
android:layout_marginTop="4dp" />
</LinearLayout>
</androidx.cardview.widget.CardView>
4. 消息列表項佈局 (item_message.xml)
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_marginVertical="4dp"
android:paddingHorizontal="8dp">
<androidx.constraintlayout.widget.ConstraintLayout
android:id="@+id/message_layout"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:maxWidth="70%"
android:padding="12dp"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintEnd_toEndOf="parent">
<TextView
android:id="@+id/message_text"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textSize="16sp"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintEnd_toEndOf="parent" />
<TextView
android:id="@+id/message_time"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textSize="10sp"
android:layout_marginTop="4dp"
app:layout_constraintTop_toBottomOf="@id/message_text"
app:layout_constraintEnd_toEndOf="parent" />
</androidx.constraintlayout.widget.ConstraintLayout>
</androidx.constraintlayout.widget.ConstraintLayout>
五、AndroidManifest.xml配置
xml
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">
<!-- 藍牙權限 -->
<uses-permission android:name="android.permission.BLUETOOTH" />
<uses-permission android:name="android.permission.BLUETOOTH_ADMIN" />
<!-- Android 12+ 需要的藍牙權限 -->
<uses-permission android:name="android.permission.BLUETOOTH_SCAN" />
<uses-permission android:name="android.permission.BLUETOOTH_CONNECT" />
<!-- 定位權限(用於掃描藍牙設備) -->
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
<!-- 前台服務權限 -->
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<!-- 如果targetSdk >= 31,需要聲明藍牙權限 -->
<uses-permission android:name="android.permission.BLUETOOTH_ADVERTISE" />
<uses-feature
android:name="android.hardware.bluetooth"
android:required="true" />
<application
android:allowBackup="true"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:theme="@style/Theme.BluetoothChat">
<activity
android:name=".MainActivity"
android:exported="true">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
<activity
android:name=".ChatActivity"
android:exported="false" />
<service
android:name=".service.BluetoothService"
android:enabled="true"
android:exported="false"
android:foregroundServiceType="connectedDevice" />
</application>
</manifest>
六、顏色和樣式資源
res/values/colors.xml
xml
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="purple_200">#FFBB86FC</color>
<color name="purple_500">#FF6200EE</color>
<color name="purple_700">#FF3700B3</color>
<color name="teal_200">#FF03DAC5</color>
<color name="teal_700">#FF018786</color>
<color name="black">#FF000000</color>
<color name="white">#FFFFFFFF</color>
<color name="connected">#4CAF50</color>
<color name="disconnected">#F44336</color>
<color name="light_gray">#F5F5F5</color>
<color name="gray">#9E9E9E</color>
<color name="sent_bubble">#2196F3</color>
<color name="received_bubble">#E0E0E0</color>
<color name="system_bubble">#FFC107</color>
<color name="sent_text">#FFFFFF</color>
<color name="received_text">#000000</color>
<color name="system_text">#000000</color>
<color name="sent_time">#BBDEFB</color>
<color name="received_time">#757575</color>
<color name="system_time">#FFECB3</color>
<color name="quick_phrase_bg">#FFFFFF</color>
<color name="quick_phrase_text">#2196F3</color>
</resources>
res/drawable/bubble_sent.xml
xml
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android">
<solid android:color="@color/sent_bubble" />
<corners
android:topLeftRadius="16dp"
android:topRightRadius="16dp"
android:bottomLeftRadius="16dp"
android:bottomRightRadius="4dp" />
</shape>
res/drawable/bubble_received.xml
xml
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android">
<solid android:color="@color/received_bubble" />
<corners
android:topLeftRadius="16dp"
android:topRightRadius="16dp"
android:bottomLeftRadius="4dp"
android:bottomRightRadius="16dp" />
</shape>