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 -> ""
+ }
+}