Bài viết hướng dẫn xây dựng mô hình **Client-Server** trên Android sử dụng **Socket TCP** (java.net.Socket / ServerSocket). Hai ứng dụng riêng biệt: Server lắng nghe, Client kết nối. Phù hợp chat LAN, điều khiển thiết bị, sync dữ liệu offline.
Yêu cầu chung
- 2 thiết bị Android (hoặc 1 thật + 1 emulator) cùng mạng WiFi LAN.
- Android 6.0+ (API 23+).
- Quyền:
INTERNET,ACCESS_NETWORK_STATE, Server thêmACCESS_WIFI_STATE.
📦 SERVER APP
build.gradle.kts (Module: server)
plugins { id("com.android.application") id("org.jetbrains.kotlin.android") }
android {
namespace = "com.example.socketserver"
compileSdk = 34
defaultConfig {
applicationId = "com.example.socketserver"
minSdk = 23
targetSdk = 34
versionCode = 1
versionName = "1.0"
}
}
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")
}
Manifest Server
<manifest xmlns:android="https://schemas.android.com/apk/res/android"
package="com.example.socketserver">
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.ACCESS_WIFI_STATE" />
<application android:allowBackup="true" android:label="@string/app_name" android:theme="@style/Theme.SocketServer">
<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>
</application>
</manifest>
Layout Server (res/layout/activity_main.xml)
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout
xmlns:android="https://schemas.android.com/apk/res/android"
xmlns:app="https://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:padding="16dp">
<TextView android:id="@+id/tvServerInfo" android:layout_width="0dp" android:layout_height="wrap_content"
android:text="Server IP: ...\nPort: 8080" android:textSize="16sp"
app:layout_constraintTop_toTopOf="parent" app:layout_constraintStart_toStartOf="parent"
app:layout_constraintEnd_toEndOf="parent" />
<TextView android:id="@+id/tvStatus" android:layout_width="0dp" android:layout_height="wrap_content"
android:text="Trạng thái: Chờ kết nối..." android:textSize="16sp" android:textStyle="bold"
app:layout_constraintTop_toBottomOf="@id/tvServerInfo" android:layout_marginTop="16dp"
app:layout_constraintStart_toStartOf="parent" app:layout_constraintEnd_toEndOf="parent" />
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/rvMessages"
android:layout_width="0dp" android:layout_height="0dp"
app:layout_constraintTop_toBottomOf="@id/tvStatus"
app:layout_constraintBottom_toTopOf="@id/inputLayout"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintEnd_toEndOf="parent"
android:layout_marginTop="16dp" />
<LinearLayout android:id="@+id/inputLayout" android:layout_width="0dp" android:layout_height="wrap_content"
android:orientation="horizontal" android:layout_marginTop="8dp"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintStart_toStartOf="parent" app:layout_constraintEnd_toEndOf="parent">
<EditText android:id="@+id/etMessage" android:layout_width="0dp" android:layout_height="wrap_content"
android:layout_weight="1" android:hint="Tin nhắn..." android:inputType="text" />
<Button android:id="@+id/btnSend" android:layout_width="wrap_content" android:layout_height="wrap_content"
android:layout_marginStart="8dp" android:text="Gửi" />
</LinearLayout>
</androidx.constraintlayout.widget.ConstraintLayout>
Message Model & Adapter (Kotlin)
data class ChatMessage(
val text: String,
val isSent: Boolean, // true = Server gửi, false = Client gửi
val timestamp: Long = System.currentTimeMillis()
)
class MessageAdapter : ListAdapter(DiffCallback()) {
inner class VH(view: View) : RecyclerView.ViewHolder(view) {
val tvText: TextView = view.findViewById(R.id.tvMsgText)
val tvTime: TextView = view.findViewById(R.id.tvMsgTime)
val container: LinearLayout = view.findViewById(R.id.msgContainer)
}
override fun onCreateViewHolder(p: ViewGroup, vt: Int): VH {
val view = LayoutInflater.from(p.context).inflate(R.layout.item_message, p, false)
return VH(view)
}
override fun onBindViewHolder(h: VH, pos: Int) {
val msg = getItem(pos)
h.tvText.text = msg.text
h.tvTime.text = android.text.format.DateFormat.format("HH:mm", msg.timestamp)
// Căn trái/phải, màu nền khác nhau
val params = h.container.layoutParams as LinearLayout.LayoutParams
params.gravity = if (msg.isSent) Gravity.END else Gravity.START
h.container.layoutParams = params
h.container.setBackgroundResource(if (msg.isSent) R.drawable.bg_msg_sent else R.drawable.bg_msg_received)
}
class DiffCallback : DiffUtil.ItemCallback() {
override fun areItemsTheSame(o: ChatMessage, n: ChatMessage) = o.timestamp == n.timestamp
override fun areContentsTheSame(o: ChatMessage, n: ChatMessage) = o == n
}
}
Message Item Layout (res/layout/item_message.xml)
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="https://schemas.android.com/apk/res/android"
android:id="@+id/msgContainer"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:orientation="vertical"
android:padding="12dp"
android:maxWidth="280dp">
<TextView android:id="@+id/tvMsgText" android:layout_width="wrap_content" android:layout_height="wrap_content"
android:textSize="15sp" android:textColor="@android:color/black" />
<TextView android:id="@+id/tvMsgTime" android:layout_width="wrap_content" android:layout_height="wrap_content"
android:textSize="10sp" android:textColor="#999" android:layout_marginTop="2dp" android:layout_gravity="end" />
</LinearLayout>
Drawables Bubble (res/drawable/bg_msg_sent.xml)
<shape xmlns:android="https://schemas.android.com/apk/res/android" android:shape="rectangle">
<solid android:color="#2196F3" />
<corners android:radius="16dp" android:bottomRightRadius="2dp" />
</shape>
<!-- bg_msg_received.xml -->
<shape xmlns:android="https://schemas.android.com/apk/res/android" android:shape="rectangle">
<solid android:color="#E0E0E0" />
<corners android:radius="16dp" android:bottomLeftRadius="2dp" />
</shape>
MainActivity Server (Kotlin + Coroutines)
class MainActivity : AppCompatActivity() {
private lateinit var binding: ActivityMainBinding
private val adapter = MessageAdapter()
private var serverSocket: ServerSocket? = null
private var clientSocket: Socket? = null
private var out: PrintWriter? = null
private val scope = CoroutineScope(Dispatchers.IO)
private val uiScope = CoroutineScope(Dispatchers.Main)
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
binding = ActivityMainBinding.inflate(layoutInflater)
setContentView(binding.root)
binding.rvMessages.apply {
layoutManager = LinearLayoutManager(this@MainActivity)
adapter = this@MainActivity.adapter
}
val localIp = getLocalIpAddress() ?: "Không xác định"
binding.tvServerInfo.text = "Server IP: $localIp\nPort: 8080"
binding.btnSend.setOnClickListener {
val text = binding.etMessage.text.toString().trim()
if (text.isNotEmpty()) sendMessage(text)
}
startServer()
}
private fun getLocalIpAddress(): String? {
try {
val wifiManager = getSystemService(WIFI_SERVICE) as WifiManager
val ipInt = wifiManager.connectionInfo.ipAddress
if (ipInt == 0) return null
return InetAddress.getByAddress(ipInt.toByteArray(ByteOrder.LITTLE_ENDIAN)).hostAddress
} catch (e: Exception) { return null }
}
private fun startServer() {
scope.launch {
try {
serverSocket = ServerSocket(8080)
uiScope.launch { binding.tvStatus.text = "Đang lắng nghe port 8080..." }
while (!Thread.currentThread().isInterrupted) {
clientSocket = serverSocket!!.accept()
uiScope.launch { binding.tvStatus.text = "Client kết nối: ${clientSocket!!.inetAddress}" }
out = PrintWriter(clientSocket!!.getOutputStream(), true)
val in = BufferedReader(InputStreamReader(clientSocket!!.getInputStream()))
// Loop đọc tin nhắn từ client
while (true) {
val line = in.readLine() ?: break
uiScope.launch {
adapter.submitList(adapter.currentList + ChatMessage(line, false))
binding.rvMessages.scrollToPosition(adapter.itemCount - 1)
}
}
// Client ngắt kết nối
uiScope.launch { binding.tvStatus.text = "Client ngắt kết nối. Đang chờ mới..." }
closeClientResources()
}
} catch (e: IOException) { e.printStackTrace() }
}
}
private fun sendMessage(text: String) {
out?.println(text)
uiScope.launch {
adapter.submitList(adapter.currentList + ChatMessage(text, true))
binding.rvMessages.scrollToPosition(adapter.itemCount - 1)
binding.etMessage.setText("")
}
}
private fun closeClientResources() {
try { out?.close(); clientSocket?.close() } catch (e: IOException) {}
out = null; clientSocket = null
}
override fun onDestroy() {
super.onDestroy()
scope.cancel()
try { serverSocket?.close() } catch (e: IOException) {}
}
}
📱 CLIENT APP
build.gradle.kts (Module: client) - Tương tự, đổi applicationId
namespace = "com.example.socketclient"
defaultConfig { applicationId = "com.example.socketclient" ... }
Manifest Client
<manifest ... package="com.example.socketclient">
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<application ...>
<activity android:name=".MainActivity" android:exported="true">...</activity>
</application>
</manifest>
Layout Client (res/layout/activity_main.xml)
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout ... android:padding="16dp">
<EditText android:id="@+id/etServerIp" android:layout_width="0dp" android:layout_height="wrap_content"
android:hint="IP Server (vd 192.168.1.15)" android:inputType="text"
app:layout_constraintTop_toTopOf="parent" app:layout_constraintStart_toStartOf="parent"
app:layout_constraintEnd_toEndOf="parent" />
<EditText android:id="@+id/etServerPort" android:layout_width="0dp" android:layout_height="wrap_content"
android:hint="Port (8080)" android:inputType="number"
app:layout_constraintTop_toBottomOf="@id/etServerIp" app:layout_constraintStart_toStartOf="parent"
app:layout_constraintEnd_toEndOf="parent" android:layout_marginTop="8dp" />
<Button android:id="@+id/btnConnect" android:layout_width="0dp" android:layout_height="wrap_content"
android:text="KẾT NỐI"
app:layout_constraintTop_toBottomOf="@id/etServerPort" app:layout_constraintStart_toStartOf="parent"
app:layout_constraintEnd_toEndOf="parent" android:layout_marginTop="16dp" />
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/rvMessages" android:layout_width="0dp" android:layout_height="0dp"
app:layout_constraintTop_toBottomOf="@id/btnConnect"
app:layout_constraintBottom_toTopOf="@id/inputLayout"
app:layout_constraintStart_toStartOf="parent" app:layout_constraintEnd_toEndOf="parent"
android:layout_marginTop="16dp" />
<LinearLayout android:id="@+id/inputLayout" android:layout_width="0dp" android:layout_height="wrap_content"
android:orientation="horizontal" android:layout_marginTop="8dp" android:visibility="gone"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintStart_toStartOf="parent" app:layout_constraintEnd_toEndOf="parent">
<EditText android:id="@+id/etMessage" android:layout_width="0dp" android:layout_height="wrap_content"
android:layout_weight="1" android:hint="Tin nhắn..." />
<Button android:id="@+id/btnSend" android:layout_width="wrap_content" android:layout_height="wrap_content"
android:layout_marginStart="8dp" android:text="Gửi" />
</LinearLayout>
</androidx.constraintlayout.widget.ConstraintLayout>
MainActivity Client (Kotlin Coroutines)
class MainActivity : AppCompatActivity() {
private lateinit var binding: ActivityMainBinding
private val adapter = MessageAdapter() // Cùng class MessageAdapter
private var socket: Socket? = null
private var out: PrintWriter? = null
private val scope = CoroutineScope(Dispatchers.IO)
private val uiScope = CoroutineScope(Dispatchers.Main)
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
binding = ActivityMainBinding.inflate(layoutInflater)
setContentView(binding.root)
binding.rvMessages.apply {
layoutManager = LinearLayoutManager(this@MainActivity)
adapter = this@MainActivity.adapter
}
binding.btnConnect.setOnClickListener { connect() }
binding.btnSend.setOnClickListener { sendMessage() }
}
private fun connect() {
val ip = binding.etServerIp.text.toString().trim()
val portStr = binding.etServerPort.text.toString().trim()
if (ip.isEmpty() || portStr.isEmpty()) return
val port = portStr.toIntOrNull() ?: return
scope.launch {
try {
socket = Socket(ip, port)
out = PrintWriter(socket!!.getOutputStream(), true)
val in = BufferedReader(InputStreamReader(socket!!.getInputStream()))
uiScope.launch {
binding.tvMessages.text = "Đã kết nối $ip:$port"
binding.btnConnect.isEnabled = false
binding.etServerIp.isEnabled = false
binding.etServerPort.isEnabled = false
binding.inputLayout.visibility = View.VISIBLE
binding.btnSend.isEnabled = true
}
// Loop nhận từ server
while (true) {
val line = in.readLine() ?: break
uiScope.launch {
adapter.submitList(adapter.currentList + ChatMessage(line, false))
binding.rvMessages.scrollToPosition(adapter.itemCount - 1)
}
}
} catch (e: IOException) {
uiScope.launch { binding.tvMessages.text = "Lỗi kết nối: ${e.message}" }
} finally {
onDisconnected()
}
}
}
private fun sendMessage() {
val text = binding.etMessage.text.toString().trim()
if (text.isNotEmpty()) {
out?.println(text)
uiScope.launch {
adapter.submitList(adapter.currentList + ChatMessage(text, true))
binding.rvMessages.scrollToPosition(adapter.itemCount - 1)
binding.etMessage.setText("")
}
}
}
private fun onDisconnected() {
uiScope.launch {
binding.btnConnect.isEnabled = true
binding.etServerIp.isEnabled = true
binding.etServerPort.isEnabled = true
binding.inputLayout.visibility = View.GONE
binding.btnSend.isEnabled = false
adapter.submitList(adapter.currentList + ChatMessage("-- Mất kết nối --", false))
}
try { socket?.close() } catch (e: IOException) {}
socket = null; out = null
}
override fun onDestroy() {
super.onDestroy()
scope.cancel()
try { socket?.close() } catch (e: IOException) {}
}
}
🚀 Chạy và Test
- Build & cài **Server App** lên thiết bị A. Mở app, ghi nhớ IP hiển thị (ví dụ:
192.168.1.42). - Build & cài **Client App** lên thiết bị B. Nhập IP của A, Port
8080, nhấn **KẾT NỐI**. - Hai bên chat realtime. Thử tắt WiFi một bên để test reconnect logic.
⚠️ Các lỗi thường gặp & Fix
| Lỗi | Nguyên nhân | Khắc phục |
|---|---|---|
| Connection refused | Server chưa start, sai IP/Port, Firewall | Kiểm tra Server chạy, IP đúng, cùng subnet, tắt tường lửa test |
| NetworkOnMainThreadException | Làm network trên Main Thread | Dùng Coroutine Dispatchers.IO / Thread / RxJava |
| SocketTimeout / Read timeout | Mạng không ổn định | Set SO_TIMEOUT, implement heartbeat |
| Emulator không kết nối được thiết bị thật | Emulator dùng NAT riêng (10.0.2.x) | Dùng 10.0.2.2 để truy cập localhost máy host, hoặc cấu hình port forwarding |
| IPv6 vs IPv4 | ServerSocket mặc định IPv6 dual-stack | Client connect bằng IPv4 address, hoặc dùng InetAddress.getByName |
🔧 Nâng cao cho Production
- Protocol: Thay plain text bằng JSON/Protobuf (kotlinx.serialization).
- Security: SSLSocketFactory + Certificate pinning.
- Discovery: NSD (Network Service Discovery) hoặc mDNS/Bonjour để tự tìm Server IP.
- Reconnection: Exponential backoff retry logic.
- Architecture: Repository + ViewModel + Flow, tách Network Module.
Kết luận
Socket TCP trên Android cho phép giao tiếp realtime, hai chiều, độ trễ thấp trên mạng cục bộ. Triển khai đúng đắn cần: Coroutine/Thread cho I/O, quản lý vòng đời Socket, xử lý reconnection, và protocol rõ ràng. Code Kotlin + Coroutines + RecyclerView ở trên là nền tảng sẵn sàng mở rộng.