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

Xây dựng ứng dụng WiFi Scanner cho Android

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ợpXử lý
WiFi tắtTự bật qua wifiManager.isWifiEnabled = true (cần CHANGE_WIFI_STATE)
Quyền bị từ chối vĩnh viễnHướng dẫn user vào Settings > Apps > Permissions
Android 10+ backgroundChỉ quét khi app foreground, dùng WorkManager nếu cần nền
Danh sách rỗngHiể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+.