Computer >> Hướng Dẫn Máy Tính >  >> Lập Trình >> Android

Truyền file qua Wi-Fi Direct (Wi-Fi Pair) trên Android

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

  1. Khám phá peer (discoverPeers)
  2. Kết nối P2P (connect với WPS PBC)
  3. Nhận WifiP2pInfo: xác định Group Owner (Server) và Client, lấy IP Group Owner.
  4. Group Owner: Mở ServerSocket port cố định (vd 8888).
  5. Client: Kết nối Socket đến IP Group Owner:8888.
  6. 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.

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ểmWiFi DirectBluetoothHotspot + 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ậpTự động (WPS)Pairing thủ côngCần bật Hotspot, nhập pass
Hỗ trợ AndroidAPI 14+API 1+API 1+
Phù hợpFile lớn, video, backupFile nhỏ, IoT, audioNhiề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).