diff --git a/core/src/main/java/com/featuremodule/core/util/WifiUtils.java b/core/src/main/java/com/featuremodule/core/util/WifiUtils.java new file mode 100644 index 0000000..48d33e6 --- /dev/null +++ b/core/src/main/java/com/featuremodule/core/util/WifiUtils.java @@ -0,0 +1,173 @@ +package com.featuremodule.core.util; + +/** + * Copy of ScanResult utility functions unavailable on earlier SDKs + */ +public class WifiUtils { + /** + * The unspecified value. + */ + public final static int UNSPECIFIED = -1; + + /** + * 2.4 GHz band first channel number + */ + public static final int BAND_24_GHZ_FIRST_CH_NUM = 1; + /** + * 2.4 GHz band last channel number + */ + public static final int BAND_24_GHZ_LAST_CH_NUM = 14; + /** + * 2.4 GHz band frequency of first channel in MHz + */ + public static final int BAND_24_GHZ_START_FREQ_MHZ = 2412; + /** + * 2.4 GHz band frequency of last channel in MHz + */ + public static final int BAND_24_GHZ_END_FREQ_MHZ = 2484; + + /** + * 5 GHz band first channel number + */ + public static final int BAND_5_GHZ_FIRST_CH_NUM = 32; + /** + * 5 GHz band last channel number + */ + public static final int BAND_5_GHZ_LAST_CH_NUM = 177; + /** + * 5 GHz band frequency of first channel in MHz + */ + public static final int BAND_5_GHZ_START_FREQ_MHZ = 5160; + /** + * 5 GHz band frequency of last channel in MHz + */ + public static final int BAND_5_GHZ_END_FREQ_MHZ = 5885; + + /** + * 6 GHz band first channel number + */ + public static final int BAND_6_GHZ_FIRST_CH_NUM = 1; + /** + * 6 GHz band last channel number + */ + public static final int BAND_6_GHZ_LAST_CH_NUM = 233; + /** + * 6 GHz band frequency of first channel in MHz + */ + public static final int BAND_6_GHZ_START_FREQ_MHZ = 5955; + /** + * 6 GHz band frequency of last channel in MHz + */ + public static final int BAND_6_GHZ_END_FREQ_MHZ = 7115; + /** + * The center frequency of the first 6Ghz preferred scanning channel, as defined by + * IEEE802.11ax draft 7.0 section 26.17.2.3.3. + */ + public static final int BAND_6_GHZ_PSC_START_MHZ = 5975; + /** + * The number of MHz to increment in order to get the next 6Ghz preferred scanning channel + * as defined by IEEE802.11ax draft 7.0 section 26.17.2.3.3. + */ + public static final int BAND_6_GHZ_PSC_STEP_SIZE_MHZ = 80; + + /** + * 6 GHz band operating class 136 channel 2 center frequency in MHz + */ + public static final int BAND_6_GHZ_OP_CLASS_136_CH_2_FREQ_MHZ = 5935; + + /** + * 60 GHz band first channel number + */ + public static final int BAND_60_GHZ_FIRST_CH_NUM = 1; + /** + * 60 GHz band last channel number + */ + public static final int BAND_60_GHZ_LAST_CH_NUM = 6; + /** + * 60 GHz band frequency of first channel in MHz + */ + public static final int BAND_60_GHZ_START_FREQ_MHZ = 58320; + /** + * 60 GHz band frequency of last channel in MHz + */ + public static final int BAND_60_GHZ_END_FREQ_MHZ = 70200; + + /** + * Utility function to check if a frequency within 2.4 GHz band + * + * @param freqMhz frequency in MHz + * @return true if within 2.4GHz, false otherwise + */ + public static boolean is24GHz(int freqMhz) { + return freqMhz >= BAND_24_GHZ_START_FREQ_MHZ && freqMhz <= BAND_24_GHZ_END_FREQ_MHZ; + } + + /** + * Utility function to check if a frequency within 5 GHz band + * + * @param freqMhz frequency in MHz + * @return true if within 5GHz, false otherwise + */ + public static boolean is5GHz(int freqMhz) { + return freqMhz >= BAND_5_GHZ_START_FREQ_MHZ && freqMhz <= BAND_5_GHZ_END_FREQ_MHZ; + } + + /** + * Utility function to check if a frequency within 6 GHz band + * + * @return true if within 6GHz, false otherwise + */ + public static boolean is6GHz(int freqMhz) { + if (freqMhz == BAND_6_GHZ_OP_CLASS_136_CH_2_FREQ_MHZ) { + return true; + } + return (freqMhz >= BAND_6_GHZ_START_FREQ_MHZ && freqMhz <= BAND_6_GHZ_END_FREQ_MHZ); + } + + /** + * Utility function to check if a frequency is 6Ghz PSC channel. + * + * @return true if the frequency is 6GHz PSC, false otherwise + */ + public static boolean is6GHzPsc(int freqMhz) { + if (!is6GHz(freqMhz)) { + return false; + } + return (freqMhz - BAND_6_GHZ_PSC_START_MHZ) % BAND_6_GHZ_PSC_STEP_SIZE_MHZ == 0; + } + + /** + * Utility function to check if a frequency within 60 GHz band + * + * @return true if within 60GHz, false otherwise + */ + public static boolean is60GHz(int freqMhz) { + return freqMhz >= BAND_60_GHZ_START_FREQ_MHZ && freqMhz <= BAND_60_GHZ_END_FREQ_MHZ; + } + + /** + * Utility function to convert frequency in MHz to channel number. + * + * @param freqMhz frequency in MHz + * @return channel number associated with given frequency, {@link #UNSPECIFIED} if no match + */ + public static int convertFrequencyMhzToChannelIfSupported(int freqMhz) { + // Special case + if (freqMhz == 2484) { + return 14; + } else if (is24GHz(freqMhz)) { + return (freqMhz - BAND_24_GHZ_START_FREQ_MHZ) / 5 + BAND_24_GHZ_FIRST_CH_NUM; + } else if (is5GHz(freqMhz)) { + return ((freqMhz - BAND_5_GHZ_START_FREQ_MHZ) / 5) + BAND_5_GHZ_FIRST_CH_NUM; + } else if (is6GHz(freqMhz)) { + if (freqMhz == BAND_6_GHZ_OP_CLASS_136_CH_2_FREQ_MHZ) { + return 2; + } + return ((freqMhz - BAND_6_GHZ_START_FREQ_MHZ) / 5) + BAND_6_GHZ_FIRST_CH_NUM; + } else if (is60GHz(freqMhz)) { + return ((freqMhz - BAND_60_GHZ_START_FREQ_MHZ) / 2160) + BAND_60_GHZ_FIRST_CH_NUM; + } + + return UNSPECIFIED; + } +} diff --git a/feature/homeImpl/src/main/AndroidManifest.xml b/feature/homeImpl/src/main/AndroidManifest.xml index 15d7e8a..1839ec2 100644 --- a/feature/homeImpl/src/main/AndroidManifest.xml +++ b/feature/homeImpl/src/main/AndroidManifest.xml @@ -4,6 +4,10 @@ android:name="android.hardware.camera" android:required="false" /> + + + + @@ -49,6 +50,10 @@ fun NavGraphBuilder.registerHome() { ?: "NONE" BarcodeResultScreen(barcode) } + + composable(InternalRoutes.WifiDestination.ROUTE) { + WifiScreen() + } } internal class InternalRoutes { @@ -87,4 +92,10 @@ internal class InternalRoutes { fun constructRoute(barcodeValue: String) = "barcode_result/$barcodeValue" } + + object WifiDestination { + const val ROUTE = "wifi" + + fun constructRoute() = ROUTE + } } diff --git a/feature/homeImpl/src/main/java/com/featuremodule/homeImpl/ui/HomeContract.kt b/feature/homeImpl/src/main/java/com/featuremodule/homeImpl/ui/HomeContract.kt index 6cdbe4c..7a385b7 100644 --- a/feature/homeImpl/src/main/java/com/featuremodule/homeImpl/ui/HomeContract.kt +++ b/feature/homeImpl/src/main/java/com/featuremodule/homeImpl/ui/HomeContract.kt @@ -10,4 +10,5 @@ internal sealed interface Event : UiEvent { data object NavigateToExoplayer : Event data object NavigateToCamera : Event data object NavigateToBarcode : Event + data object NavigateToWifi : Event } diff --git a/feature/homeImpl/src/main/java/com/featuremodule/homeImpl/ui/HomeScreen.kt b/feature/homeImpl/src/main/java/com/featuremodule/homeImpl/ui/HomeScreen.kt index 460bd35..77b31cf 100644 --- a/feature/homeImpl/src/main/java/com/featuremodule/homeImpl/ui/HomeScreen.kt +++ b/feature/homeImpl/src/main/java/com/featuremodule/homeImpl/ui/HomeScreen.kt @@ -47,6 +47,7 @@ internal fun HomeScreen(route: String?, viewModel: HomeVM = hiltViewModel()) { GenericButton(text = "Exoplayer") { viewModel.postEvent(Event.NavigateToExoplayer) } GenericButton(text = "Camera") { viewModel.postEvent(Event.NavigateToCamera) } GenericButton(text = "Barcode") { viewModel.postEvent(Event.NavigateToBarcode) } + GenericButton(text = "Wifi") { viewModel.postEvent(Event.NavigateToWifi) } } } } diff --git a/feature/homeImpl/src/main/java/com/featuremodule/homeImpl/ui/HomeVM.kt b/feature/homeImpl/src/main/java/com/featuremodule/homeImpl/ui/HomeVM.kt index af6c8bc..3e6699e 100644 --- a/feature/homeImpl/src/main/java/com/featuremodule/homeImpl/ui/HomeVM.kt +++ b/feature/homeImpl/src/main/java/com/featuremodule/homeImpl/ui/HomeVM.kt @@ -17,33 +17,33 @@ internal class HomeVM @Inject constructor( override fun initialState() = State override fun handleEvent(event: Event) { - when (event) { - Event.NavigateToFeatureA -> launch { - val randomInt = Random.nextInt(until = 10) - // Using `saveState = false` causes return to Feature A on Home item click - navManager.navigate(NavCommand.OpenNavBarRoute(NavBarItems.FeatureA.graphRoute)) - - navManager.navigate( - NavCommand.Forward(FeatureADestination.constructRoute(randomInt)), - ) - } + launch { + when (event) { + Event.NavigateToFeatureA -> { + val randomInt = Random.nextInt(until = 10) + // Using `saveState = false` causes return to Feature A on Home item click + navManager.navigate(NavCommand.OpenNavBarRoute(NavBarItems.FeatureA.graphRoute)) - Event.NavigateToExoplayer -> launch { - navManager.navigate( + navManager.navigate( + NavCommand.Forward(FeatureADestination.constructRoute(randomInt)), + ) + } + + Event.NavigateToExoplayer -> navManager.navigate( NavCommand.Forward(InternalRoutes.ExoplayerDestination.constructRoute()), ) - } - Event.NavigateToCamera -> launch { - navManager.navigate( + Event.NavigateToCamera -> navManager.navigate( NavCommand.Forward(InternalRoutes.ImageUploadDestination.constructRoute()), ) - } - Event.NavigateToBarcode -> launch { - navManager.navigate( + Event.NavigateToBarcode -> navManager.navigate( NavCommand.Forward(InternalRoutes.BarcodeCameraDestination.constructRoute()), ) + + Event.NavigateToWifi -> navManager.navigate( + NavCommand.Forward(InternalRoutes.WifiDestination.constructRoute()), + ) } } } diff --git a/feature/homeImpl/src/main/java/com/featuremodule/homeImpl/wifi/WifiContract.kt b/feature/homeImpl/src/main/java/com/featuremodule/homeImpl/wifi/WifiContract.kt new file mode 100644 index 0000000..aaa483a --- /dev/null +++ b/feature/homeImpl/src/main/java/com/featuremodule/homeImpl/wifi/WifiContract.kt @@ -0,0 +1,34 @@ +package com.featuremodule.homeImpl.wifi + +import android.net.NetworkRequest +import android.net.wifi.ScanResult +import android.net.wifi.WifiNetworkSuggestion +import com.featuremodule.core.ui.UiEvent +import com.featuremodule.core.ui.UiState + +internal data class State( + val wifiNetworks: List = emptyList(), + val wifiToConnect: NetworkRequest? = null, + val wifiSuggestions: ArrayList? = null, + val isLocationEnabled: Boolean = true, + val isWifiEnabled: Boolean = true, +) : UiState + +internal data class NetworkState( + val ssid: String, + val bssid: String, + val bandGhz: String, + val channel: Int, + val channelWidthMhz: Int, + val level: Int, +) + +internal sealed interface Event : UiEvent { + data class WifiResultsScanned(val result: List) : Event + data class SaveWifi(val network: NetworkState) : Event + data class ConnectWifi(val network: NetworkState) : Event + data object ClearWifiEvents : Event + data class UpdateLocationEnabled(val enabled: Boolean) : Event + data class UpdateWifiEnabled(val enabled: Boolean) : Event + data object PopBack : Event +} diff --git a/feature/homeImpl/src/main/java/com/featuremodule/homeImpl/wifi/WifiScreen.kt b/feature/homeImpl/src/main/java/com/featuremodule/homeImpl/wifi/WifiScreen.kt new file mode 100644 index 0000000..19d2bc2 --- /dev/null +++ b/feature/homeImpl/src/main/java/com/featuremodule/homeImpl/wifi/WifiScreen.kt @@ -0,0 +1,412 @@ +package com.featuremodule.homeImpl.wifi + +import android.Manifest +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import android.content.IntentFilter +import android.content.pm.PackageManager +import android.location.LocationManager +import android.net.ConnectivityManager +import android.net.NetworkRequest +import android.net.wifi.WifiManager +import android.net.wifi.WifiManager.ScanResultsCallback +import android.net.wifi.WifiNetworkSuggestion +import android.os.Build +import android.os.Bundle +import android.provider.Settings +import android.util.Log +import android.widget.Toast +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material3.Button +import androidx.compose.material3.Card +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalLifecycleOwner +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.text.withStyle +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.compose.ui.window.Dialog +import androidx.compose.ui.window.DialogProperties +import androidx.core.content.ContextCompat +import androidx.core.location.LocationManagerCompat +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.lifecycle.repeatOnLifecycle + +@Composable +internal fun WifiScreen(viewModel: WifiVM = hiltViewModel()) { + val context = LocalContext.current + val wifiManager = remember { context.getSystemService(WifiManager::class.java) } + + val state by viewModel.state.collectAsStateWithLifecycle() + var hasLocationPermission by remember { mutableStateOf(context.hasLocationPermission()) } + + val lifecycle = LocalLifecycleOwner.current.lifecycle + val locationManager = remember { context.getSystemService(LocationManager::class.java) } + LaunchedEffect(Unit) { + lifecycle.repeatOnLifecycle(Lifecycle.State.RESUMED) { + viewModel.postEvent( + Event.UpdateLocationEnabled( + LocationManagerCompat.isLocationEnabled(locationManager), + ), + ) + viewModel.postEvent(Event.UpdateWifiEnabled(wifiManager.isWifiEnabled)) + } + } + + fun sendScanResults() { + try { + viewModel.postEvent(Event.WifiResultsScanned(wifiManager.scanResults)) + } catch (e: SecurityException) { + Log.e("WifiScreen", "Failed to get scan results", e) + } + } + + DisposableWifiScanCallback( + context = context, + wifiManager = wifiManager, + onReceive = ::sendScanResults, + ) + + LaunchedEffect(state.isWifiEnabled, state.isLocationEnabled, hasLocationPermission) { + sendScanResults() + // Just location can work + if (hasLocationPermission && state.isLocationEnabled) { + wifiManager.startScan() + } + } + + AddWifiSuggestion( + wifiSuggestions = state.wifiSuggestions, + wifiManager = wifiManager, + onDone = { viewModel.postEvent(Event.ClearWifiEvents) }, + ) + + ConnectToWifi( + wifiToConnect = state.wifiToConnect, + context = context, + onDone = { viewModel.postEvent(Event.ClearWifiEvents) }, + ) + + WifiScreen( + state = state, + startActivity = { context.startActivity(it) }, + postEvent = viewModel::postEvent, + ) + + if (!hasLocationPermission) { + LocationPermissionDialog( + onSuccess = { hasLocationPermission = true }, + onFailure = { + viewModel.postEvent(Event.PopBack) + Toast.makeText( + context, + "Precise location permission was not granted", + Toast.LENGTH_LONG, + ).show() + }, + ) + } +} + +@Composable +private fun LocationPermissionDialog(onSuccess: () -> Unit, onFailure: () -> Unit) { + val launchRequest = rememberLauncherForActivityResult( + ActivityResultContracts.RequestPermission(), + ) { isGranted -> + if (isGranted) { + onSuccess() + } else { + onFailure() + } + } + + Dialog( + onDismissRequest = onFailure, + properties = DialogProperties(dismissOnClickOutside = false), + ) { + Card { + Column( + modifier = Modifier.padding(16.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + val title = buildAnnotatedString { + withStyle(SpanStyle(fontWeight = FontWeight.SemiBold)) { + append("Precise location ") + } + append("permission is required to access wifi scanning") + } + Text(text = title, fontSize = 18.sp, textAlign = TextAlign.Center) + Spacer(modifier = Modifier.height(16.dp)) + + Row { + OutlinedButton(onClick = onFailure, modifier = Modifier.weight(1f)) { + Text(text = "Leave") + } + Spacer(modifier = Modifier.width(16.dp)) + Button( + onClick = { + launchRequest.launch(Manifest.permission.ACCESS_FINE_LOCATION) + }, + modifier = Modifier.weight(1f), + ) { + Text(text = "Grant") + } + } + } + } + } +} + +@Composable +private fun WifiScreen( + state: State, + startActivity: (Intent) -> Unit, + postEvent: (Event) -> Unit, +) { + var clickedWifiItem by remember { mutableStateOf(null) } + + LazyColumn( + horizontalAlignment = Alignment.CenterHorizontally, + modifier = Modifier.fillMaxSize(), + ) { + if (!state.isWifiEnabled) { + item { + Text(text = "Wifi is not enabled", modifier = Modifier.padding(8.dp)) + Button( + onClick = { startActivity(Intent(Settings.ACTION_WIFI_SETTINGS)) }, + modifier = Modifier.padding(8.dp), + ) { + Text(text = "Enable wifi") + } + } + } + + if (!state.isLocationEnabled) { + item { + Text(text = "Location is not enabled", modifier = Modifier.padding(8.dp)) + Button( + onClick = { startActivity(Intent(Settings.ACTION_LOCATION_SOURCE_SETTINGS)) }, + modifier = Modifier.padding(8.dp), + ) { + Text(text = "Enable location") + } + } + } + + items(items = state.wifiNetworks) { + WifiNetworkItem(state = it, onClick = { clickedWifiItem = it }) + } + } + + clickedWifiItem?.let { + WifiItemMenu( + onDismiss = { clickedWifiItem = null }, + onSave = { postEvent(Event.SaveWifi(it)) }, + onConnect = { postEvent(Event.ConnectWifi(it)) }, + ) + } +} + +@Composable +private fun WifiNetworkItem(state: NetworkState, onClick: () -> Unit) = Card( + onClick = onClick, + modifier = Modifier + .padding(2.dp) + .fillMaxWidth(), +) { + Row( + modifier = Modifier.padding(all = 16.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(16.dp), + ) { + Column(modifier = Modifier.weight(1f)) { + Text( + text = state.ssid, + fontWeight = FontWeight.SemiBold, + fontSize = 18.sp, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + if (state.channel != -1) { + Text(text = "Channel ${state.channel}", fontSize = 14.sp) + } + } + + if (state.bandGhz.isNotEmpty() && state.channelWidthMhz != -1) { + Column(horizontalAlignment = Alignment.CenterHorizontally) { + Text(text = "${state.bandGhz} GHz") + Text(text = "${state.channelWidthMhz} MHz", fontSize = 14.sp) + } + } + + Text(text = "${state.level}") + } +} + +@Composable +private fun WifiItemMenu( + onDismiss: () -> Unit, + onSave: () -> Unit, + onConnect: () -> Unit, +) { + @Composable + fun Entry(text: String, onClick: () -> Unit) { + Text( + text = text, + modifier = Modifier + .fillMaxWidth() + .clickable { + onClick() + onDismiss() + } + .padding(all = 16.dp), + ) + } + + Dialog(onDismissRequest = onDismiss) { + Card { + Column { + Entry("Save this network") { onSave() } + Entry("Connect as IoT") { onConnect() } + } + } + } +} + +@Composable +private fun DisposableWifiScanCallback( + context: Context, + wifiManager: WifiManager, + onReceive: () -> Unit, +) { + val onReceiveUpdated by rememberUpdatedState(onReceive) + DisposableEffect(context, wifiManager) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { + val callback = object : ScanResultsCallback() { + override fun onScanResultsAvailable() { + onReceiveUpdated() + } + } + wifiManager.registerScanResultsCallback( + ContextCompat.getMainExecutor(context), + callback, + ) + + onDispose { + wifiManager.unregisterScanResultsCallback(callback) + } + } else { + val wifiScanReceiver = object : BroadcastReceiver() { + override fun onReceive(context: Context, intent: Intent) { + onReceiveUpdated() + } + } + val intentFilter = IntentFilter(WifiManager.SCAN_RESULTS_AVAILABLE_ACTION) + context.registerReceiver(wifiScanReceiver, intentFilter) + + onDispose { + context.unregisterReceiver(wifiScanReceiver) + } + } + } +} + +// Because ArrayList is not mutated, and is used as immutable +@Suppress("ktlint:compose:mutable-params-check") +@Composable +private fun AddWifiSuggestion( + wifiSuggestions: ArrayList?, + wifiManager: WifiManager, + onDone: () -> Unit, +) { + val onDoneUpdated by rememberUpdatedState(onDone) + val launchAddWifi = rememberLauncherForActivityResult( + ActivityResultContracts.StartActivityForResult(), + ) { activityResult -> + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.R) return@rememberLauncherForActivityResult + + val result = activityResult.data + ?.getIntegerArrayListExtra(Settings.EXTRA_WIFI_NETWORK_RESULT_LIST) + .orEmpty() + + if (result.any { + it == Settings.ADD_WIFI_RESULT_SUCCESS || + it == Settings.ADD_WIFI_RESULT_ALREADY_EXISTS + } + ) { + wifiManager.addNetworkSuggestions(wifiSuggestions.orEmpty()) + } + onDoneUpdated() + } + + LaunchedEffect(wifiSuggestions) { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) return@LaunchedEffect + if (wifiSuggestions.isNullOrEmpty()) return@LaunchedEffect + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { + val bundle = Bundle() + bundle.putParcelableArrayList(Settings.EXTRA_WIFI_NETWORK_LIST, wifiSuggestions) + launchAddWifi.launch(Intent(Settings.ACTION_WIFI_ADD_NETWORKS).putExtras(bundle)) + } else { + // Requires only API Q (29) + wifiManager.addNetworkSuggestions(wifiSuggestions) + onDoneUpdated() + } + } +} + +@Composable +private fun ConnectToWifi( + context: Context, + wifiToConnect: NetworkRequest?, + onDone: () -> Unit, +) { + val onDoneUpdated by rememberUpdatedState(onDone) + val connectivityManager = remember { context.getSystemService(ConnectivityManager::class.java) } + LaunchedEffect(wifiToConnect) { + if (wifiToConnect == null) return@LaunchedEffect + + connectivityManager.requestNetwork( + wifiToConnect, + ConnectivityManager.NetworkCallback(), + ) + onDoneUpdated() + } +} + +private fun Context.hasLocationPermission() = ContextCompat.checkSelfPermission( + this, + Manifest.permission.ACCESS_FINE_LOCATION, +) == PackageManager.PERMISSION_GRANTED diff --git a/feature/homeImpl/src/main/java/com/featuremodule/homeImpl/wifi/WifiVM.kt b/feature/homeImpl/src/main/java/com/featuremodule/homeImpl/wifi/WifiVM.kt new file mode 100644 index 0000000..bf6d84b --- /dev/null +++ b/feature/homeImpl/src/main/java/com/featuremodule/homeImpl/wifi/WifiVM.kt @@ -0,0 +1,99 @@ +package com.featuremodule.homeImpl.wifi + +import android.net.MacAddress +import android.net.NetworkCapabilities +import android.net.NetworkRequest +import android.net.wifi.ScanResult +import android.net.wifi.WifiNetworkSpecifier +import android.net.wifi.WifiNetworkSuggestion +import android.os.Build +import androidx.annotation.RequiresApi +import com.featuremodule.core.navigation.NavCommand +import com.featuremodule.core.navigation.NavManager +import com.featuremodule.core.ui.BaseVM +import com.featuremodule.core.util.WifiUtils +import dagger.hilt.android.lifecycle.HiltViewModel +import javax.inject.Inject + +@HiltViewModel +internal class WifiVM @Inject constructor( + private val navManager: NavManager, +) : BaseVM() { + override fun initialState() = State() + + override fun handleEvent(event: Event) { + when (event) { + is Event.WifiResultsScanned -> setState { + copy( + wifiNetworks = event.result + .map { it.toNetworkState() } + .sortedByDescending { it.level }, + ) + } + + is Event.SaveWifi -> if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + saveAndSuggestWifi(event.network) + } + + is Event.ConnectWifi -> if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + connectToIotWifi(event.network) + } + + Event.ClearWifiEvents -> setState { copy(wifiToConnect = null, wifiSuggestions = null) } + is Event.UpdateLocationEnabled -> setState { copy(isLocationEnabled = event.enabled) } + is Event.UpdateWifiEnabled -> setState { copy(isWifiEnabled = event.enabled) } + Event.PopBack -> launch { + navManager.navigate(NavCommand.PopBack) + } + } + } + + private fun ScanResult.toNetworkState() = NetworkState( + ssid = SSID, + bssid = BSSID, + bandGhz = toBand(frequency), + channel = WifiUtils.convertFrequencyMhzToChannelIfSupported(frequency), + channelWidthMhz = when (channelWidth) { + ScanResult.CHANNEL_WIDTH_20MHZ -> 20 + ScanResult.CHANNEL_WIDTH_40MHZ -> 40 + ScanResult.CHANNEL_WIDTH_80MHZ, ScanResult.CHANNEL_WIDTH_80MHZ_PLUS_MHZ -> 80 + ScanResult.CHANNEL_WIDTH_160MHZ -> 160 + ScanResult.CHANNEL_WIDTH_320MHZ -> 320 + else -> -1 + }, + level = level, + ) + + @RequiresApi(Build.VERSION_CODES.Q) + private fun connectToIotWifi(network: NetworkState) { + val specifier = WifiNetworkSpecifier.Builder() + .setSsid(network.ssid) + .setBssid(MacAddress.fromString(network.bssid)) + .build() + val request = NetworkRequest.Builder() + .addTransportType(NetworkCapabilities.TRANSPORT_WIFI) + .setNetworkSpecifier(specifier) + .build() + setState { copy(wifiToConnect = request) } + } + + @RequiresApi(Build.VERSION_CODES.Q) + private fun saveAndSuggestWifi(network: NetworkState) { + val suggestions = arrayListOf( + WifiNetworkSuggestion.Builder() + .setSsid(network.ssid) + .setBssid(MacAddress.fromString(network.bssid)) + .build(), + ) + + setState { copy(wifiSuggestions = suggestions) } + } + + private fun toBand(frequency: Int) = when { + WifiUtils.is24GHz(frequency) -> "2.4" + WifiUtils.is5GHz(frequency) -> "5" + WifiUtils.is6GHz(frequency) -> "6" + WifiUtils.is60GHz(frequency) -> "60" + else -> "" + } +}