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
- Kiểm tra/bật WiFi
- Yêu cầu runtime permission (Location/NEARBY_WIFI_DEVICES)
- Gọi
WifiManager.startScan() - Nhận kết quả qua
BroadcastReceiverlắng ngheSCAN_RESULTS_AVAILABLE_ACTION - 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.