一、項目結構

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>