Hướng dẫn xây dựng một ứng dụng quét WiFi (WiFi Scanner) hoàn chỉnh trên Android, bao gồm UI hiện đại, quản lý quyền theo phiên bản, và hiển thị thông tin chi tiết từng mạng.
Tính năng
- Quét mạng WiFi xung quanh, hiển thị SSID, BSSID, bảo mật, cường độ tín hiệu, băng tần.
- Tự động yêu cầu quyền Location (Android ≤12) hoặc NEARBY_WIFI_DEVICES (Android 13+).
- Biểu thị cường độ tín hiệu bằng màu sắc và phần trăm.
- Lọc trùng, sắp xếp theo độ mạnh tín hiệu.
- Tự động quét khi mở app, nút quét lại thủ công.
1. Cấu hình Gradle (build.gradle.kts)
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")
implementation("androidx.recyclerview:recyclerview:1.3.2")
implementation("androidx.activity:activity-ktx:1.8.2") // Activity Result API
}
2. Manifest - Permissions & Features
<manifest xmlns:android="https://schemas.android.com/apk/res/android"
package="com.example.wifiscanner">
<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-feature android:name="android.hardware.wifi" android:required="true" />
<application
android:allowBackup="true"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:theme="@style/Theme.WiFiScanner">
<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>
3. Model Data Class
data class WifiNetwork(
val ssid: String,
val bssid: String,
val capabilities: String,
val level: Int,
val frequency: Int
) {
val security: String
get() = when {
capabilities.contains("WPA3") -> "WPA3"
capabilities.contains("WPA2") -> "WPA2"
capabilities.contains("WPA") -> "WPA"
capabilities.contains("WEP") -> "WEP"
else -> "OPEN"
}
val signalPercent: Int
get() = when {
level <= -100 -> 0
level >= -30 -> 100
else -> (level + 100) * 100 / 70
}
val band: String
get() = if (frequency > 5000) "5 GHz" else "2.4 GHz"
}
4. RecyclerView Adapter với DiffUtil (Hiệu năng cao)
class WifiAdapter : ListAdapter<WifiNetwork, WifiAdapter.VH>(DiffCallback()) {
class VH(view: View) : RecyclerView.ViewHolder(view) {
val tvSsid = view.findViewById<TextView>(R.id.tvSsid)
val tvSecurity = view.findViewById<TextView>(R.id.tvSecurity)
val tvBand = view.findViewById<TextView>(R.id.tvBand)
val tvSignal = view.findViewById<TextView>(R.id.tvSignal)
val pbSignal = view.findViewById<ProgressBar>(R.id.pbSignal)
}
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 = getItem(position)
holder.tvSsid.text = if (wifi.ssid.isNotBlank()) wifi.ssid else "Ẩn SSID"
holder.tvSecurity.text = wifi.security
holder.tvBand.text = wifi.band
holder.tvSignal.text = "${wifi.signalPercent}%"
holder.pbSignal.progress = wifi.signalPercent
// Màu theo mức độ
val color = when {
wifi.level > -60 -> 0xFF4CAF50 // Green
wifi.level > -75 -> 0xFFFFC107 // Amber
else -> 0xFFF44336 // Red
}
holder.tvSignal.setTextColor(color)
holder.pbSignal.progressTintList = ColorStateList.valueOf(color)
}
class DiffCallback : DiffUtil.ItemCallback<WifiNetwork>() {
override fun areItemsTheSame(old: WifiNetwork, new: WifiNetwork) = old.bssid == new.bssid
override fun areContentsTheSame(old: WifiNetwork, new: WifiNetwork) = old == new
}
}
5. Item Layout (res/layout/item_wifi.xml)
<?xml version="1.0" encoding="utf-8"?>
<androidx.cardview.widget.CardView
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="wrap_content"
android:layout_margin="8dp"
app:cardCornerRadius="8dp"
app:cardElevation="2dp">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:padding="12dp">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:gravity="center_vertical">
<TextView
android:id="@+id/tvSsid"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:textSize="16sp"
android:textStyle="bold" />
<ProgressBar
android:id="@+id/pbSignal"
style="@style/Widget.AppCompat.ProgressBar.Horizontal"
android:layout_width="80dp"
android:layout_height="8dp"
android:layout_marginStart="8dp"
android:max="100"
android:progress="0" />
<TextView
android:id="@+id/tvSignal"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="8dp"
android:textSize="14sp"
android:textStyle="bold" />
</LinearLayout>
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:layout_marginTop="4dp">
<TextView
android:id="@+id/tvSecurity"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textSize="12sp"
android:background="@drawable/bg_chip"
android:paddingHorizontal="8dp"
android:paddingVertical="2dp"
android:layout_marginEnd="8dp" />
<TextView
android:id="@+id/tvBand"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textSize="12sp"
android:textColor="#888" />
</LinearLayout>
</LinearLayout>
</androidx.cardview.widget.CardView>
6. MainActivity - Logic chính
class MainActivity : AppCompatActivity() {
private lateinit var binding: ActivityMainBinding
private val wifiManager by lazy { getSystemService(WIFI_SERVICE) as WifiManager }
private val adapter = WifiAdapter()
private var receiver: BroadcastReceiver? = null
// Activity Result API cho permission
private val requestPerms = registerForActivityResult(ActivityResultContracts.RequestMultiplePermissions()) { granted ->
if (granted.values.all { it }) startScan()
else showPermissionRationale()
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
binding = ActivityMainBinding.inflate(layoutInflater)
setContentView(binding.root)
setupRecyclerView()
binding.btnScan.setOnClickListener { checkPermAndScan() }
ensureWifiEnabled()
}
private fun setupRecyclerView() {
binding.rvWifi.apply {
layoutManager = LinearLayoutManager(this@MainActivity)
adapter = this@MainActivity.adapter
addItemDecoration(DividerItemDecoration(this, DividerItemDecoration.VERTICAL))
}
}
private fun ensureWifiEnabled() {
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 requestPerms.launch(denied.toTypedArray())
}
private fun startScan() {
val success = wifiManager.startScan()
binding.btnScan.isEnabled = false
binding.btnScan.text = if (success) "Đang quét..." else "Quét lại"
// Re-enable sau 3s
binding.btnScan.postDelayed({ binding.btnScan.isEnabled = true; binding.btnScan.text = "Quét lại" }, 3000)
}
private fun showPermissionRationale() {
Toast.makeText(this, "Ứng dụng cần quyền vị trí/WiFi để quét mạng", Toast.LENGTH_LONG).show()
}
override fun onResume() {
super.onResume()
registerReceiver()
checkPermAndScan()
}
override fun onPause() {
super.onPause()
receiver?.let { unregisterReceiver(it) }
}
private fun registerReceiver() {
receiver = object : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) {
if (intent.action == WifiManager.SCAN_RESULTS_AVAILABLE_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)
binding.btnScan.isEnabled = true
binding.btnScan.text = "Quét lại"
}
}
}
registerReceiver(receiver!!, IntentFilter(WifiManager.SCAN_RESULTS_AVAILABLE_ACTION))
}
}
7. Xử lý trường hợp đặc biệt
| Trường hợp | Xử lý |
|---|---|
| WiFi tắt | Tự bật qua wifiManager.isWifiEnabled = true (cần CHANGE_WIFI_STATE) |
| Quyền bị từ chối vĩnh viễn | Hướng dẫn user vào Settings > Apps > Permissions |
| Android 10+ background | Chỉ quét khi app foreground, dùng WorkManager nếu cần nền |
| Danh sách rỗng | Hiển thị trạng thái "Không tìm thấy mạng" |
Mở rộng gợi ý
- Thêm biểu đồ cường độ tín hiệu realtime (MPAndroidChart).
- Lưu lịch sử quét vào Room Database.
- Phân loại kênh (channel) từ frequency để khuyến nghị kênh ít nhiễu.
- Xuất báo cáo CSV/JSON.
Kết luận
Ứng dụng WiFi Scanner hoàn chỉnh cần quản lý quyền chặt chẽ theo từng API level, dùng RecyclerView + ListAdapter cho hiệu năng, và xử lý vòng đời BroadcastReceiver đúng chuẩn. Code Kotlin trên sẵn sàng build và chạy trên Android 6.0+.