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

Quét mạng WiFi lập trình trên Android (Programmatic Scan)

Bài viết trình bày cách thực hiện quét WiFi theo chương trình (programmatic) trên Android, tập trung vào luồng quyền runtime và xử lý BroadcastReceiver đúng chuẩn.

Kiến trúc luồng quét

  1. Kiểm tra/bật WiFi
  2. Yêu cầu runtime permission (Location/NEARBY_WIFI_DEVICES)
  3. Gọi WifiManager.startScan()
  4. Nhận kết quả qua BroadcastReceiver lắng nghe SCAN_RESULTS_AVAILABLE_ACTION
  5. Cập nhật UI (RecyclerView/ListView)

Manifest permissions

<manifest ...>
    <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" />
    <!-- Android 13+ -->
    <uses-permission android:name="android.permission.NEARBY_WIFI_DEVICES"
        android:usesPermissionFlags="neverForLocation" />
    <application ...>...</application>
</manifest>

Layout: ConstraintLayout + RecyclerView (Hiện đại hơn ListView)

<?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">

    <androidx.recyclerview.widget.RecyclerView
        android:id="@+id/rvWifi"
        android:layout_width="0dp"
        android:layout_height="0dp"
        app:layout_constraintTop_toTopOf="parent"
        app:layout_constraintBottom_toTopOf="@id/btnScan"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintEnd_toEndOf="parent"
        android:padding="8dp" />

    <Button
        android:id="@+id/btnScan"
        android:layout_width="0dp"
        android:layout_height="wrap_content"
        android:layout_margin="16dp"
        android:text="Quét lại"
        app:layout_constraintBottom_toBottomOf="parent"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintEnd_toEndOf="parent" />
</androidx.constraintlayout.widget.ConstraintLayout>

Model: WifiNetwork.kt

data class WifiNetwork(
    val ssid: String,
    val bssid: String,
    val capabilities: String,
    val level: Int, // dBm
    val frequency: Int // MHz
) {
    fun signalStrengthPercent(): Int {
        // Chuyển dBm (-100 đến -30) sang % (0-100)
        return when {
            level <= -100 -> 0
            level >= -30 -> 100
            else -> (level + 100) * 100 / 70
        }
    }

    fun band(): String = if (frequency > 5000) "5 GHz" else "2.4 GHz"
}

Adapter: WifiAdapter.kt

class WifiAdapter : RecyclerView.Adapter<WifiAdapter.VH>() {
    private var list = emptyList<WifiNetwork>()

    inner class VH(view: View) : RecyclerView.ViewHolder(view) {
        val tvSsid: TextView = view.findViewById(R.id.tvSsid)
        val tvDetails: TextView = view.findViewById(R.id.tvDetails)
        val tvSignal: TextView = view.findViewById(R.id.tvSignal)
    }

    override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): VH {
        val view = LayoutInflater.from(parent.context)
            .inflate(R.layout.item_wifi, parent, false)
        return VH(view)
    }

    override fun onBindViewHolder(holder: VH, position: Int) {
        val wifi = list[position]
        holder.tvSsid.text = if (wifi.ssid.isNotBlank()) wifi.ssid else "<Ẩn SSID>"
        holder.tvDetails.text = "${wifi.capabilities} • ${wifi.band()}"
        holder.tvSignal.text = "${wifi.signalStrengthPercent()}%"
        // Màu theo mức độ
        holder.tvSignal.setTextColor(when {
            wifi.level > -60 -> 0xFF4CAF50.toInt() // Xanh
            wifi.level > -75 -> 0xFFFFC107.toInt() // Vàng
            else -> 0xFFF44336.toInt() // Đỏ
        })
    }

    override fun getItemCount() = list.size

    fun submitList(newList: List<WifiNetwork>) {
        list = newList
        notifyDataSetChanged()
    }
}

Item Layout: res/layout/item_wifi.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="wrap_content"
    android:orientation="horizontal"
    android:padding="12dp"
    android:gravity="center_vertical">

    <LinearLayout
        android:layout_width="0dp"
        android:layout_height="wrap_content"
        android:layout_weight="1"
        android:orientation="vertical">
        <TextView android:id="@+id/tvSsid" android:layout_width="match_parent" android:layout_height="wrap_content"
            android:textSize="16sp" android:textStyle="bold" />
        <TextView android:id="@+id/tvDetails" android:layout_width="match_parent" android:layout_height="wrap_content"
            android:textSize="12sp" android:textColor="#888" />
    </LinearLayout>

    <TextView android:id="@+id/tvSignal" android:layout_width="wrap_content" android:layout_height="wrap_content"
        android:textSize="14sp" android:textStyle="bold" android:layout_marginStart="8dp" />
</LinearLayout>

MainActivity.kt - Logic hoàn chỉnh

class MainActivity : AppCompatActivity() {
    private lateinit var binding: ActivityMainBinding
    private lateinit var wifiManager: WifiManager
    private val adapter = WifiAdapter()
    private var scanReceiver: BroadcastReceiver? = null
    private val permRequest = registerForActivityResult(ActivityResultContracts.RequestMultiplePermissions()) { results ->
        if (results.values.all { it }) startScan()
        else Toast.makeText(this, "Cần quyền để quét WiFi", Toast.LENGTH_LONG).show()
    }

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        binding = ActivityMainBinding.inflate(layoutInflater)
        setContentView(binding.root)

        wifiManager = getSystemService(WIFI_SERVICE) as WifiManager
        binding.rvWifi.adapter = adapter
        binding.rvWifi.layoutManager = LinearLayoutManager(this)

        binding.btnScan.setOnClickListener { checkPermAndScan() }

        // Bật WiFi nếu tắt
        if (!wifiManager.isWifiEnabled) {
            wifiManager.isWifiEnabled = true
            Toast.makeText(this, "Đang bật WiFi...", Toast.LENGTH_LONG).show()
        }
    }

    private fun checkPermAndScan() {
        val perms = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
            arrayOf(Manifest.permission.NEARBY_WIFI_DEVICES)
        } else {
            arrayOf(Manifest.permission.ACCESS_FINE_LOCATION, Manifest.permission.ACCESS_COARSE_LOCATION)
        }
        val denied = perms.filter { checkSelfPermission(it) != PackageManager.PERMISSION_GRANTED }
        if (denied.isEmpty()) startScan() else permRequest.launch(denied.toTypedArray())
    }

    private fun startScan() {
        val success = wifiManager.startScan()
        Toast.makeText(this, if (success) "Đang quét..." else "Không thể quét", Toast.LENGTH_SHORT).show()
    }

    override fun onResume() {
        super.onResume()
        registerScanReceiver()
        checkPermAndScan()
    }

    override fun onPause() {
        super.onPause()
        scanReceiver?.let { unregisterReceiver(it) }
    }

    private fun registerScanReceiver() {
        scanReceiver = object : BroadcastReceiver() {
            override fun onReceive(context: Context, intent: Intent) {
                if (WifiManager.SCAN_RESULTS_AVAILABLE_ACTION == intent.action) {
                    val results = wifiManager.scanResults
                    val networks = results.map { WifiNetwork(it.SSID, it.BSSID, it.capabilities, it.level, it.frequency) }
                        .distinctBy { it.bssid }
                        .sortedByDescending { it.level }
                    adapter.submitList(networks)
                }
            }
        }
        registerReceiver(scanReceiver!!, IntentFilter(WifiManager.SCAN_RESULTS_AVAILABLE_ACTION))
    }
}

Lưu ý nâng cao

  • Android 10+: Chỉ quét được khi app ở foreground. Background scan bị hạn chế.
  • Throttling: Hệ thống giới hạn tần suất startScan() (khoảng 4 lần/2 giây). Quét quá nhiều sẽ bị ignore.
  • MAC Randomization: BSSID có thể random hóa, không dùng để tracking thiết bị.
  • Hidden SSID: SSID rỗng hoặc "<unknown ssid>" - hiển thị "Ẩn SSID".

Kết luận

Quét WiFi lập trình đòi hỏi quản lý quyền cẩn thận theo từng phiên bản Android, đăng ký/hủy BroadcastReceiver đúng vòng đời Activity, và xử lý UI mượt mà với RecyclerView. Code mẫu Kotlin trên áp dụng các best practice hiện đại.