Wi-Fi Direct (Wi-Fi P2P) cho phép truyền file tốc độ cao (lên tới 250 Mbps) giữa hai thiết bị Android mà không cần router, internet, hay Bluetooth. Bài viết triển khai mô hình **Server (Group Owner)** và **Client** truyền file qua Socket sau khi thiết lập kết nối P2P.
Quyền & Feature Manifest (Cả 2 app)
<uses-permission android:name="android.permission.ACCESS_WIFI_STATE" />
<uses-permission android:name="android.permission.CHANGE_WIFI_STATE" />
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
<uses-permission android:name="android.permission.NEARBY_WIFI_DEVICES"
android:usesPermissionFlags="neverForLocation" />
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" />
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE"
android:maxSdkVersion="28" />
<uses-feature android:name="android.hardware.wifi.direct" android:required="true" />
KIẾN TRÚC TỔNG QUAN
- Khám phá peer (
discoverPeers) - Kết nối P2P (
connectvới WPS PBC) - Nhận
WifiP2pInfo: xác định Group Owner (Server) và Client, lấy IP Group Owner. - Group Owner: Mở
ServerSocketport cố định (vd 8888). - Client: Kết nối
Socketđến IP Group Owner:8888. - Giao thức truyền file đơn giản:
- Client gửi:
FILENAME|FILESIZE\n - Server ACK:
READY\n - Client stream file bytes.
- Server ghi file, trả
DONE\n.
- Client gửi:
PHẦN CHUNG: P2P Manager & Receiver
Tạo module shared hoặc copy code vào cả 2 app.
P2PHelper.kt (Singleton wrapper)
class P2PHelper(private val context: Context) {
private val manager = context.getSystemService(Context.WIFI_P2P_SERVICE) as WifiP2pManager
private val channel = manager.initialize(context, context.mainLooper, null)
private val receiver: WiFiDirectBroadcastReceiver
private val intentFilter = IntentFilter().apply {
addAction(WifiP2pManager.WIFI_P2P_STATE_CHANGED_ACTION)
addAction(WifiP2pManager.WIFI_P2P_PEERS_CHANGED_ACTION)
addAction(WifiP2pManager.WIFI_P2P_CONNECTION_CHANGED_ACTION)
addAction(WifiP2pManager.WIFI_P2P_THIS_DEVICE_CHANGED_ACTION)
}
interface Callback {
fun onStateChanged(enabled: Boolean)
fun onPeersUpdated(peers: List)
fun onConnectionChanged(info: WifiP2pInfo?)
fun onThisDeviceChanged(device: WifiP2pDevice)
}
private var callback: Callback? = null
fun setCallback(cb: Callback) { callback = cb }
inner class WiFiDirectBroadcastReceiver : BroadcastReceiver() {
override fun onReceive(ctx: Context, intent: Intent) {
when (intent.action) {
WifiP2pManager.WIFI_P2P_STATE_CHANGED_ACTION -> {
val state = intent.getIntExtra(WifiP2pManager.EXTRA_WIFI_STATE, -1)
callback?.onStateChanged(state == WifiP2pManager.WIFI_P2P_STATE_ENABLED)
}
WifiP2pManager.WIFI_P2P_PEERS_CHANGED_ACTION -> {
manager.requestPeers(channel) { list -> callback?.onPeersUpdated(list.deviceList) }
}
WifiP2pManager.WIFI_P2P_CONNECTION_CHANGED_ACTION -> {
val info = intent.getParcelableExtra(WifiP2pManager.EXTRA_NETWORK_INFO)
if (info != null) callback?.onConnectionChanged(info)
}
WifiP2pManager.WIFI_P2P_THIS_DEVICE_CHANGED_ACTION -> {
val device = intent.getParcelableExtra(WifiP2pManager.EXTRA_WIFI_P2P_DEVICE)
device?.let { callback?.onThisDeviceChanged(it) }
}
}
}
}
init { receiver = WiFiDirectBroadcastReceiver() }
fun register() { context.registerReceiver(receiver, intentFilter) }
fun unregister() { context.unregisterReceiver(receiver) }
fun discoverPeers() = manager.discoverPeers(channel, object : WifiP2pManager.ActionListener {
override fun onSuccess() {}
override fun onFailure(reason: Int) { callback?.onPeersUpdated(emptyList()) }
})
fun connect(device: WifiP2pDevice) {
val config = WifiP2pConfig().apply {
deviceAddress = device.deviceAddress
wps.setup = WpsInfo.PBC
}
manager.connect(channel, config, object : WifiP2pManager.ActionListener {
override fun onSuccess() {}
override fun onFailure(reason: Int) { callback?.onConnectionChanged(null) }
})
}
fun requestConnectionInfo() = manager.requestConnectionInfo(channel) { info ->
callback?.onConnectionChanged(info)
}
}
SERVER APP (Group Owner)
Layout Server (res/layout/activity_server.xml)
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="https://schemas.android.com/apk/res/android"
android:layout_width="match_parent" android:layout_height="match_parent"
android:orientation="vertical" android:padding="16dp">
<TextView android:id="@+id/tvStatus" android:layout_width="match_parent" android:layout_height="wrap_content"
android:text="Trạng thái: Chờ kết nối..." android:textSize="16sp" android:textStyle="bold"
android:layout_marginBottom="16dp" />
<TextView android:id="@+id/tvFileInfo" android:layout_width="match_parent" android:layout_height="wrap_content"
android:text="Chưa nhận file" android:textSize="14sp" android:layout_marginBottom="16dp" />
<ProgressBar android:id="@+id/pbTransfer" style="@style/Widget.AppCompat.ProgressBar.Horizontal"
android:layout_width="match_parent" android:layout_height="wrap_content"
android:max="100" android:visibility="gone" />
<Button android:id="@+id/btnDisconnect" android:layout_width="wrap_content" android:layout_height="wrap_content"
android:layout_gravity="center" android:text="Ngắt kết nối" android:enabled="false" />
</LinearLayout>
ServerActivity.kt
class ServerActivity : AppCompatActivity(), P2PHelper.Callback {
private lateinit var binding: ActivityServerBinding
private lateinit var p2p: P2PHelper
private var serverSocket: ServerSocket? = null
private var transferThread: Thread? = null
private val PORT = 8888
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
binding = ActivityServerBinding.inflate(layoutInflater)
setContentView(binding.root)
p2p = P2PHelper(this).apply { setCallback(this) }
binding.btnDisconnect.setOnClickListener { disconnect() }
}
override fun onResume() { super.onResume(); p2p.register() }
override fun onPause() { super.onPause(); p2p.unregister() }
override fun onStateChanged(enabled: Boolean) {
runOnUiThread { binding.tvStatus.text = "WiFi Direct: ${if (enabled) "BẬT" else "TẮT"}" }
}
override fun onPeersUpdated(peers: List) {
// Server thường không cần danh sách peer, nhưng có thể dùng để debug
}
override fun onConnectionChanged(info: WifiP2pInfo?) {
runOnUiThread {
if (info?.groupFormed == true && info.isGroupOwner) {
binding.tvStatus.text = "Đã là Group Owner (Server)\nIP: ${info.groupOwnerAddress.hostAddress}"
binding.btnDisconnect.isEnabled = true
startServerSocket()
} else if (info?.groupFormed == true && !info.isGroupOwner) {
binding.tvStatus.text = "Đã kết nối làm Client - Chuyển sang Client Mode"
// Có thể chuyển Activity hoặc đóng ServerSocket nếu đang mở
} else {
binding.tvStatus.text = "Chưa kết nối / Mất kết nối"
binding.btnDisconnect.isEnabled = false
binding.pbTransfer.visibility = View.GONE
}
}
}
override fun onThisDeviceChanged(device: WifiP2pDevice) {}
private fun startServerSocket() {
transferThread = Thread {
try {
serverSocket = ServerSocket(PORT)
while (!Thread.currentThread().isInterrupted) {
val client = serverSocket!!.accept()
handleClient(client)
}
} catch (e: IOException) { e.printStackTrace() }
}.apply { start() }
}
private fun handleClient(socket: Socket) {
try {
val `in` = BufferedInputStream(socket.getInputStream())
val out = BufferedOutputStream(socket.getOutputStream())
val reader = BufferedReader(InputStreamReader(`in`))
// 1. Đọc header: FILENAME|FILESIZE
val header = reader.readLine() ?: return
val parts = header.split("|")
if (parts.size != 2) return
val fileName = parts[0]
val fileSize = parts[1].toLong()
runOnUiThread {
binding.tvFileInfo.text = "Đang nhận: $fileName (${formatSize(fileSize)})"
binding.pbTransfer.visibility = View.VISIBLE
binding.pbTransfer.progress = 0
}
// 2. Gửi ACK
out.write("READY\n".toByteArray()); out.flush()
// 3. Nhận file
val dir = getExternalFilesDir(Environment.DIRECTORY_DOWNLOADS)!!
val file = File(dir, fileName)
val fos = FileOutputStream(file)
val buffer = ByteArray(8192)
var totalRead = 0L
var read: Int
while (totalRead < fileSize && (read = `in`.read(buffer)) != -1) {
fos.write(buffer, 0, read)
totalRead += read
val progress = (totalRead * 100 / fileSize).toInt()
runOnUiThread { binding.pbTransfer.progress = progress }
}
fos.close()
// 4. Gửi DONE
out.write("DONE\n".toByteArray()); out.flush()
runOnUiThread {
binding.tvFileInfo.text = "Đã lưu: ${file.absolutePath}"
binding.pbTransfer.visibility = View.GONE
// Quét MediaStore để hiện trong Gallery
MediaScannerConnection.scanFile(this, arrayOf(file.absolutePath), null, null)
}
} catch (e: IOException) {
e.printStackTrace()
runOnUiThread { binding.tvFileInfo.text = "Lỗi: ${e.message}" }
} finally {
try { socket.close() } catch (e: IOException) {}
}
}
private fun disconnect() {
transferThread?.interrupt()
try { serverSocket?.close() } catch (e: IOException) {}
p2p.manager.removeGroup(p2p.channel, object : WifiP2pManager.ActionListener {
override fun onSuccess() {}
override fun onFailure(reason: Int) {}
})
}
private fun formatSize(bytes: Long): String {
return when {
bytes < 1024 -> "$bytes B"
bytes < 1024*1024 -> String.format("%.1f KB", bytes/1024.0)
bytes < 1024*1024*1024 -> String.format("%.1f MB", bytes/1024.0/1024.0)
else -> String.format("%.1f GB", bytes/1024.0/1024.0/1024.0)
}
}
}
CLIENT APP
Layout Client (res/layout/activity_client.xml)
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="https://schemas.android.com/apk/res/android"
android:layout_width="match_parent" android:layout_height="match_parent"
android:orientation="vertical" android:padding="16dp">
<TextView android:id="@+id/tvStatus" android:layout_width="match_parent" android:layout_height="wrap_content"
android:text="Trạng thái: Chưa kết nối" android:textSize="16sp" android:textStyle="bold"
android:layout_marginBottom="16dp" />
<Button android:id="@+id/btnDiscover" android:layout_width="match_parent" android:layout_height="wrap_content"
android:text="Tìm thiết bị" android:layout_marginBottom="8dp" />
<ListView android:id="@+id/lvPeers" android:layout_width="match_parent" android:layout_height="0dp"
android:layout_weight="1" android:layout_marginBottom="16dp" />
<Button android:id="@+id/btnPickFile" android:layout_width="match_parent" android:layout_height="wrap_content"
android:text="Chọn file để gửi" android:enabled="false" android:layout_marginBottom="8dp" />
<Button android:id="@+id/btnSendFile" android:layout_width="match_parent" android:layout_height="wrap_content"
android:text="GỬI FILE" android:enabled="false" />
<ProgressBar android:id="@+id/pbTransfer" style="@style/Widget.AppCompat.ProgressBar.Horizontal"
android:layout_width="match_parent" android:layout_height="wrap_content"
android:max="100" android:visibility="gone" android:layout_marginTop="8dp" />
</LinearLayout>
ClientActivity.kt
class ClientActivity : AppCompatActivity(), P2PHelper.Callback {
private lateinit var binding: ActivityClientBinding
private lateinit var p2p: P2PHelper
private var peers = mutableListOf()
private var selectedPeer: WifiP2pDevice? = null
private var selectedFile: Uri? = null
private val PICK_FILE_REQUEST = 1001
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
binding = ActivityClientBinding.inflate(layoutInflater)
setContentView(binding.root)
p2p = P2PHelper(this).apply { setCallback(this) }
binding.btnDiscover.setOnClickListener { p2p.discoverPeers() }
binding.btnPickFile.setOnClickListener { pickFile() }
binding.btnSendFile.setOnClickListener { sendFile() }
binding.lvPeers.setOnItemClickListener { _, _, pos, _ ->
selectedPeer = peers[pos]
binding.btnSendFile.isEnabled = true
Toast.makeText(this, "Đã chọn: ${selectedPeer?.deviceName}", Toast.LENGTH_SHORT).show()
}
}
override fun onResume() { super.onResume(); p2p.register() }
override fun onPause() { super.onPause(); p2p.unregister() }
override fun onStateChanged(enabled: Boolean) {
runOnUiThread { binding.tvStatus.text = "WiFi Direct: ${if (enabled) "BẬT" else "TẮT"}" }
}
override fun onPeersUpdated(list: List) {
runOnUiThread {
peers = list.toMutableList()
val adapter = ArrayAdapter(this, android.R.layout.simple_list_item_1,
peers.map { "${it.deviceName} ${if (it.isGroupOwner) "(GO)" else ""}" })
binding.lvPeers.adapter = adapter
}
}
override fun onConnectionChanged(info: WifiP2pInfo?) {
runOnUiThread {
if (info?.groupFormed == true && !info.isGroupOwner) {
binding.tvStatus.text = "Đã kết nối đến Group Owner: ${info.groupOwnerAddress.hostAddress}"
binding.btnPickFile.isEnabled = true
} else if (info?.groupFormed == true && info.isGroupOwner) {
binding.tvStatus.text = "Làm Group Owner - Chuyển sang Server Mode"
} else {
binding.tvStatus.text = "Mất kết nối"
binding.btnPickFile.isEnabled = false
binding.btnSendFile.isEnabled = false
binding.pbTransfer.visibility = View.GONE
}
}
}
override fun onThisDeviceChanged(device: WifiP2pDevice) {}
private fun pickFile() {
val intent = Intent(Intent.ACTION_OPEN_DOCUMENT).apply {
type = "*/*"
addCategory(Intent.CATEGORY_OPENABLE)
}
startActivityForResult(intent, PICK_FILE_REQUEST)
}
override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
super.onActivityResult(requestCode, resultCode, data)
if (requestCode == PICK_FILE_REQUEST && resultCode == RESULT_OK) {
selectedFile = data?.data
binding.btnSendFile.isEnabled = selectedFile != null && selectedPeer != null
selectedFile?.let { binding.btnPickFile.text = "File: ${getFileName(it)}" }
}
}
private fun getFileName(uri: Uri): String {
return contentResolver.query(uri, null, null, null, null)?.use { cursor ->
if (cursor.moveToFirst()) cursor.getString(cursor.getColumnIndexOrThrow(OpenableColumns.DISPLAY_NAME)) else "unknown"
} ?: "unknown"
}
private fun sendFile() {
selectedFile?.let { uri ->
selectedPeer?.let { peer ->
p2p.connect(peer) // Kết nối nếu chưa
// Đợi connection info
p2p.requestConnectionInfo()
// Thực tế nên dùng callback/flow chờ groupFormed, đây là demo đơn giản
Thread {
Thread.sleep(2000) // Đợi kết nối xong
doSendFile(uri)
}.start()
}
}
}
private fun doSendFile(uri: Uri) {
val contentResolver = contentResolver
val fileSize = contentResolver.query(uri, null, null, null, null)?.use { cursor ->
if (cursor.moveToFirst()) cursor.getLong(cursor.getColumnIndexOrThrow(OpenableColumns.SIZE)) else 0L
} ?: 0
val fileName = getFileName(uri)
runOnUiThread {
binding.tvStatus.text = "Đang gửi: $fileName (${formatSize(fileSize)})"
binding.pbTransfer.visibility = View.VISIBLE
binding.pbTransfer.progress = 0
}
try {
// Lấy IP Group Owner từ P2PHelper (cần lưu trữ info.groupOwnerAddress)
// Ở đây giả định đã có sẵn từ onConnectionChanged
val serverIp = "192.168.49.1" // Mặc định Group Owner IP, nên lấy động từ WifiP2pInfo
val socket = Socket(serverIp, 8888)
val out = BufferedOutputStream(socket.getOutputStream())
val `in` = BufferedReader(InputStreamReader(socket.getInputStream()))
// 1. Gửi header
out.write("$fileName|$fileSize\n".toByteArray()); out.flush()
// 2. Đợi READY
val ack = `in`.readLine()
if (ack != "READY") throw IOException("Server not ready")
// 3. Stream file
val inputStream = contentResolver.openInputStream(uri)!!
val buffer = ByteArray(8192)
var totalSent = 0L
var read: Int
while (totalSent < fileSize && (read = inputStream.read(buffer)) != -1) {
out.write(buffer, 0, read)
totalSent += read
val progress = (totalSent * 100 / fileSize).toInt()
runOnUiThread { binding.pbTransfer.progress = progress }
}
out.flush()
inputStream.close()
// 4. Đợi DONE
val done = `in`.readLine()
runOnUiThread {
binding.tvStatus.text = if (done == "DONE") "Gửi thành công!" else "Server không xác nhận"
binding.pbTransfer.visibility = View.GONE
}
socket.close()
} catch (e: Exception) {
e.printStackTrace()
runOnUiThread {
binding.tvStatus.text = "Lỗi gửi: ${e.message}"
binding.pbTransfer.visibility = View.GONE
}
}
}
private fun formatSize(bytes: Long): String = when {
bytes < 1024 -> "$bytes B"
bytes < 1024*1024 -> "%.1f KB".format(bytes/1024.0)
bytes < 1024*1024*1024 -> "%.1f MB".format(bytes/1024.0/1024.0)
else -> "%.1f GB".format(bytes/1024.0/1024.0/1024.0)
}
}
Lưu ý triển khai thực tế
| Yếu tố | Khuyến nghị |
|---|---|
| IP Group Owner | Luôn lấy từ WifiP2pInfo.groupOwnerAddress trong callback, KHÔNG hardcode 192.168.49.1 |
| Đồng bộ kết nối | Dùng Coroutine Channel/Flow hoặc Interface callback để chờ groupFormed=true mới gửi file |
| File lớn (>4GB) | Dùng RandomAccessFile, hỗ trợ resume, chunked transfer |
| Nhiều file | Zip thành 1 file hoặc gửi manifest JSON trước |
| Bảo mật | TLS/SSL trên Socket, hoặc mã hóa file trước khi gửi |
| Foreground Service | Để truyền file lâu dài khi app background (Android 8+) |
So sánh WiFi Direct vs Bluetooth vs Hotspot
| Đặc điểm | WiFi Direct | Bluetooth | Hotspot + Socket |
|---|---|---|---|
| Tốc độ tối đa | ~250 Mbps (802.11n) | ~2-3 Mbps (BT 5.0) | ~50-100 Mbps |
| Khoảng cách | ~200m | ~10-50m | ~50-100m |
| Thiết lập | Tự động (WPS) | Pairing thủ công | Cần bật Hotspot, nhập pass |
| Hỗ trợ Android | API 14+ | API 1+ | API 1+ |
| Phù hợp | File lớn, video, backup | File nhỏ, IoT, audio | Nhiều thiết bị, server cố định |
Kết luận
WiFi Direct là giải pháp tối ưu cho truyền file dung lượng lớn giữa thiết bị Android offline. Cốt lõi: WifiP2pManager thiết lập liên kết P2P → xác định Group Owner → Socket TCP truyền file. Code Kotlin + Coroutines + P2PHelper wrapper ở trên cung cấp nền tảng sẵn sàng mở rộng cho ứng dụng chia sẻ file thực tế (như ShareIt, Xender).