Android向けKotlin製Wi-Fi操作ユーティリティクラスの実装と設計

概要

Androidアプリケーション開発において、Wi-Fiのネットワークスキャン、接続、切断、および状態監視は頻出する要件です。本記事では、Kotlinのビルダーパターンを活用し、これらの操作を一元管理するユーティリティクラス WifiController の実装アプローチを解説します。メモリリークを防ぐためのライフサイクル管理や、ブロードキャストレシーバーの安全な登録・解除、そしてコルーチンを用いた非同期処理の最適化も含まれています。

利用方法

ActivityやFragmentのライフサイクルに合わせて初期化と破棄を行います。ビルダーパターンを用いることで、必要なコールバックのみを直感的に設定できます。

private lateinit var wifiController: WifiController

override fun onResume() {
    super.onResume()
    if (wifiController.isWifiEnabled()) {
        wifiController.startScanning()
    }
}

override fun onDestroy() {
    super.onDestroy()
    wifiController.release()
}

private fun initializeWifiController() {
    wifiController = WifiController.Builder(this)
        .onWifiStateChanged { state ->
            when (state) {
                WifiManager.WIFI_STATE_DISABLED -> updateWifiToggleUI(false)
                WifiManager.WIFI_STATE_ENABLED -> updateWifiToggleUI(true)
                else -> updateWifiToggleUI(false)
            }
        }
        .onNetworkStateChanged { detailedState ->
            val statusText = when (detailedState) {
                NetworkInfo.DetailedState.AUTHENTICATING -> "認証中..."
                NetworkInfo.DetailedState.CONNECTING -> "接続中..."
                NetworkInfo.DetailedState.CONNECTED -> "接続済み"
                NetworkInfo.DetailedState.DISCONNECTING -> "切断中..."
                NetworkInfo.DetailedState.DISCONNECTED -> "切断済み"
                NetworkInfo.DetailedState.FAILED -> "接続失敗"
                else -> ""
            }
            updateConnectionStatusUI(statusText)
        }
        .onScanResultsAvailable { results ->
            hideLoadingIndicator()
            updateWifiListUI(results)
        }
        .onConnectedNetworkUpdated { network ->
            updateCurrentNetworkUI(network)
        }
        .onAuthenticationFailed { errorMessage ->
            showToast(errorMessage)
        }
        .build()
}

コア実装

以下が WifiController および関連するデータモデルの完全な実装です。コルーチンスコープをクラス内部で管理し、release() 呼び出し時に確実にキャンセルされるように設計されています。これにより、GlobalScope を使用していた従来の実装よりも安全で効率的な非同期処理が可能になります。

import android.annotation.SuppressLint
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.content.IntentFilter
import android.net.NetworkInfo
import android.net.wifi.WifiConfiguration
import android.net.wifi.WifiManager
import kotlinx.coroutines.*

class WifiController private constructor(private val config: Builder) {

    private val appContext: Context = config.context.applicationContext
    private val wifiManager: WifiManager = appContext.getSystemService(Context.WIFI_SERVICE) as WifiManager
    private val coroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.Main)
    private val broadcastReceiver = WifiBroadcastReceiver()

    init {
        registerReceiver()
    }

    fun release() {
        coroutineScope.cancel()
        unregisterReceiver()
        config.clearCallbacks()
    }

    fun isWifiEnabled(): Boolean = wifiManager.isWifiEnabled

    fun setWifiEnabled(enabled: Boolean) {
        wifiManager.isWifiEnabled = enabled
    }

    fun startScanning() {
        wifiManager.startScan()
    }

    fun refreshCurrentNetwork() {
        coroutineScope.launch {
            val currentNetwork = withContext(Dispatchers.IO) { fetchConnectedNetwork() }
            config.onConnectedNetworkUpdated?.invoke(currentNetwork)
        }
    }

    fun connectToNetwork(network: WifiNetworkInfo, password: String = "") {
        coroutineScope.launch(Dispatchers.IO) {
            val savedConfigs = wifiManager.configuredNetworks ?: emptyList()
            val savedConfig = savedConfigs.find { trimQuotes(it.SSID) == network.ssid }

            if (savedConfig != null) {
                wifiManager.enableNetwork(savedConfig.networkId, true)
            } else {
                val newConfig = WifiConfiguration().apply {
                    SSID = addQuotes(network.ssid)
                    clearDefaultSettings()
                    applySecuritySettings(network.securityProtocol, password)
                }
                val networkId = wifiManager.addNetwork(newConfig)
                wifiManager.enableNetwork(networkId, true)
            }
        }
    }

    fun disconnectFromNetwork() {
        coroutineScope.launch(Dispatchers.IO) {
            val currentNetwork = fetchConnectedNetwork() ?: return@launch
            wifiManager.disconnect()
            wifiManager.disableNetwork(currentNetwork.netId)
            wifiManager.reconnect()
        }
    }

    @SuppressLint("MissingPermission")
    fun updateNetworkPassword(network: WifiNetworkInfo, newPassword: String) {
        coroutineScope.launch(Dispatchers.IO) {
            val configs = wifiManager.configuredNetworks ?: return@launch
            val targetConfig = configs.find { it.SSID == addQuotes(network.ssid) } ?: return@launch
            
            targetConfig.preSharedKey = addQuotes(newPassword)
            wifiManager.disconnect()
            val updatedId = wifiManager.updateNetwork(targetConfig)
            if (updatedId != -1) {
                wifiManager.enableNetwork(updatedId, true)
                wifiManager.reconnect()
            }
        }
    }

    fun removeSavedNetwork(network: WifiNetworkInfo) {
        coroutineScope.launch(Dispatchers.IO) {
            val configs = wifiManager.configuredNetworks ?: return@launch
            val targetConfig = configs.find { it.SSID == addQuotes(network.ssid) } ?: return@launch
            
            wifiManager.disconnect()
            wifiManager.removeNetwork(targetConfig.networkId)
            wifiManager.reconnect()
        }
    }

    private fun registerReceiver() {
        val filter = IntentFilter().apply {
            addAction(WifiManager.NETWORK_STATE_CHANGED_ACTION)
            addAction(WifiManager.WIFI_STATE_CHANGED_ACTION)
            addAction(WifiManager.RSSI_CHANGED_ACTION)
            addAction(WifiManager.SCAN_RESULTS_AVAILABLE_ACTION)
            addAction(WifiManager.SUPPLICANT_STATE_CHANGED_ACTION)
        }
        appContext.registerReceiver(broadcastReceiver, filter)
    }

    private fun unregisterReceiver() {
        try {
            appContext.unregisterReceiver(broadcastReceiver)
        } catch (e: IllegalArgumentException) {
            // レシーバーが未登録の場合の例外を無視
        }
    }

    private fun fetchScanResults(): List<WifiNetworkInfo> {
        val scanResults = wifiManager.scanResults ?: emptyList()
        val savedConfigs = wifiManager.configuredNetworks ?: emptyList()
        val connectedSsid = fetchConnectedNetwork()?.ssid

        val networkList = mutableListOf<WifiNetworkInfo>()
        for (result in scanResults) {
            if (result.SSID.isNullOrEmpty() || result.SSID == connectedSsid) continue
            
            val isAlreadyAdded = networkList.any { it.ssid == result.SSID }
            if (!isAlreadyAdded) {
                val savedConfig = savedConfigs.find { trimQuotes(it.SSID) == result.SSID }
                networkList.add(
                    WifiNetworkInfo(
                        netId = savedConfig?.networkId ?: -1,
                        ssid = result.SSID,
                        signalLevel = calculateSignalLevel(result.level),
                        bssid = result.BSSID,
                        ipAddress = null,
                        securityProtocol = parseSecurityProtocol(result.capabilities),
                        isSaved = savedConfig != null
                    )
                )
            }
        }
        
        return networkList.sortedWith(
            compareByDescending<WifiNetworkInfo> { it.isSaved }
            .thenByDescending { it.signalLevel }
        )
    }

    private fun fetchConnectedNetwork(): WifiNetworkInfo? {
        val info = wifiManager.connectionInfo ?: return null
        val rawSsid = info.ssid
        if (rawSsid == null || rawSsid.contains("unknown ssid", ignoreCase = true)) return null

        return WifiNetworkInfo(
            netId = info.networkId,
            ssid = trimQuotes(rawSsid),
            signalLevel = calculateSignalLevel(info.rssi),
            bssid = info.bssid,
            ipAddress = formatIpAddress(info.ipAddress),
            securityProtocol = SecurityProtocol.WPA,
            isSaved = true
        )
    }

    private fun refreshAllData() {
        coroutineScope.launch {
            val scanList = withContext(Dispatchers.IO) { fetchScanResults() }
            val connected = withContext(Dispatchers.IO) { fetchConnectedNetwork() }
            config.onScanResultsAvailable?.invoke(scanList)
            config.onConnectedNetworkUpdated?.invoke(connected)
        }
    }

    private fun trimQuotes(text: String): String {
        return if (text.startsWith("\"") && text.endsWith("\"")) {
            text.substring(1, text.length - 1)
        } else text
    }

    private fun addQuotes(text: String): String = "\"$text\""

    private fun calculateSignalLevel(rssi: Int): Int {
        return when {
            rssi <= -97 -> 0
            rssi <= -87 -> 1
            rssi <= -75 -> 2
            rssi <= -63 -> 3
            else -> 4
        }
    }

    private fun formatIpAddress(ipInt: Int): String {
        return "${ipInt and 0xFF}.${(ipInt shr 8) and 0xFF}.${(ipInt shr 16) and 0xFF}.${(ipInt shr 24) and 0xFF}"
    }

    private fun parseSecurityProtocol(capabilities: String?): SecurityProtocol {
        if (capabilities.isNullOrEmpty()) return SecurityProtocol.OPEN
        return when {
            capabilities.contains("WPA2-EAP") -> SecurityProtocol.ENTERPRISE
            capabilities.contains("WPA-PSK") || capabilities.contains("WPA2-PSK") -> SecurityProtocol.WPA
            capabilities.contains("WEP") -> SecurityProtocol.WEP
            else -> SecurityProtocol.OPEN
        }
    }
    
    private fun WifiConfiguration.clearDefaultSettings() {
        allowedAuthAlgorithms.clear()
        allowedGroupCiphers.clear()
        allowedKeyManagement.clear()
        allowedPairwiseCiphers.clear()
        allowedProtocols.clear()
    }

    private fun WifiConfiguration.applySecuritySettings(protocol: SecurityProtocol, password: String) {
        when (protocol) {
            SecurityProtocol.OPEN -> {
                allowedKeyManagement.set(WifiConfiguration.KeyMgmt.NONE)
            }
            SecurityProtocol.WPA -> {
                preSharedKey = addQuotes(password)
                allowedAuthAlgorithms.set(WifiConfiguration.AuthAlgorithm.OPEN)
                allowedKeyManagement.set(WifiConfiguration.KeyMgmt.WPA_PSK)
                allowedProtocols.set(WifiConfiguration.Protocol.RSN)
                allowedProtocols.set(WifiConfiguration.Protocol.WPA)
            }
            SecurityProtocol.WEP -> {
                wepKeys[0] = addQuotes(password)
                wepTxKeyIndex = 0
                allowedKeyManagement.set(WifiConfiguration.KeyMgmt.NONE)
                allowedGroupCiphers.set(WifiConfiguration.GroupCipher.WEP40)
            }
            SecurityProtocol.ENTERPRISE -> {
                // 企業向けWi-Fiは複雑なEAP設定が必要
            }
        }
    }

    class Builder(val context: Context) {
        internal var onScanResultsAvailable: ((List<WifiNetworkInfo>) -> Unit)? = null
        internal var onConnectedNetworkUpdated: ((WifiNetworkInfo?) -> Unit)? = null
        internal var onWifiStateChanged: ((Int) -> Unit)? = null
        internal var onNetworkStateChanged: ((NetworkInfo.DetailedState) -> Unit)? = null
        internal var onAuthenticationFailed: ((String) -> Unit)? = null

        fun onScanResultsAvailable(callback: (List<WifiNetworkInfo>) -> Unit) = apply { this.onScanResultsAvailable = callback }
        fun onConnectedNetworkUpdated(callback: (WifiNetworkInfo?) -> Unit) = apply { this.onConnectedNetworkUpdated = callback }
        fun onWifiStateChanged(callback: (Int) -> Unit) = apply { this.onWifiStateChanged = callback }
        fun onNetworkStateChanged(callback: (NetworkInfo.DetailedState) -> Unit) = apply { this.onNetworkStateChanged = callback }
        fun onAuthenticationFailed(callback: (String) -> Unit) = apply { this.onAuthenticationFailed = callback }

        internal fun clearCallbacks() {
            onScanResultsAvailable = null
            onConnectedNetworkUpdated = null
            onWifiStateChanged = null
            onNetworkStateChanged = null
            onAuthenticationFailed = null
        }

        fun build(): WifiController = WifiController(this)
    }

    private inner class WifiBroadcastReceiver : BroadcastReceiver() {
        @Suppress("DEPRECATION")
        override fun onReceive(context: Context, intent: Intent) {
            when (intent.action) {
                WifiManager.WIFI_STATE_CHANGED_ACTION -> {
                    val state = intent.getIntExtra(WifiManager.EXTRA_WIFI_STATE, WifiManager.WIFI_STATE_UNKNOWN)
                    config.onWifiStateChanged?.invoke(state)
                }
                WifiManager.NETWORK_STATE_CHANGED_ACTION -> {
                    val networkInfo = intent.getParcelableExtra<NetworkInfo>(WifiManager.EXTRA_NETWORK_INFO)
                    networkInfo?.let {
                        config.onNetworkStateChanged?.invoke(it.detailedState)
                        if (it.detailedState == NetworkInfo.DetailedState.CONNECTED || 
                            it.detailedState == NetworkInfo.DetailedState.DISCONNECTED) {
                            refreshAllData()
                        }
                    }
                }
                WifiManager.RSSI_CHANGED_ACTION -> {
                    refreshCurrentNetwork()
                }
                WifiManager.SCAN_RESULTS_AVAILABLE_ACTION -> {
                    refreshAllData()
                }
                WifiManager.SUPPLICANT_STATE_CHANGED_ACTION -> {
                    val error = intent.getIntExtra(WifiManager.EXTRA_SUPPLICANT_ERROR, -1)
                    if (error == WifiManager.ERROR_AUTHENTICATING) {
                        config.onAuthenticationFailed?.invoke("認証に失敗しました。パスワードを確認してください。")
                    }
                }
            }
        }
    }
}

data class WifiNetworkInfo(
    val netId: Int,
    val ssid: String,
    val signalLevel: Int,
    val bssid: String?,
    val ipAddress: String?,
    val securityProtocol: SecurityProtocol,
    val isSaved: Boolean
)

enum class SecurityProtocol {
    OPEN,
    WEP,
    WPA,
    ENTERPRISE
}

タグ: Android Kotlin WifiManager coroutine BroadcastReceiver

10月22日 11:31 投稿