概要
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
}