diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 6dff163d..3a958dac 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -44,10 +44,12 @@ android { dependencies { implementation(project(":feature:ble")) implementation(project(":feature:softap")) + implementation(project(":feature:nfc")) implementation(project(":feature:common")) implementation(project(":feature:ui")) implementation(project(":lib:ble")) implementation(project(":lib:softap")) + implementation(project(":feature:ui")) implementation(libs.androidx.compose.ui) diff --git a/app/src/main/java/no/nordicsemi/android/wifi/provisioner/HomeScreen.kt b/app/src/main/java/no/nordicsemi/android/wifi/provisioner/HomeScreen.kt index 953339a7..3bad8e01 100644 --- a/app/src/main/java/no/nordicsemi/android/wifi/provisioner/HomeScreen.kt +++ b/app/src/main/java/no/nordicsemi/android/wifi/provisioner/HomeScreen.kt @@ -31,11 +31,15 @@ package no.nordicsemi.android.wifi.provisioner +import android.content.Context +import android.content.res.Configuration import android.os.Build import androidx.compose.foundation.Image import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.WindowInsetsSides import androidx.compose.foundation.layout.consumeWindowInsets @@ -44,9 +48,11 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.only import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.safeDrawing +import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.layout.windowInsetsPadding -import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold @@ -58,21 +64,24 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalConfiguration import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import androidx.hilt.navigation.compose.hiltViewModel +import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.launch +import no.nordicsemi.android.common.navigation.DestinationId import no.nordicsemi.android.common.navigation.viewmodel.SimpleNavigationViewModel import no.nordicsemi.android.common.theme.view.NordicAppBar import no.nordicsemi.android.wifi.provisioner.app.BuildConfig import no.nordicsemi.android.wifi.provisioner.app.R -import no.nordicsemi.android.wifi.provisioner.ble.sections.ProvisionOverBleSection import no.nordicsemi.android.wifi.provisioner.ble.view.BleDestination -import no.nordicsemi.android.wifi.provisioner.softap.view.ProvisionOverWifiSection +import no.nordicsemi.android.wifi.provisioner.feature.nfc.NfcDestination import no.nordicsemi.android.wifi.provisioner.softap.view.SoftApDestination +import no.nordicsemi.android.wifi.provisioner.ui.view.section.ProvisionSection @OptIn(ExperimentalMaterial3Api::class) @Composable @@ -81,6 +90,10 @@ fun HomeScreen() { val context = LocalContext.current val scope = rememberCoroutineScope() val snackbarHostState = remember { SnackbarHostState() } + val isLargeScreen = + LocalConfiguration.current.screenLayout and Configuration.SCREENLAYOUT_SIZE_MASK >= Configuration.SCREENLAYOUT_SIZE_LARGE + val isLandscape = + LocalConfiguration.current.orientation == Configuration.ORIENTATION_LANDSCAPE Scaffold( contentWindowInsets = WindowInsets(0, 0, 0, 0), topBar = { @@ -92,59 +105,199 @@ fun HomeScreen() { SnackbarHost(hostState = snackbarHostState) }, ) { innerPadding -> + + when { + !isLargeScreen && isLandscape -> { + SmallScreenLandscapeContent( + context = context, + scope = scope, + snackbarHostState = snackbarHostState, + innerPadding = innerPadding, + navigateTo = vm::navigateTo + ) + } + + else -> { + PortraitContent( + context = context, + scope = scope, + snackbarHostState = snackbarHostState, + innerPadding = innerPadding, + navigateTo = vm::navigateTo + ) + } + } + } +} + +@Composable +private fun PortraitContent( + context: Context, + scope: CoroutineScope, + snackbarHostState: SnackbarHostState, + innerPadding: PaddingValues, + navigateTo: (DestinationId) -> Unit +) { + Column( + modifier = Modifier + .fillMaxSize() + .verticalScroll(state = rememberScrollState()) + .padding(innerPadding) + .padding(bottom = 56.dp) + .consumeWindowInsets(innerPadding) + .windowInsetsPadding( + WindowInsets.safeDrawing.only( + WindowInsetsSides.Horizontal, + ), + ), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Image( + painter = painterResource(id = R.drawable.ic_nrf70), + contentDescription = stringResource(id = R.string.ic_nrf70), + modifier = Modifier + .widthIn(max = 200.dp) + .weight(0.5f, fill = true) + .padding(8.dp) + ) Column( modifier = Modifier - .fillMaxSize() - .padding(innerPadding) - .consumeWindowInsets(innerPadding) - .windowInsetsPadding( - WindowInsets.safeDrawing.only( - WindowInsetsSides.Horizontal, - ), - ), - horizontalAlignment = Alignment.CenterHorizontally + .fillMaxWidth() + .padding(horizontal = 16.dp) + .padding(bottom = 16.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center ) { - Row( - modifier = Modifier - .fillMaxWidth() - .weight(0.4f, true), - horizontalArrangement = Arrangement.Center, - verticalAlignment = Alignment.CenterVertically - ) { - Image( - painter = painterResource(id = R.drawable.ic_nrf70), - contentDescription = stringResource(id = R.string.ic_nrf70), - modifier = Modifier - .widthIn(max = 200.dp) - .padding(8.dp) - ) - } - Column( - modifier = Modifier - .fillMaxWidth() - .weight(0.4f, true), - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.Center + Spacer(modifier = Modifier.size(16.dp)) + ProvisionSection( + sectionTitle = stringResource(R.string.provision_over_ble), + sectionRational = stringResource(R.string.provision_over_ble_rationale), + onClick = { navigateTo(BleDestination) } + ) + Spacer(modifier = Modifier.size(16.dp)) + ProvisionSection( + sectionTitle = stringResource(R.string.provision_over_wifi), + sectionRational = stringResource(R.string.provision_over_wifi_rationale), + onClick = { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + navigateTo(SoftApDestination) + } else { + scope.launch { + snackbarHostState.showSnackbar( + message = context.getString(R.string.error_softap_not_supported), + actionLabel = context.getString(no.nordicsemi.android.wifi.provisioner.ui.R.string.dismiss) + ) + } + } + } + ) + Spacer(modifier = Modifier.size(16.dp)) + ProvisionSection( + sectionTitle = stringResource(R.string.provision_over_nfc), + sectionRational = stringResource(R.string.provision_over_nfc_rationale) ) { - ProvisionOverBleSection { - vm.navigateTo(BleDestination) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { + navigateTo(NfcDestination) + } else { + scope.launch { + snackbarHostState.showSnackbar( + message = context.getString(R.string.error_nfc_not_supported), + actionLabel = context.getString(no.nordicsemi.android.wifi.provisioner.ui.R.string.dismiss) + ) + } } - ProvisionOverWifiSection { + } + } + Text( + text = stringResource( + id = R.string.app_version, + BuildConfig.VERSION_NAME, + BuildConfig.VERSION_CODE + ), + textAlign = TextAlign.End, + style = MaterialTheme.typography.labelMedium + ) + } +} + +@Composable +private fun SmallScreenLandscapeContent( + context: Context, + scope: CoroutineScope, + snackbarHostState: SnackbarHostState, + innerPadding: PaddingValues, + navigateTo: (DestinationId) -> Unit +) { + Row( + modifier = Modifier + .fillMaxSize() + .padding(innerPadding) + .padding(horizontal = 16.dp) + .consumeWindowInsets(innerPadding) + .windowInsetsPadding( + WindowInsets.safeDrawing.only( + WindowInsetsSides.Horizontal, + ), + ), + verticalAlignment = Alignment.CenterVertically, + ) { + Column { + Image( + modifier = Modifier.padding(horizontal = 56.dp), + painter = painterResource(id = R.drawable.ic_nrf70), + contentDescription = stringResource(id = R.string.ic_nrf70), + ) + } + Column( + modifier = Modifier + .weight(1f) + .verticalScroll(state = rememberScrollState()) + ) { + Spacer(modifier = Modifier.size(16.dp)) + ProvisionSection( + sectionTitle = stringResource(R.string.provision_over_ble), + sectionRational = stringResource(R.string.provision_over_ble_rationale), + onClick = { navigateTo(BleDestination) } + ) + Spacer(modifier = Modifier.size(16.dp)) + ProvisionSection( + sectionTitle = stringResource(R.string.provision_over_wifi), + sectionRational = stringResource(R.string.provision_over_wifi_rationale), + onClick = { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { - vm.navigateTo(SoftApDestination) + navigateTo(SoftApDestination) } else { scope.launch { snackbarHostState.showSnackbar( message = context.getString(R.string.error_softap_not_supported), - actionLabel = context.getString(R.string.dismiss) + actionLabel = context.getString(no.nordicsemi.android.wifi.provisioner.ui.R.string.dismiss) ) } } } + ) + Spacer(modifier = Modifier.size(16.dp)) + ProvisionSection( + sectionTitle = stringResource(R.string.provision_over_nfc), + sectionRational = stringResource(R.string.provision_over_nfc_rationale) + ) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { + navigateTo(NfcDestination) + } else { + scope.launch { + snackbarHostState.showSnackbar( + message = context.getString(R.string.error_nfc_not_supported), + actionLabel = context.getString(no.nordicsemi.android.wifi.provisioner.ui.R.string.dismiss) + ) + } + } } + Spacer(modifier = Modifier.size(16.dp)) Row( - modifier = Modifier.fillMaxWidth().padding(bottom = 16.dp), - horizontalArrangement = Arrangement.Center, + modifier = Modifier + .fillMaxWidth() + .padding(end = 8.dp), + horizontalArrangement = Arrangement.End, ) { Text( text = stringResource( diff --git a/app/src/main/java/no/nordicsemi/android/wifi/provisioner/MainActivity.kt b/app/src/main/java/no/nordicsemi/android/wifi/provisioner/MainActivity.kt index c840937f..9a2a4f07 100644 --- a/app/src/main/java/no/nordicsemi/android/wifi/provisioner/MainActivity.kt +++ b/app/src/main/java/no/nordicsemi/android/wifi/provisioner/MainActivity.kt @@ -45,6 +45,7 @@ import no.nordicsemi.android.common.navigation.NavigationView import no.nordicsemi.android.common.theme.NordicActivity import no.nordicsemi.android.common.theme.NordicTheme import no.nordicsemi.android.wifi.provisioner.ble.view.BleProvisioningDestinations +import no.nordicsemi.android.wifi.provisioner.feature.nfc.NfcProvisionerDestinations import no.nordicsemi.android.wifi.provisioner.softap.view.SoftApProvisionerDestinations @AndroidEntryPoint @@ -61,13 +62,20 @@ class MainActivity : NordicActivity() { NavigationView( destinations = (HomeDestination + BleProvisioningDestinations).run { - // Soft AP is available on Android 10 and newer. - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { - this + SoftApProvisionerDestinations - } else { - this - } - } + // Soft AP is available on Android 10 and newer. + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + this + SoftApProvisionerDestinations + } else { + this + } + }.run { + // NFC is available on Android 6.0 and newer. + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { + this + NfcProvisionerDestinations + } else { + this + } + } ) } } diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 48f49967..0b222e9d 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -32,91 +32,22 @@ nRF Wi-Fi Provisioner - Change An icon of nRF70 Series - Device status - Provisioning data - Upload status - - Unknown error - - Start - Next device - Finish - Set password - Password - **** **** - Dismiss - Accept - Clear - - Device status - Disconnected - Wi-Fi status - Start provisioning - Version - Scanning error - Unprovision - Select password - Provision - Provisioning status - Unrovisioning status - Success - - - Authentication - Association - Obtaining IP - Connected - Disconnected - Unprovisioned - Error occurred during provisioning. - Icon indicating wifi and it\'s authentication method. - - Wi-Fi - - IPv4: %s - SSID: %s - BSSID: %s - Band: %s - Band: %s, Channel: %s - Channel: %s - 2.4 GHz - 5 GHz - Any - - Authentication error. - The specified network could not be find. - Timeout occurred. - Could not obtain IP from provided provisioning information. - Could not connect to provisioned network. - - Connection info - Wi-Fi info - - Scan params - Passive: %s - Period: %s [ms] - Group channels: %s - - Hide password - Show password - - Authentication - Association - Obtaining IP - Result - Connected - Connection failed - - V %d - - Persistent storage - - Sort by: - Name - RSSI This feature requires an Android device running Android 10 or above. Version: %s (%s) + + Provision over Bluetooth LE + This mode uses secure Bluetooth LE link to transfer + Wi-Fi credentials to the provisionee and verify provisioning status. + + Provision over Wi-Fi + This mode uses temporary a Wi-Fi network (SoftAP) + created by the provisionee to send Wi-Fi credentials. Communication is encrypted using TLS. + + Provision over NFC + This mode allows provisioning a nRF700x device to a + Wi-Fi network by providing the Wi-Fi credentials via NFC. + + This feature requires an Android device running Android 6.0 or above. \ No newline at end of file diff --git a/build.gradle.kts b/build.gradle.kts index 7c56c389..25067335 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -44,4 +44,5 @@ plugins { alias(libs.plugins.nordic.feature) apply false alias(libs.plugins.kotlin.android) apply false id("org.jetbrains.kotlin.jvm") version "1.9.21" apply false + alias(libs.plugins.kotlin.parcelize) apply false } diff --git a/feature/ble/src/main/java/no/nordicsemi/android/wifi/provisioner/ble/sections/ActionButtonSection.kt b/feature/ble/src/main/java/no/nordicsemi/android/wifi/provisioner/ble/sections/ActionButtonSection.kt index 82ac4a99..3539dd43 100644 --- a/feature/ble/src/main/java/no/nordicsemi/android/wifi/provisioner/ble/sections/ActionButtonSection.kt +++ b/feature/ble/src/main/java/no/nordicsemi/android/wifi/provisioner/ble/sections/ActionButtonSection.kt @@ -44,7 +44,7 @@ import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import no.nordicsemi.android.wifi.provisioner.ble.view.BleViewEntity import no.nordicsemi.android.wifi.provisioner.ble.view.OnUnprovisionEvent -import no.nordicsemi.android.wifi.provisioner.ui.R +import no.nordicsemi.android.wifi.provisioner.feature.ble.R import no.nordicsemi.kotlin.wifi.provisioner.feature.common.event.OnFinishedEvent import no.nordicsemi.kotlin.wifi.provisioner.feature.common.event.OnProvisionClickEvent import no.nordicsemi.kotlin.wifi.provisioner.feature.common.event.OnProvisionNextDeviceEvent diff --git a/feature/ble/src/main/java/no/nordicsemi/android/wifi/provisioner/ble/sections/DeviceSection.kt b/feature/ble/src/main/java/no/nordicsemi/android/wifi/provisioner/ble/sections/DeviceSection.kt index 335d8165..816c46b9 100644 --- a/feature/ble/src/main/java/no/nordicsemi/android/wifi/provisioner/ble/sections/DeviceSection.kt +++ b/feature/ble/src/main/java/no/nordicsemi/android/wifi/provisioner/ble/sections/DeviceSection.kt @@ -38,8 +38,8 @@ import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.Preview import no.nordicsemi.android.common.theme.NordicTheme +import no.nordicsemi.android.wifi.provisioner.feature.ble.R import no.nordicsemi.android.wifi.provisioner.ui.ClickableDataItem -import no.nordicsemi.android.wifi.provisioner.ui.R import no.nordicsemi.kotlin.wifi.provisioner.feature.common.event.OnProvisionNextDeviceEvent import no.nordicsemi.kotlin.wifi.provisioner.feature.common.event.OnSelectDeviceClickEvent import no.nordicsemi.kotlin.wifi.provisioner.feature.common.event.ProvisioningViewEvent diff --git a/feature/ble/src/main/java/no/nordicsemi/android/wifi/provisioner/ble/sections/DisconnectedDeviceStatus.kt b/feature/ble/src/main/java/no/nordicsemi/android/wifi/provisioner/ble/sections/DisconnectedDeviceStatus.kt index c385c7e6..a96fa0f3 100644 --- a/feature/ble/src/main/java/no/nordicsemi/android/wifi/provisioner/ble/sections/DisconnectedDeviceStatus.kt +++ b/feature/ble/src/main/java/no/nordicsemi/android/wifi/provisioner/ble/sections/DisconnectedDeviceStatus.kt @@ -36,7 +36,7 @@ import androidx.compose.material.icons.outlined.LinkOff import androidx.compose.runtime.Composable import androidx.compose.ui.res.stringResource import no.nordicsemi.android.wifi.provisioner.ui.DataItem -import no.nordicsemi.android.wifi.provisioner.ui.R +import no.nordicsemi.android.wifi.provisioner.feature.ble.R @Composable fun DisconnectedDeviceStatus() { diff --git a/feature/ble/src/main/java/no/nordicsemi/android/wifi/provisioner/ble/sections/PasswordSection.kt b/feature/ble/src/main/java/no/nordicsemi/android/wifi/provisioner/ble/sections/PasswordSection.kt index bea0d990..996d147c 100644 --- a/feature/ble/src/main/java/no/nordicsemi/android/wifi/provisioner/ble/sections/PasswordSection.kt +++ b/feature/ble/src/main/java/no/nordicsemi/android/wifi/provisioner/ble/sections/PasswordSection.kt @@ -38,7 +38,7 @@ import androidx.compose.ui.res.stringResource import no.nordicsemi.kotlin.wifi.provisioner.feature.common.event.ProvisioningViewEvent import no.nordicsemi.kotlin.wifi.provisioner.feature.common.event.OnShowPasswordDialog import no.nordicsemi.android.wifi.provisioner.ui.ClickableDataItem -import no.nordicsemi.android.wifi.provisioner.ui.R +import no.nordicsemi.android.wifi.provisioner.feature.ble.R @Composable fun PasswordSection(isEditable: Boolean = false, onEvent: (ProvisioningViewEvent) -> Unit) { diff --git a/feature/ble/src/main/java/no/nordicsemi/android/wifi/provisioner/ble/sections/ProvisionOverBleSection.kt b/feature/ble/src/main/java/no/nordicsemi/android/wifi/provisioner/ble/sections/ProvisionOverBleSection.kt deleted file mode 100644 index d60c62f8..00000000 --- a/feature/ble/src/main/java/no/nordicsemi/android/wifi/provisioner/ble/sections/ProvisionOverBleSection.kt +++ /dev/null @@ -1,40 +0,0 @@ -package no.nordicsemi.android.wifi.provisioner.ble.sections - -import androidx.compose.foundation.clickable -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.layout.widthIn -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.OutlinedCard -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.ui.Modifier -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.unit.dp -import no.nordicsemi.android.wifi.provisioner.feature.ble.R -import no.nordicsemi.android.wifi.provisioner.ui.view.section.SectionTitle - - -@Composable -fun ProvisionOverBleSection(onClick: () -> Unit) { - OutlinedCard( - modifier = Modifier - .widthIn(max = 600.dp) - .padding(all = 8.dp) - .clickable(onClick = onClick) - ) { - Column(modifier = Modifier.padding(all = 16.dp)) { - SectionTitle(text = stringResource( - R.string.provision_over_ble) - ) - Spacer(modifier = Modifier.size(8.dp)) - Text( - text = stringResource(R.string.provision_over_ble_rationale), - style = MaterialTheme.typography.bodyMedium - ) - } - } -} \ No newline at end of file diff --git a/feature/ble/src/main/java/no/nordicsemi/android/wifi/provisioner/ble/sections/ProvisioningSection.kt b/feature/ble/src/main/java/no/nordicsemi/android/wifi/provisioner/ble/sections/ProvisioningSection.kt index 7032c474..b4c80d66 100644 --- a/feature/ble/src/main/java/no/nordicsemi/android/wifi/provisioner/ble/sections/ProvisioningSection.kt +++ b/feature/ble/src/main/java/no/nordicsemi/android/wifi/provisioner/ble/sections/ProvisioningSection.kt @@ -49,7 +49,7 @@ import no.nordicsemi.android.common.theme.view.ProgressItemStatus import no.nordicsemi.kotlin.wifi.provisioner.domain.WifiConnectionStateDomain import no.nordicsemi.android.wifi.provisioner.ui.DataItem import no.nordicsemi.android.wifi.provisioner.ui.LoadingItem -import no.nordicsemi.android.wifi.provisioner.ui.R +import no.nordicsemi.android.wifi.provisioner.feature.ble.R import no.nordicsemi.android.wifi.provisioner.ble.view.toDisplayString import no.nordicsemi.kotlin.wifi.provisioner.domain.resource.Error import no.nordicsemi.kotlin.wifi.provisioner.domain.resource.Loading diff --git a/feature/ble/src/main/java/no/nordicsemi/android/wifi/provisioner/ble/sections/UnprovisioningSection.kt b/feature/ble/src/main/java/no/nordicsemi/android/wifi/provisioner/ble/sections/UnprovisioningSection.kt index 6c1a450e..0d10dc20 100644 --- a/feature/ble/src/main/java/no/nordicsemi/android/wifi/provisioner/ble/sections/UnprovisioningSection.kt +++ b/feature/ble/src/main/java/no/nordicsemi/android/wifi/provisioner/ble/sections/UnprovisioningSection.kt @@ -41,7 +41,7 @@ import androidx.compose.ui.unit.dp import no.nordicsemi.android.wifi.provisioner.ui.DataItem import no.nordicsemi.android.wifi.provisioner.ui.ErrorDataItem import no.nordicsemi.android.wifi.provisioner.ui.LoadingItem -import no.nordicsemi.android.wifi.provisioner.ui.R +import no.nordicsemi.android.wifi.provisioner.feature.ble.R import no.nordicsemi.kotlin.wifi.provisioner.domain.resource.Loading import no.nordicsemi.kotlin.wifi.provisioner.domain.resource.Resource import no.nordicsemi.kotlin.wifi.provisioner.domain.resource.Success @@ -56,11 +56,10 @@ fun UnprovisioningSection(status: Resource) { } } - @Composable private fun ErrorItem(error: Throwable) { ErrorDataItem( - iconRes = R.drawable.ic_upload_wifi, + iconRes = no.nordicsemi.android.wifi.provisioner.ui.R.drawable.ic_upload_wifi, title = stringResource(id = R.string.unprovision_status), error = error ) diff --git a/feature/ble/src/main/java/no/nordicsemi/android/wifi/provisioner/ble/sections/WifiSection.kt b/feature/ble/src/main/java/no/nordicsemi/android/wifi/provisioner/ble/sections/WifiSection.kt index 2eaf412f..439fc0ad 100644 --- a/feature/ble/src/main/java/no/nordicsemi/android/wifi/provisioner/ble/sections/WifiSection.kt +++ b/feature/ble/src/main/java/no/nordicsemi/android/wifi/provisioner/ble/sections/WifiSection.kt @@ -39,7 +39,7 @@ import no.nordicsemi.kotlin.wifi.provisioner.feature.common.event.OnSelectWifiEv import no.nordicsemi.kotlin.wifi.provisioner.domain.ScanRecordDomain import no.nordicsemi.kotlin.wifi.provisioner.feature.common.WifiData import no.nordicsemi.android.wifi.provisioner.ui.ClickableDataItem -import no.nordicsemi.android.wifi.provisioner.ui.R +import no.nordicsemi.android.wifi.provisioner.feature.ble.R import no.nordicsemi.android.wifi.provisioner.ui.mapping.toImageVector @Composable diff --git a/feature/ble/src/main/java/no/nordicsemi/android/wifi/provisioner/ble/view/UiMapper.kt b/feature/ble/src/main/java/no/nordicsemi/android/wifi/provisioner/ble/view/UiMapper.kt index 36cfe829..13a3d6a7 100644 --- a/feature/ble/src/main/java/no/nordicsemi/android/wifi/provisioner/ble/view/UiMapper.kt +++ b/feature/ble/src/main/java/no/nordicsemi/android/wifi/provisioner/ble/view/UiMapper.kt @@ -41,7 +41,7 @@ import androidx.compose.runtime.Composable import androidx.compose.ui.res.stringResource import no.nordicsemi.kotlin.wifi.provisioner.domain.WifiConnectionFailureReasonDomain import no.nordicsemi.kotlin.wifi.provisioner.domain.WifiConnectionStateDomain -import no.nordicsemi.android.wifi.provisioner.ui.R +import no.nordicsemi.android.wifi.provisioner.feature.ble.R @Composable fun WifiConnectionStateDomain?.toImageVector() = when (this) { diff --git a/feature/ble/src/main/res/values/strings.xml b/feature/ble/src/main/res/values/strings.xml index da9c95d7..3648d623 100644 --- a/feature/ble/src/main/res/values/strings.xml +++ b/feature/ble/src/main/res/values/strings.xml @@ -117,8 +117,5 @@ Persistent storage - Provision over Bluetooth LE - This mode uses secure Bluetooth LE link to transfer - Wi-Fi credentials to the provisionee and verify provisioning status. - + \ No newline at end of file diff --git a/feature/nfc/build.gradle.kts b/feature/nfc/build.gradle.kts new file mode 100644 index 00000000..e988272a --- /dev/null +++ b/feature/nfc/build.gradle.kts @@ -0,0 +1,23 @@ +plugins { + alias(libs.plugins.nordic.feature) + alias(libs.plugins.nordic.hilt) +} + +android { + namespace = "no.nordicsemi.android.wifi.provisioner.feature.nfc" +} + +dependencies { + implementation(libs.androidx.core.ktx) + implementation(libs.androidx.activity.compose) + implementation(libs.nordic.theme) + implementation(libs.nordic.navigation) + implementation(libs.androidx.hilt.navigation.compose) + implementation(libs.androidx.compose.material.iconsExtended) + implementation(libs.androidx.lifecycle.runtime.compose) + implementation(project(":feature:common")) + implementation(project(":feature:ui")) + api(project(":lib:nfc")) + implementation(libs.nordic.permissions.nfc) + implementation("androidx.compose.material3:material3:1.3.0-beta02") +} \ No newline at end of file diff --git a/feature/nfc/src/main/AndroidManifest.xml b/feature/nfc/src/main/AndroidManifest.xml new file mode 100644 index 00000000..06a6b1da --- /dev/null +++ b/feature/nfc/src/main/AndroidManifest.xml @@ -0,0 +1,13 @@ + + + + + + + + + + \ No newline at end of file diff --git a/feature/nfc/src/main/java/no/nordicsemi/android/wifi/provisioner/feature/nfc/NfcDestinations.kt b/feature/nfc/src/main/java/no/nordicsemi/android/wifi/provisioner/feature/nfc/NfcDestinations.kt new file mode 100644 index 00000000..601b62fe --- /dev/null +++ b/feature/nfc/src/main/java/no/nordicsemi/android/wifi/provisioner/feature/nfc/NfcDestinations.kt @@ -0,0 +1,34 @@ +package no.nordicsemi.android.wifi.provisioner.feature.nfc + +import android.os.Build +import androidx.annotation.RequiresApi +import no.nordicsemi.android.common.navigation.createDestination +import no.nordicsemi.android.common.navigation.createSimpleDestination +import no.nordicsemi.android.common.navigation.defineDestination +import no.nordicsemi.android.wifi.provisioner.feature.nfc.view.NfcPublishScreen +import no.nordicsemi.android.wifi.provisioner.feature.nfc.view.HomeScreen +import no.nordicsemi.android.wifi.provisioner.feature.nfc.view.WifiScannerScreen +import no.nordicsemi.android.wifi.provisioner.nfc.domain.WifiData + +val NfcDestination = createSimpleDestination("nfc") +val WifiScannerDestination = + createSimpleDestination( + name = "wifi-scanner-destination", + ) + +val NfcPublishDestination = + createDestination("publish-destination") + + +@RequiresApi(Build.VERSION_CODES.M) +val NfcProvisionerDestinations = listOf( + defineDestination(NfcDestination) { + HomeScreen() + }, + defineDestination(WifiScannerDestination) { + WifiScannerScreen() + }, + defineDestination(NfcPublishDestination) { + NfcPublishScreen() + } +) \ No newline at end of file diff --git a/feature/nfc/src/main/java/no/nordicsemi/android/wifi/provisioner/feature/nfc/di/NfcAdapter.kt b/feature/nfc/src/main/java/no/nordicsemi/android/wifi/provisioner/feature/nfc/di/NfcAdapter.kt new file mode 100644 index 00000000..0b290d26 --- /dev/null +++ b/feature/nfc/src/main/java/no/nordicsemi/android/wifi/provisioner/feature/nfc/di/NfcAdapter.kt @@ -0,0 +1,29 @@ +package no.nordicsemi.android.wifi.provisioner.feature.nfc.di + +import android.content.Context +import android.nfc.NfcAdapter +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.android.qualifiers.ApplicationContext +import dagger.hilt.components.SingletonComponent +import no.nordicsemi.android.wifi.provisioner.nfc.NfcManagerForWifi +import javax.inject.Singleton + +@Module +@InstallIn(SingletonComponent::class) +object NfcAdapterModule { + + @Provides + @Singleton + fun provideNfcAdapter(@ApplicationContext context: Context): NfcAdapter { + return NfcAdapter.getDefaultAdapter(context) + } + + @Provides + @Singleton + fun provideNfcManagerForWifi(@ApplicationContext context: Context) = + NfcManagerForWifi( + nfcAdapter = provideNfcAdapter(context) + ) +} \ No newline at end of file diff --git a/feature/nfc/src/main/java/no/nordicsemi/android/wifi/provisioner/feature/nfc/di/WifiManagerRepositoryModule.kt b/feature/nfc/src/main/java/no/nordicsemi/android/wifi/provisioner/feature/nfc/di/WifiManagerRepositoryModule.kt new file mode 100644 index 00000000..8fda0c34 --- /dev/null +++ b/feature/nfc/src/main/java/no/nordicsemi/android/wifi/provisioner/feature/nfc/di/WifiManagerRepositoryModule.kt @@ -0,0 +1,26 @@ +package no.nordicsemi.android.wifi.provisioner.feature.nfc.di + +import android.content.Context +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.android.qualifiers.ApplicationContext +import dagger.hilt.components.SingletonComponent +import no.nordicsemi.android.wifi.provisioner.nfc.NdefMessageBuilder +import no.nordicsemi.android.wifi.provisioner.nfc.WifiManagerRepository +import javax.inject.Singleton + +@Module +@InstallIn(SingletonComponent::class) +object WifiManagerRepositoryModule { + + @Provides + @Singleton + fun provideNdefMessageBuilder() = NdefMessageBuilder() + + @Provides + @Singleton + fun provideWifiManagerRepository( + @ApplicationContext context: Context + ) = WifiManagerRepository(context) +} \ No newline at end of file diff --git a/feature/nfc/src/main/java/no/nordicsemi/android/wifi/provisioner/feature/nfc/mapping/Frequency.kt b/feature/nfc/src/main/java/no/nordicsemi/android/wifi/provisioner/feature/nfc/mapping/Frequency.kt new file mode 100644 index 00000000..0aded127 --- /dev/null +++ b/feature/nfc/src/main/java/no/nordicsemi/android/wifi/provisioner/feature/nfc/mapping/Frequency.kt @@ -0,0 +1,171 @@ +package no.nordicsemi.android.wifi.provisioner.feature.nfc.mapping + +/** + * Frequency utility class to provide frequency band information. + * Inspired from [here](https://cs.android.com/android/platform/superproject/+/master:packages/modules/Wifi/framework/java/android/net/wifi/ScanResult.java) + */ +object Frequency { + /** The unspecified value. */ + private const val UNSPECIFIED: Int = -1 + + /** 2.4 GHz band first channel number. */ + private const val BAND_24_GHZ_FIRST_CH_NUM: Int = 1 + + /** 2.4 GHz band frequency of first channel in MHz. */ + private const val BAND_24_GHZ_START_FREQ_MHZ: Int = 2412 + + /** 2.4 GHz band frequency of last channel in MHz. */ + private const val BAND_24_GHZ_END_FREQ_MHZ: Int = 2484 + + /** 5 GHz band first channel number. */ + private const val BAND_5_GHZ_FIRST_CH_NUM: Int = 32 + + /** 5 GHz band frequency of first channel in MHz. */ + private const val BAND_5_GHZ_START_FREQ_MHZ: Int = 5160 + + /** 5 GHz band frequency of last channel in MHz. */ + private const val BAND_5_GHZ_END_FREQ_MHZ: Int = 5885 + + /** 6 GHz band first channel number. */ + private const val BAND_6_GHZ_FIRST_CH_NUM: Int = 1 + + /** 6 GHz band frequency of first channel in MHz. */ + private const val BAND_6_GHZ_START_FREQ_MHZ: Int = 5955 + + /** 6 GHz band frequency of last channel in MHz. */ + private const val BAND_6_GHZ_END_FREQ_MHZ: Int = 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. + */ + private const val BAND_6_GHZ_PSC_START_MHZ: Int = 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. + */ + private const val BAND_6_GHZ_PSC_STEP_SIZE_MHZ: Int = 80 + + /** 6 GHz band operating class 136 channel 2 center frequency in MHz. */ + private const val BAND_6_GHZ_OP_CLASS_136_CH_2_FREQ_MHZ: Int = 5935 + + /** 60 GHz band first channel number. */ + private const val BAND_60_GHZ_FIRST_CH_NUM: Int = 1 + + /** 60 GHz band frequency of first channel in MHz. */ + private const val BAND_60_GHZ_START_FREQ_MHZ: Int = 58320 + + /** 60 GHz band frequency of last channel in MHz. */ + private const val BAND_60_GHZ_END_FREQ_MHZ: Int = 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 + */ + private fun is24GHz(freqMhz: Int): Boolean { + return freqMhz in BAND_24_GHZ_START_FREQ_MHZ..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 + */ + private fun is5GHz(freqMhz: Int): Boolean { + return freqMhz in BAND_5_GHZ_START_FREQ_MHZ..BAND_5_GHZ_END_FREQ_MHZ + } + + /** + * Utility function to check if a frequency within 6 GHz band. + * + * @param freqMhz + * @return true if within 6GHz, false otherwise + */ + private fun is6GHz(freqMhz: Int): Boolean { + if (freqMhz == BAND_6_GHZ_OP_CLASS_136_CH_2_FREQ_MHZ) { + return true + } + return (freqMhz in BAND_6_GHZ_START_FREQ_MHZ..BAND_6_GHZ_END_FREQ_MHZ) + } + + /** + * Utility function to check if a frequency is 6Ghz PSC channel. + * + * @param freqMhz + * @return true if the frequency is 6GHz PSC, false otherwise + */ + private fun is6GHzPsc(freqMhz: Int): Boolean { + 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 + * @param freqMhz + * @return true if within 60GHz, false otherwise + * + * @hide + */ + private fun is60GHz(freqMhz: Int): Boolean { + return freqMhz in BAND_60_GHZ_START_FREQ_MHZ..BAND_60_GHZ_END_FREQ_MHZ + } + + /** + * Utility function to get the frequency band of a given frequency. + * + * @param frequency frequency in MHz + * @return frequency band + */ + fun get(frequency: Int): String { + return when { + is24GHz(frequency) -> "2.4 GHz" + is5GHz(frequency) -> "5 GHz" + is6GHz(frequency) -> "6 GHz" + is6GHzPsc(frequency) -> "6 GHz PSC" + is60GHz(frequency) -> "60 GHz" + else -> "Unknown" + } + } + + /** + * Utility function to convert frequency in MHz to channel number. + * + * @param freqMhz frequency in MHz + * @return channel number associated with given frequency, [.UNSPECIFIED] if no match + */ + fun toChannelNumber(freqMhz: Int): Int { + when { + freqMhz == 2484 -> { + return 14 + } + + is24GHz(freqMhz) -> { + return (freqMhz - BAND_24_GHZ_START_FREQ_MHZ) / 5 + BAND_24_GHZ_FIRST_CH_NUM + } + + is5GHz(freqMhz) -> { + return ((freqMhz - BAND_5_GHZ_START_FREQ_MHZ) / 5) + BAND_5_GHZ_FIRST_CH_NUM + } + + 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 + } + + is60GHz(freqMhz) -> { + return ((freqMhz - BAND_60_GHZ_START_FREQ_MHZ) / 2160) + BAND_60_GHZ_FIRST_CH_NUM + } + + else -> return UNSPECIFIED + } + } + +} diff --git a/feature/nfc/src/main/java/no/nordicsemi/android/wifi/provisioner/feature/nfc/mapping/UiMapper.kt b/feature/nfc/src/main/java/no/nordicsemi/android/wifi/provisioner/feature/nfc/mapping/UiMapper.kt new file mode 100644 index 00000000..2ef36024 --- /dev/null +++ b/feature/nfc/src/main/java/no/nordicsemi/android/wifi/provisioner/feature/nfc/mapping/UiMapper.kt @@ -0,0 +1,80 @@ +package no.nordicsemi.android.wifi.provisioner.feature.nfc.mapping + +import no.nordicsemi.android.wifi.provisioner.nfc.domain.AuthenticationMode +import no.nordicsemi.android.wifi.provisioner.nfc.domain.EncryptionMode +import no.nordicsemi.android.wifi.provisioner.nfc.domain.WifiAuthTypeBelowTiramisu +import no.nordicsemi.android.wifi.provisioner.nfc.domain.WifiAuthTypeTiramisuOrAbove + +/** + * Converts the [AuthenticationMode] to a display string. + * + * @return The display string. + */ +fun AuthenticationMode.toDisplayString(): String = when (this) { + WifiAuthTypeBelowTiramisu.OPEN, WifiAuthTypeTiramisuOrAbove.OPEN -> "Open" + WifiAuthTypeBelowTiramisu.WEP, WifiAuthTypeTiramisuOrAbove.WEP -> "Shared" + WifiAuthTypeBelowTiramisu.WPA_PSK, WifiAuthTypeTiramisuOrAbove.WPA_PSK -> "WPA-Personal" + WifiAuthTypeBelowTiramisu.WPA2_PSK -> "WPA2-Personal" + WifiAuthTypeBelowTiramisu.WPA_WPA2_PSK -> "WPA/WPA2-Personal" + WifiAuthTypeBelowTiramisu.WPA2_EAP, WifiAuthTypeTiramisuOrAbove.WPA2_EAP -> "WPA2-Enterprise" + WifiAuthTypeBelowTiramisu.WPA3_PSK, WifiAuthTypeTiramisuOrAbove.WPA3_PSK -> "WPA3-Personal" + WifiAuthTypeTiramisuOrAbove.UNKNOWN -> "Unknown" + WifiAuthTypeTiramisuOrAbove.EAP_WPA3_ENTERPRISE_192_BIT -> "EAP-WPA3-Enterprise-192-Bit" + WifiAuthTypeTiramisuOrAbove.OWE -> "Opportunistic-Wireless-Encryption" + WifiAuthTypeTiramisuOrAbove.WAPI_PSK -> "WAPI-PSK" + WifiAuthTypeTiramisuOrAbove.WAPI_CERT -> "WAPI-Certificate" + WifiAuthTypeTiramisuOrAbove.EAP_WPA3_ENTERPRISE -> "EAP-WPA3-Enterprise" + WifiAuthTypeTiramisuOrAbove.OSEN -> "Hotspot-2" + WifiAuthTypeTiramisuOrAbove.PASSPOINT_R1_R2 -> "Passpoint-R1-R2" + WifiAuthTypeTiramisuOrAbove.PASSPOINT_R3 -> "Passpoint-R3" + WifiAuthTypeTiramisuOrAbove.DPP -> "DPP" + WifiAuthTypeBelowTiramisu.WPA_EAP -> "WPA-Enterprise" +} + +/** + * @return The list of security types supported to display in the dropdown. + */ +fun authListToDisplay(): List { + return WifiAuthTypeBelowTiramisu.entries.map { it.toDisplayString() } +} + +/** + * Converts the display string to [AuthenticationMode]. + * + * @return The [AuthenticationMode]. + */ +fun String.toAuthenticationMode(): AuthenticationMode = when (this) { + "Shared" -> WifiAuthTypeBelowTiramisu.WEP + "WPA-Personal" -> WifiAuthTypeBelowTiramisu.WPA_PSK + "WPA2-Personal" -> WifiAuthTypeBelowTiramisu.WPA2_PSK + "WPA/WPA2-Personal" -> WifiAuthTypeBelowTiramisu.WPA_WPA2_PSK + "WPA2-Enterprise" -> WifiAuthTypeBelowTiramisu.WPA2_EAP + "WPA3-Personal" -> WifiAuthTypeBelowTiramisu.WPA3_PSK + else -> WifiAuthTypeBelowTiramisu.OPEN +} + +/** + * Converts the [EncryptionMode] to a display string. + * + * @return The display string. + */ +fun EncryptionMode.toDisplayString(): String = when (this) { + EncryptionMode.NONE -> "None" + EncryptionMode.WEP -> "WEP" + EncryptionMode.TKIP -> "TKIP" + EncryptionMode.AES -> "AES" + EncryptionMode.AES_TKIP -> "AES/TKIP" +} + +/** + * Converts the display string to [EncryptionMode]. + * + * @return The [EncryptionMode]. + */ +fun String.toEncryptionMode(): EncryptionMode = when (this) { + "WEP" -> EncryptionMode.WEP + "TKIP" -> EncryptionMode.TKIP + "AES" -> EncryptionMode.AES + "AES/TKIP" -> EncryptionMode.AES_TKIP + else -> EncryptionMode.NONE +} \ No newline at end of file diff --git a/feature/nfc/src/main/java/no/nordicsemi/android/wifi/provisioner/feature/nfc/permission/RequireLocationForWifi.kt b/feature/nfc/src/main/java/no/nordicsemi/android/wifi/provisioner/feature/nfc/permission/RequireLocationForWifi.kt new file mode 100644 index 00000000..04eb6540 --- /dev/null +++ b/feature/nfc/src/main/java/no/nordicsemi/android/wifi/provisioner/feature/nfc/permission/RequireLocationForWifi.kt @@ -0,0 +1,36 @@ +package no.nordicsemi.android.wifi.provisioner.feature.nfc.permission + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import no.nordicsemi.android.wifi.provisioner.feature.nfc.permission.utils.WifiPermissionNotAvailableReason +import no.nordicsemi.android.wifi.provisioner.feature.nfc.permission.utils.WifiPermissionState +import no.nordicsemi.android.wifi.provisioner.feature.nfc.permission.view.LocationPermissionRequiredView +import no.nordicsemi.android.wifi.provisioner.feature.nfc.permission.viewmodel.PermissionViewModel + +@Composable +fun RequireLocationForWifi( + onChanged: (Boolean) -> Unit = {}, + contentWithoutLocation: @Composable () -> Unit = { LocationPermissionRequiredView() }, + content: @Composable (isLocationRequiredAndDisabled: Boolean) -> Unit, +) { + val viewModel = hiltViewModel() + val state by viewModel.locationPermission.collectAsStateWithLifecycle() + + LaunchedEffect(state) { + onChanged( + state is WifiPermissionState.Available || + (state as WifiPermissionState.NotAvailable).reason == WifiPermissionNotAvailableReason.DISABLED + ) + } + + when (val s = state) { + WifiPermissionState.Available -> content(false) + is WifiPermissionState.NotAvailable -> when (s.reason) { + WifiPermissionNotAvailableReason.DISABLED -> content(true) + else -> contentWithoutLocation() + } + } +} \ No newline at end of file diff --git a/feature/nfc/src/main/java/no/nordicsemi/android/wifi/provisioner/feature/nfc/permission/RequireWifi.kt b/feature/nfc/src/main/java/no/nordicsemi/android/wifi/provisioner/feature/nfc/permission/RequireWifi.kt new file mode 100644 index 00000000..b0eaa2cb --- /dev/null +++ b/feature/nfc/src/main/java/no/nordicsemi/android/wifi/provisioner/feature/nfc/permission/RequireWifi.kt @@ -0,0 +1,50 @@ +package no.nordicsemi.android.wifi.provisioner.feature.nfc.permission + +import android.os.Build +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import no.nordicsemi.android.wifi.provisioner.feature.nfc.permission.utils.WifiPermissionNotAvailableReason +import no.nordicsemi.android.wifi.provisioner.feature.nfc.permission.utils.WifiPermissionState +import no.nordicsemi.android.wifi.provisioner.feature.nfc.permission.view.WifiDisabledView +import no.nordicsemi.android.wifi.provisioner.feature.nfc.permission.view.WifiNotAvailableView +import no.nordicsemi.android.wifi.provisioner.feature.nfc.permission.view.WifiPermissionRequiredView +import no.nordicsemi.android.wifi.provisioner.feature.nfc.permission.viewmodel.PermissionViewModel + +@Composable +fun RequireWifi( + onChanged: (Boolean) -> Unit = {}, + contentWithoutWifi: @Composable (WifiPermissionNotAvailableReason) -> Unit = { + NoWifiView(reason = it) + }, + content: @Composable () -> Unit, +) { + val viewModel = hiltViewModel() + val state by viewModel.wifiState.collectAsStateWithLifecycle() + + LaunchedEffect(state) { + onChanged(state is WifiPermissionState.Available) + } + + when (val s = state) { + WifiPermissionState.Available -> content() + is WifiPermissionState.NotAvailable -> contentWithoutWifi(s.reason) + } +} + +@Composable +private fun NoWifiView( + reason: WifiPermissionNotAvailableReason, +) { + when (reason) { + WifiPermissionNotAvailableReason.NOT_AVAILABLE -> WifiNotAvailableView() + WifiPermissionNotAvailableReason.PERMISSION_REQUIRED -> + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + WifiPermissionRequiredView() + } + + WifiPermissionNotAvailableReason.DISABLED -> WifiDisabledView() + } +} \ No newline at end of file diff --git a/feature/nfc/src/main/java/no/nordicsemi/android/wifi/provisioner/feature/nfc/permission/location/LocationStateManager.kt b/feature/nfc/src/main/java/no/nordicsemi/android/wifi/provisioner/feature/nfc/permission/location/LocationStateManager.kt new file mode 100644 index 00000000..951d53fd --- /dev/null +++ b/feature/nfc/src/main/java/no/nordicsemi/android/wifi/provisioner/feature/nfc/permission/location/LocationStateManager.kt @@ -0,0 +1,80 @@ +package no.nordicsemi.android.wifi.provisioner.feature.nfc.permission.location + +import android.annotation.SuppressLint +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import android.content.IntentFilter +import android.location.LocationManager +import androidx.core.content.ContextCompat +import androidx.core.location.LocationManagerCompat +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.channels.awaitClose +import kotlinx.coroutines.flow.callbackFlow +import no.nordicsemi.android.wifi.provisioner.feature.nfc.permission.utils.LocalDataProvider +import no.nordicsemi.android.wifi.provisioner.feature.nfc.permission.utils.PermissionUtils +import no.nordicsemi.android.wifi.provisioner.feature.nfc.permission.utils.WifiPermissionNotAvailableReason +import no.nordicsemi.android.wifi.provisioner.feature.nfc.permission.utils.WifiPermissionState +import javax.inject.Inject +import javax.inject.Singleton + +private const val REFRESH_PERMISSIONS = + "no.nordicsemi.android.common.permission.REFRESH_LOCATION_PERMISSIONS" + +@Singleton +class LocationStateManager @Inject constructor( + @ApplicationContext private val context: Context, +) { + private val dataProvider = LocalDataProvider(context) + private val utils = PermissionUtils(context, dataProvider) + + @SuppressLint("WrongConstant") + fun locationState() = callbackFlow { + trySend(getLocationState()) + + val locationStateChangeHandler = object : BroadcastReceiver() { + override fun onReceive(context: Context, intent: Intent) { + trySend(getLocationState()) + } + } + val filter = IntentFilter().apply { + addAction(LocationManager.MODE_CHANGED_ACTION) + addAction(REFRESH_PERMISSIONS) + } + ContextCompat.registerReceiver( + context, + locationStateChangeHandler, + filter, + ContextCompat.RECEIVER_EXPORTED + ) + awaitClose { + context.unregisterReceiver(locationStateChangeHandler) + } + } + + fun refreshPermission() { + val intent = Intent(REFRESH_PERMISSIONS) + context.sendBroadcast(intent) + } + + fun markLocationPermissionRequested() { + dataProvider.locationPermissionRequested = true + } + + fun isLocationPermissionDeniedForever(context: Context): Boolean { + return utils.isLocationPermissionDeniedForever(context) + } + + private fun getLocationState(): WifiPermissionState { + val lm = context.getSystemService(Context.LOCATION_SERVICE) as LocationManager + return when { + !utils.isLocationPermissionGranted -> + WifiPermissionState.NotAvailable(WifiPermissionNotAvailableReason.PERMISSION_REQUIRED) + + dataProvider.isLocationPermissionRequired && !LocationManagerCompat.isLocationEnabled(lm) -> + WifiPermissionState.NotAvailable(WifiPermissionNotAvailableReason.DISABLED) + + else -> WifiPermissionState.Available + } + } +} \ No newline at end of file diff --git a/feature/nfc/src/main/java/no/nordicsemi/android/wifi/provisioner/feature/nfc/permission/utils/LocalDataProvider.kt b/feature/nfc/src/main/java/no/nordicsemi/android/wifi/provisioner/feature/nfc/permission/utils/LocalDataProvider.kt new file mode 100644 index 00000000..81954914 --- /dev/null +++ b/feature/nfc/src/main/java/no/nordicsemi/android/wifi/provisioner/feature/nfc/permission/utils/LocalDataProvider.kt @@ -0,0 +1,63 @@ +package no.nordicsemi.android.wifi.provisioner.feature.nfc.permission.utils + +import android.content.Context +import android.content.SharedPreferences +import android.os.Build +import androidx.annotation.ChecksSdkIntAtLeast +import androidx.core.app.ActivityCompat + +private const val SHARED_PREFS_NAME = "SHARED_PREFS_NAME" + +private const val PREFS_PERMISSION_REQUESTED = "permission_requested" +private const val PREFS_WIFI_PERMISSION_REQUESTED = "wifi_permission_requested" + +internal class LocalDataProvider( + private val context: Context +) { + private val sharedPrefs: SharedPreferences + get() = context.getSharedPreferences(SHARED_PREFS_NAME, Context.MODE_PRIVATE) + + /** + * The first time an app requests a permission there is no 'Don't ask again' checkbox and + * [ActivityCompat.shouldShowRequestPermissionRationale] returns false. + * This situation is similar to a permission being denied forever, so to distinguish both cases + * a flag needs to be saved. + */ + var locationPermissionRequested: Boolean + get() = sharedPrefs.getBoolean(PREFS_PERMISSION_REQUESTED, false) + set(value) { + sharedPrefs.edit().putBoolean(PREFS_PERMISSION_REQUESTED, value).apply() + } + + /** + * The first time an app requests a permission there is no 'Don't ask again' checkbox and + * [ActivityCompat.shouldShowRequestPermissionRationale] returns false. + * This situation is similar to a permission being denied forever, so to distinguish both cases + * a flag needs to be saved. + */ + var wifiPermissionRequested: Boolean + get() = sharedPrefs.getBoolean(PREFS_WIFI_PERMISSION_REQUESTED, false) + set(value) { + sharedPrefs.edit().putBoolean(PREFS_WIFI_PERMISSION_REQUESTED, value).apply() + } + + val isLocationPermissionRequired: Boolean + /** + * Location enabled is required on phones running Android 6 - 11 + * (for example on Nexus and Pixel devices). Initially, Samsung phones didn't require it, + * but that has been fixed for those phones in Android 9. + * Several Wi-Fi APIs require the ACCESS_FINE_LOCATION permission, + * even when your app targets Android 13 or higher. + * + * @return False if it is known that location is not required, true otherwise. + */ + get() = isMarshmallowOrAbove + + val isMarshmallowOrAbove: Boolean + @ChecksSdkIntAtLeast(api = Build.VERSION_CODES.M) + get() = Build.VERSION.SDK_INT >= Build.VERSION_CODES.M + + val isTiramisuOrAbove: Boolean + @ChecksSdkIntAtLeast(api = Build.VERSION_CODES.TIRAMISU) + get() = Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU +} diff --git a/feature/nfc/src/main/java/no/nordicsemi/android/wifi/provisioner/feature/nfc/permission/utils/PermissionUtils.kt b/feature/nfc/src/main/java/no/nordicsemi/android/wifi/provisioner/feature/nfc/permission/utils/PermissionUtils.kt new file mode 100644 index 00000000..18fe2bdb --- /dev/null +++ b/feature/nfc/src/main/java/no/nordicsemi/android/wifi/provisioner/feature/nfc/permission/utils/PermissionUtils.kt @@ -0,0 +1,74 @@ +package no.nordicsemi.android.wifi.provisioner.feature.nfc.permission.utils + +import android.Manifest +import android.app.Activity +import android.content.Context +import android.content.ContextWrapper +import android.content.pm.PackageManager +import android.net.wifi.WifiManager +import androidx.core.content.ContextCompat + +internal class PermissionUtils( + private val context: Context, + private val dataProvider: LocalDataProvider, +) { + val isWifiEnabled: Boolean + get() = (context.applicationContext.getSystemService(Context.WIFI_SERVICE) as WifiManager) + .isWifiEnabled + + val isWifiAvailable: Boolean + get() = context.packageManager.hasSystemFeature(PackageManager.FEATURE_WIFI) + + private val isLocationPermissionRequired: Boolean + get() = dataProvider.isMarshmallowOrAbove + + private val isWifiPermissionGranted: Boolean + get() = !dataProvider.isTiramisuOrAbove || + ContextCompat.checkSelfPermission( + context, + Manifest.permission.NEARBY_WIFI_DEVICES + ) == PackageManager.PERMISSION_GRANTED + + val isLocationPermissionGranted: Boolean + get() = !isLocationPermissionRequired || + ContextCompat.checkSelfPermission( + context, + Manifest.permission.ACCESS_FINE_LOCATION + ) == PackageManager.PERMISSION_GRANTED + + val areNecessaryWifiPermissionsGranted: Boolean + get() = isWifiPermissionGranted + + fun isWifiPermissionDeniedForever(context: Context): Boolean { + return dataProvider.isTiramisuOrAbove && + !isWifiPermissionGranted && // Wifi permission must be denied + dataProvider.wifiPermissionRequested && // Permission must have been requested before + !context.findActivity() + .shouldShowRequestPermissionRationale(Manifest.permission.NEARBY_WIFI_DEVICES) + } + + fun isLocationPermissionDeniedForever(context: Context): Boolean { + return dataProvider.isMarshmallowOrAbove && + !isLocationPermissionGranted // Location permission must be denied + && dataProvider.locationPermissionRequested // Permission must have been requested before + && !context.findActivity() + .shouldShowRequestPermissionRationale(Manifest.permission.ACCESS_FINE_LOCATION) + } + + /** + * Finds the activity from the given context. + * + * https://github.com/google/accompanist/blob/6611ebda55eb2948eca9e1c89c2519e80300855a/permissions/src/main/java/com/google/accompanist/permissions/PermissionsUtil.kt#L99 + * + * @throws IllegalStateException if no activity was found. + * @return the activity. + */ + private fun Context.findActivity(): Activity { + var context = this + while (context is ContextWrapper) { + if (context is Activity) return context + context = context.baseContext + } + throw IllegalStateException("no activity") + } +} diff --git a/feature/nfc/src/main/java/no/nordicsemi/android/wifi/provisioner/feature/nfc/permission/utils/WifiPermissionState.kt b/feature/nfc/src/main/java/no/nordicsemi/android/wifi/provisioner/feature/nfc/permission/utils/WifiPermissionState.kt new file mode 100644 index 00000000..833f9199 --- /dev/null +++ b/feature/nfc/src/main/java/no/nordicsemi/android/wifi/provisioner/feature/nfc/permission/utils/WifiPermissionState.kt @@ -0,0 +1,29 @@ +package no.nordicsemi.android.wifi.provisioner.feature.nfc.permission.utils + +/** + * Represents the reason for Wi-Fi permission is not available. + */ +enum class WifiPermissionNotAvailableReason { + PERMISSION_REQUIRED, + NOT_AVAILABLE, + DISABLED, +} + +/** + * Represents the state of Wi-Fi permission. + */ +sealed class WifiPermissionState { + + /** + * Represents the Wi-Fi permission is available. + */ + data object Available : WifiPermissionState() + + /** + * Represents the Wi-Fi permission is not available. + * @param reason The reason for Wi-Fi permission is not available. + */ + data class NotAvailable( + val reason: WifiPermissionNotAvailableReason, + ) : WifiPermissionState() +} diff --git a/feature/nfc/src/main/java/no/nordicsemi/android/wifi/provisioner/feature/nfc/permission/view/LocationPermissionRequiredView.kt b/feature/nfc/src/main/java/no/nordicsemi/android/wifi/provisioner/feature/nfc/permission/view/LocationPermissionRequiredView.kt new file mode 100644 index 00000000..c3af507d --- /dev/null +++ b/feature/nfc/src/main/java/no/nordicsemi/android/wifi/provisioner/feature/nfc/permission/view/LocationPermissionRequiredView.kt @@ -0,0 +1,106 @@ +package no.nordicsemi.android.wifi.provisioner.feature.nfc.permission.view + +import android.Manifest +import android.content.Context +import android.content.Intent +import android.net.Uri +import android.provider.Settings +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.LocationOff +import androidx.compose.material3.Button +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.core.content.ContextCompat +import androidx.hilt.navigation.compose.hiltViewModel +import no.nordicsemi.android.common.theme.NordicTheme +import no.nordicsemi.android.common.theme.view.WarningView +import no.nordicsemi.android.wifi.provisioner.feature.nfc.R +import no.nordicsemi.android.wifi.provisioner.feature.nfc.permission.viewmodel.PermissionViewModel + +@Composable +internal fun LocationPermissionRequiredView() { + val viewModel = hiltViewModel() + val context = LocalContext.current + var permissionDenied by remember { mutableStateOf(viewModel.isLocationPermissionDeniedForever(context)) } + + val launcher = rememberLauncherForActivityResult( + ActivityResultContracts.RequestMultiplePermissions() + ) { + viewModel.markLocationPermissionRequested() + permissionDenied = viewModel.isLocationPermissionDeniedForever(context) + viewModel.refreshLocationPermission() + } + + LocationPermissionRequiredView( + permissionDenied = permissionDenied, + onGrantClicked = { + val requiredPermissions = arrayOf( + Manifest.permission.ACCESS_FINE_LOCATION + ) + launcher.launch(requiredPermissions) + }, + onOpenSettingsClicked = { openPermissionSettings(context) }, + ) +} + +@Composable +internal fun LocationPermissionRequiredView( + permissionDenied: Boolean, + onGrantClicked: () -> Unit, + onOpenSettingsClicked: () -> Unit, +) { + WarningView( + imageVector = Icons.Default.LocationOff, + title = stringResource(id = R.string.location_permission_required), + hint = stringResource(id = R.string.location_permission__required_des), + modifier = Modifier + .fillMaxSize() + .verticalScroll(rememberScrollState()) + ) { + if (!permissionDenied) { + Button(onClick = onGrantClicked) { + Text(text = stringResource(id = R.string.grant_permission)) + } + } else { + Button(onClick = onOpenSettingsClicked) { + Text(text = stringResource(id = R.string.settings)) + } + } + } +} + +private fun openPermissionSettings(context: Context) { + ContextCompat.startActivity( + context, + Intent( + Settings.ACTION_APPLICATION_DETAILS_SETTINGS, + Uri.fromParts("package", context.packageName, null) + ), + null + ) +} + +@Preview +@Composable +private fun LocationPermissionRequiredView_Preview() { + NordicTheme { + LocationPermissionRequiredView( + permissionDenied = false, + onGrantClicked = { }, + onOpenSettingsClicked = { }, + ) + } +} \ No newline at end of file diff --git a/feature/nfc/src/main/java/no/nordicsemi/android/wifi/provisioner/feature/nfc/permission/view/WifiDisabledView.kt b/feature/nfc/src/main/java/no/nordicsemi/android/wifi/provisioner/feature/nfc/permission/view/WifiDisabledView.kt new file mode 100644 index 00000000..1eac0744 --- /dev/null +++ b/feature/nfc/src/main/java/no/nordicsemi/android/wifi/provisioner/feature/nfc/permission/view/WifiDisabledView.kt @@ -0,0 +1,49 @@ +package no.nordicsemi.android.wifi.provisioner.feature.nfc.permission.view + +import android.content.Context +import android.content.Intent +import android.provider.Settings +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.WifiOff +import androidx.compose.material3.Button +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import no.nordicsemi.android.common.theme.NordicTheme +import no.nordicsemi.android.common.theme.view.WarningView +import no.nordicsemi.android.wifi.provisioner.feature.nfc.R + +@Composable +internal fun WifiDisabledView() { + WarningView( + imageVector = Icons.Default.WifiOff, + title = stringResource(id = R.string.wifi_disabled), + hint = stringResource(id = R.string.wifi_disabled_des), + modifier = Modifier + .fillMaxSize() + .verticalScroll(rememberScrollState()) + ) { + val context = LocalContext.current + Button(onClick = { enableWifi(context) }) { + Text(text = stringResource(id = R.string.enable_wifi)) + } + } +} + +private fun enableWifi(context: Context) { + context.startActivity(Intent(Settings.ACTION_WIFI_SETTINGS)) +} + +@Preview +@Composable +private fun WifiDisabledViewPreview() { + NordicTheme { + WifiDisabledView() + } +} \ No newline at end of file diff --git a/feature/nfc/src/main/java/no/nordicsemi/android/wifi/provisioner/feature/nfc/permission/view/WifiNotAvailableView.kt b/feature/nfc/src/main/java/no/nordicsemi/android/wifi/provisioner/feature/nfc/permission/view/WifiNotAvailableView.kt new file mode 100644 index 00000000..56e8b89e --- /dev/null +++ b/feature/nfc/src/main/java/no/nordicsemi/android/wifi/provisioner/feature/nfc/permission/view/WifiNotAvailableView.kt @@ -0,0 +1,34 @@ +package no.nordicsemi.android.wifi.provisioner.feature.nfc.permission.view + +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.WifiOff +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import no.nordicsemi.android.common.theme.NordicTheme +import no.nordicsemi.android.common.theme.view.WarningView +import no.nordicsemi.android.wifi.provisioner.feature.nfc.R + +@Composable +internal fun WifiNotAvailableView() { + WarningView( + imageVector = Icons.Default.WifiOff, + title = stringResource(id = R.string.wifi_not_available), + hint = stringResource(id = R.string.wifi_not_available_des), + modifier = Modifier + .fillMaxSize() + .verticalScroll(rememberScrollState()) + ) +} + +@Preview +@Composable +private fun WifiNotAvailableView_Preview() { + NordicTheme { + WifiNotAvailableView() + } +} diff --git a/feature/nfc/src/main/java/no/nordicsemi/android/wifi/provisioner/feature/nfc/permission/view/WifiPermissionRequiredView.kt b/feature/nfc/src/main/java/no/nordicsemi/android/wifi/provisioner/feature/nfc/permission/view/WifiPermissionRequiredView.kt new file mode 100644 index 00000000..e937ef6a --- /dev/null +++ b/feature/nfc/src/main/java/no/nordicsemi/android/wifi/provisioner/feature/nfc/permission/view/WifiPermissionRequiredView.kt @@ -0,0 +1,92 @@ +package no.nordicsemi.android.wifi.provisioner.feature.nfc.permission.view + +import android.Manifest +import android.content.Context +import android.content.Intent +import android.net.Uri +import android.os.Build +import android.provider.Settings +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts +import androidx.annotation.RequiresApi +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.WifiOff +import androidx.compose.material3.Button +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.core.content.ContextCompat +import androidx.hilt.navigation.compose.hiltViewModel +import no.nordicsemi.android.common.theme.NordicTheme +import no.nordicsemi.android.common.theme.view.WarningView +import no.nordicsemi.android.wifi.provisioner.feature.nfc.R +import no.nordicsemi.android.wifi.provisioner.feature.nfc.permission.viewmodel.PermissionViewModel + +@RequiresApi(Build.VERSION_CODES.TIRAMISU) +@Composable +internal fun WifiPermissionRequiredView() { + val viewModel: PermissionViewModel = hiltViewModel() + val context = LocalContext.current + var permissionDenied by remember { mutableStateOf(viewModel.isWifiPermissionDeniedForever(context)) } + + WarningView( + imageVector = Icons.Default.WifiOff, + title = stringResource(id = R.string.wifi_permission_required), + hint = stringResource(id = R.string.wifi_permission_required_des), + modifier = Modifier + .fillMaxSize() + .verticalScroll(rememberScrollState()) + ) { + val requiredPermissions = arrayOf( + Manifest.permission.NEARBY_WIFI_DEVICES, + ) + + val launcher = rememberLauncherForActivityResult( + ActivityResultContracts.RequestMultiplePermissions() + ) { + viewModel.markWifiPermissionRequested() + permissionDenied = viewModel.isWifiPermissionDeniedForever(context) + viewModel.refreshWifiPermission() + } + + if (!permissionDenied) { + Button(onClick = { launcher.launch(requiredPermissions) }) { + Text(text = stringResource(id = R.string.grant_permission)) + } + } else { + Button(onClick = { openPermissionSettings(context) }) { + Text(text = stringResource(id = R.string.settings)) + } + } + } +} + +private fun openPermissionSettings(context: Context) { + ContextCompat.startActivity( + context, + Intent( + Settings.ACTION_APPLICATION_DETAILS_SETTINGS, + Uri.fromParts("package", context.packageName, null) + ), + null + ) +} + +@RequiresApi(Build.VERSION_CODES.TIRAMISU) +@Preview +@Composable +private fun WifiPermissionRequiredViewPreview() { + NordicTheme { + WifiPermissionRequiredView() + } +} \ No newline at end of file diff --git a/feature/nfc/src/main/java/no/nordicsemi/android/wifi/provisioner/feature/nfc/permission/viewmodel/PermissionViewModel.kt b/feature/nfc/src/main/java/no/nordicsemi/android/wifi/provisioner/feature/nfc/permission/viewmodel/PermissionViewModel.kt new file mode 100644 index 00000000..fcdf3b8e --- /dev/null +++ b/feature/nfc/src/main/java/no/nordicsemi/android/wifi/provisioner/feature/nfc/permission/viewmodel/PermissionViewModel.kt @@ -0,0 +1,55 @@ +package no.nordicsemi.android.wifi.provisioner.feature.nfc.permission.viewmodel + +import android.content.Context +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.stateIn +import no.nordicsemi.android.wifi.provisioner.feature.nfc.permission.location.LocationStateManager +import no.nordicsemi.android.wifi.provisioner.feature.nfc.permission.utils.WifiPermissionNotAvailableReason +import no.nordicsemi.android.wifi.provisioner.feature.nfc.permission.utils.WifiPermissionState +import no.nordicsemi.android.wifi.provisioner.feature.nfc.permission.wifi.WifiStateManager +import javax.inject.Inject + +@HiltViewModel +class PermissionViewModel @Inject constructor( + private val wifiStateManager: WifiStateManager, + private val locationManager: LocationStateManager, +) : ViewModel() { + val wifiState = wifiStateManager.wifiState() + .stateIn( + viewModelScope, SharingStarted.Lazily, + WifiPermissionState.NotAvailable(WifiPermissionNotAvailableReason.NOT_AVAILABLE) + ) + + val locationPermission = locationManager.locationState() + .stateIn( + viewModelScope, SharingStarted.Lazily, + WifiPermissionState.NotAvailable(WifiPermissionNotAvailableReason.NOT_AVAILABLE) + ) + + fun refreshWifiPermission() { + wifiStateManager.refreshPermission() + } + + fun refreshLocationPermission() { + locationManager.refreshPermission() + } + + fun markLocationPermissionRequested() { + locationManager.markLocationPermissionRequested() + } + + fun markWifiPermissionRequested() { + wifiStateManager.markWifiPermissionRequested() + } + + fun isWifiPermissionDeniedForever(context: Context): Boolean { + return wifiStateManager.isWifiPermissionDeniedForever(context) + } + + fun isLocationPermissionDeniedForever(context: Context): Boolean { + return locationManager.isLocationPermissionDeniedForever(context) + } +} diff --git a/feature/nfc/src/main/java/no/nordicsemi/android/wifi/provisioner/feature/nfc/permission/wifi/WifiStateManager.kt b/feature/nfc/src/main/java/no/nordicsemi/android/wifi/provisioner/feature/nfc/permission/wifi/WifiStateManager.kt new file mode 100644 index 00000000..898fe900 --- /dev/null +++ b/feature/nfc/src/main/java/no/nordicsemi/android/wifi/provisioner/feature/nfc/permission/wifi/WifiStateManager.kt @@ -0,0 +1,85 @@ +package no.nordicsemi.android.wifi.provisioner.feature.nfc.permission.wifi + +import android.annotation.SuppressLint +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import android.content.IntentFilter +import android.net.wifi.WifiManager +import androidx.core.content.ContextCompat +import androidx.core.content.ContextCompat.RECEIVER_EXPORTED +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.channels.awaitClose +import kotlinx.coroutines.flow.callbackFlow +import no.nordicsemi.android.wifi.provisioner.feature.nfc.permission.utils.LocalDataProvider +import no.nordicsemi.android.wifi.provisioner.feature.nfc.permission.utils.PermissionUtils +import no.nordicsemi.android.wifi.provisioner.feature.nfc.permission.utils.WifiPermissionNotAvailableReason +import no.nordicsemi.android.wifi.provisioner.feature.nfc.permission.utils.WifiPermissionState +import javax.inject.Inject +import javax.inject.Singleton + +private const val REFRESH_PERMISSIONS = + "no.nordicsemi.android.common.permission.REFRESH_WIFI_PERMISSIONS" + +@Singleton +class WifiStateManager @Inject constructor( + @ApplicationContext private val context: Context, +) { + private val dataProvider = LocalDataProvider(context) + private val utils = PermissionUtils(context, dataProvider) + + @SuppressLint("WrongConstant") + fun wifiState() = callbackFlow { + trySend(getWifiPermissionState()) + + val wifiStateChangeHandler = object : BroadcastReceiver() { + override fun onReceive(context: Context, intent: Intent) { + trySend(getWifiPermissionState()) + } + } + val filter = IntentFilter().apply { + addAction(WifiManager.WIFI_STATE_CHANGED_ACTION) + addAction(REFRESH_PERMISSIONS) + } + + ContextCompat.registerReceiver( + context, + wifiStateChangeHandler, + filter, + RECEIVER_EXPORTED + ) + + awaitClose { + context.unregisterReceiver(wifiStateChangeHandler) + } + } + + fun refreshPermission() { + val intent = Intent(REFRESH_PERMISSIONS) + context.sendBroadcast(intent) + } + + fun markWifiPermissionRequested() { + dataProvider.wifiPermissionRequested = true + } + + fun isWifiPermissionDeniedForever(context: Context): Boolean { + return utils.isWifiPermissionDeniedForever(context) + } + + private fun getWifiPermissionState() = when { + !utils.isWifiAvailable -> WifiPermissionState.NotAvailable( + WifiPermissionNotAvailableReason.NOT_AVAILABLE + ) + + !utils.areNecessaryWifiPermissionsGranted -> WifiPermissionState.NotAvailable( + WifiPermissionNotAvailableReason.PERMISSION_REQUIRED + ) + + !utils.isWifiEnabled -> WifiPermissionState.NotAvailable( + WifiPermissionNotAvailableReason.DISABLED + ) + + else -> WifiPermissionState.Available + } +} diff --git a/feature/nfc/src/main/java/no/nordicsemi/android/wifi/provisioner/feature/nfc/uicomponent/AddWifiManuallyDialog.kt b/feature/nfc/src/main/java/no/nordicsemi/android/wifi/provisioner/feature/nfc/uicomponent/AddWifiManuallyDialog.kt new file mode 100644 index 00000000..6520653f --- /dev/null +++ b/feature/nfc/src/main/java/no/nordicsemi/android/wifi/provisioner/feature/nfc/uicomponent/AddWifiManuallyDialog.kt @@ -0,0 +1,211 @@ +package no.nordicsemi.android.wifi.provisioner.feature.nfc.uicomponent + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.Wifi +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.TextRange +import androidx.compose.ui.text.input.TextFieldValue +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import no.nordicsemi.android.wifi.provisioner.feature.nfc.R +import no.nordicsemi.android.wifi.provisioner.feature.nfc.mapping.authListToDisplay +import no.nordicsemi.android.wifi.provisioner.feature.nfc.mapping.toAuthenticationMode +import no.nordicsemi.android.wifi.provisioner.feature.nfc.mapping.toDisplayString +import no.nordicsemi.android.wifi.provisioner.feature.nfc.mapping.toEncryptionMode +import no.nordicsemi.android.wifi.provisioner.nfc.domain.EncryptionMode +import no.nordicsemi.android.wifi.provisioner.nfc.domain.WifiData + +/** + * Composable function to show the dialog to add Wi-Fi manually. + * + * @param onCancelClick The lambda to be called when the cancel button is clicked. + * @param onConfirmClick The lambda to be called when the confirm button is clicked. + */ +@Composable +internal fun AddWifiManuallyDialog( + onCancelClick: () -> Unit, + onConfirmClick: (WifiData) -> Unit, +) { + var ssid by rememberSaveable { mutableStateOf("") } + var password by rememberSaveable { mutableStateOf("") } + var showPassword by rememberSaveable { mutableStateOf(false) } + var isPasswordEmpty by rememberSaveable { mutableStateOf(false) } + var authMode by rememberSaveable { mutableStateOf("WPA2-Personal") } // default to WPA2-Personal. + var encryptionMode by rememberSaveable { mutableStateOf(EncryptionMode.AES.toDisplayString()) } // default to AES. + var isSsidEmpty by rememberSaveable { mutableStateOf(false) } + var macAddress by remember { mutableStateOf(TextFieldValue(text = "")) } + var isMacAddressError by rememberSaveable { mutableStateOf(false) } + + AlertDialog( + onDismissRequest = { }, + icon = { + Icon(imageVector = Icons.Outlined.Wifi, contentDescription = null) + }, + title = { + Text( + text = stringResource(id = R.string.setup_wifi_title), + modifier = Modifier.fillMaxWidth(), + textAlign = TextAlign.Center, + style = MaterialTheme.typography.titleLarge + ) + }, + text = { + Column( + verticalArrangement = Arrangement.spacedBy(8.dp), + modifier = Modifier.verticalScroll(rememberScrollState()) + ) { + // Show the SSID field. + TextInputField( + input = ssid, + label = stringResource(id = R.string.ssid_label), + placeholder = stringResource(id = R.string.ssid_placeholder), + errorState = isSsidEmpty && ssid.trim().isEmpty(), + errorMessage = stringResource(id = R.string.ssid_error), + onUpdate = { + ssid = it + isSsidEmpty = ssid.isEmpty() + } + ) + + // Show the authentication dropdown. + DropdownView( + items = authListToDisplay(), + label = stringResource(id = R.string.authentication), + placeholder = stringResource(id = R.string.authentication_placeholder), + defaultSelectedItem = authMode + ) { authMode = it } + + // Show the password field only if the authentication mode is not open. + if (authMode.lowercase() != "open") { + // Show the password field. + PasswordInputField( + input = password, + label = stringResource(id = R.string.password), + placeholder = stringResource(id = R.string.password_placeholder), + isError = isPasswordEmpty && password.trim().isEmpty(), + errorMessage = stringResource(id = R.string.password_error), + showPassword = showPassword, + onShowPassChange = { showPassword = !showPassword }, + onUpdate = { password = it }, + ) + } else { + // Clear the password if the authentication mode is open. + password = "" + } + + // Show the MAC address field. + TextInputField( + input = macAddress, + label = stringResource(id = R.string.mac_address_label), + placeholder = stringResource(id = R.string.mac_address_placeholder), + errorState = isMacAddressError && macAddress.text.isNotEmpty(), + errorMessage = stringResource(id = R.string.mac_address_error), + onUpdate = { + val value = addColonToMacAddress(it.text.uppercase()) + macAddress = TextFieldValue( + text = value, + selection = TextRange(value.length), + ) + isMacAddressError = !isValidMacAddress(value) + } + ) + + // Show the encryption dropdown, only for protected network. + if (authMode.lowercase() != "open") { + DropdownView( + items = EncryptionMode.entries.map { it.toDisplayString() }, + label = stringResource(id = R.string.encryption), + placeholder = stringResource(id = R.string.encryption_placeholder), + defaultSelectedItem = encryptionMode, + ) { encryptionMode = it } + } + } + }, + dismissButton = { + TextButton( + onClick = { + onCancelClick() + } + ) { Text(text = stringResource(id = R.string.cancel)) } + }, + confirmButton = { + TextButton( + onClick = { + // Validate the fields. + when { + ssid.trim().isEmpty() -> isSsidEmpty = true + + authMode.lowercase() != "open" && password.trim() + .isEmpty() -> isPasswordEmpty = true + + macAddress.text.isNotEmpty() && !isValidMacAddress(macAddress.text) -> isMacAddressError = + true + + else -> onConfirmClick( + WifiData( + ssid = ssid, + macAddress = macAddress.text, + password = password, + authType = authMode.toAuthenticationMode(), + encryptionMode = if (authMode.lowercase() == "open") + EncryptionMode.NONE else encryptionMode.toEncryptionMode(), + ) + ) + } + } + ) { Text(text = stringResource(id = R.string.confirm)) } + } + ) +} + +@Preview +@Composable +private fun OpenAddWifiManuallyDialogPreview() { + AddWifiManuallyDialog( + onCancelClick = {}, + onConfirmClick = {} + ) +} + +/** Adds colon to the MAC address. */ +private fun addColonToMacAddress(s: String, insertText: String = ":"): String { + val mac = s.replace(insertText, "") + val sb = StringBuilder() + for (i in mac.indices) { + sb.append(mac[i]) + if (i % 2 == 1 && i != mac.length - 1) { + sb.append(insertText) + } + } + return sb.toString() +} + +/** Checks if the MAC address is valid. */ +private fun isValidMacAddress(address: String): Boolean { + return try { + // Regex pattern to match a valid MAC address + val macAddressPattern = Regex("^([0-9A-Fa-f]{2}:){5}[0-9A-Fa-f]{2}$") + return macAddressPattern.matches(address) + } catch (e: IllegalArgumentException) { + e.printStackTrace() + false + } +} \ No newline at end of file diff --git a/feature/nfc/src/main/java/no/nordicsemi/android/wifi/provisioner/feature/nfc/uicomponent/DropDownView.kt b/feature/nfc/src/main/java/no/nordicsemi/android/wifi/provisioner/feature/nfc/uicomponent/DropDownView.kt new file mode 100644 index 00000000..805a2217 --- /dev/null +++ b/feature/nfc/src/main/java/no/nordicsemi/android/wifi/provisioner/feature/nfc/uicomponent/DropDownView.kt @@ -0,0 +1,127 @@ +package no.nordicsemi.android.wifi.provisioner.feature.nfc.uicomponent + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.size +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.ExposedDropdownMenuBox +import androidx.compose.material3.ExposedDropdownMenuDefaults +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.MenuAnchorType +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import no.nordicsemi.android.common.theme.NordicTheme + +/** + * Composable function to show the dropdown view. + * + * @param items The list of items to be shown in the dropdown. + * @param label The label to be shown in the dropdown. + * @param placeholder The placeholder to be shown in the dropdown. + * @param defaultSelectedItem The default selected item in the dropdown. + * @param onItemSelected The callback to be called when an item is selected. + */ +@Composable +internal inline fun DropdownView( + items: List, + label: String, + placeholder: String, + defaultSelectedItem: T? = null, + crossinline onItemSelected: (T) -> Unit, +) { + NfcDropdownMenu( + items = items, + label = label, + defaultSelectedItem = defaultSelectedItem, + placeholder = placeholder, + onItemSelected = { onItemSelected(it) } + ) +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun NfcDropdownMenu( + items: List, + label: String, + defaultSelectedItem: T? = null, + placeholder: String, + onItemSelected: (T) -> Unit +) { + var expanded by rememberSaveable { mutableStateOf(false) } + var selectedText by rememberSaveable { mutableStateOf(defaultSelectedItem) } + + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center, + ) { + Box { + ExposedDropdownMenuBox( + expanded = expanded, + onExpandedChange = { expanded = it } + ) { + OutlinedTextField( + value = selectedText?.toString() ?: placeholder, + onValueChange = {}, + readOnly = true, + trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = expanded) }, + modifier = Modifier + .menuAnchor(MenuAnchorType.PrimaryNotEditable) + .fillMaxWidth(), + placeholder = { + Text(text = placeholder) + }, + label = { Text(text = label) }, + ) + DropdownMenu( + expanded = expanded, + onDismissRequest = { expanded = false }, + modifier = Modifier + .exposedDropdownSize() + .background(MaterialTheme.colorScheme.surface) + ) { + items.forEach { item -> + DropdownMenuItem( + text = { + Text(item.toString()) + }, + onClick = { + selectedText = item + expanded = false + onItemSelected(item) + } + ) + } + } + } + } + } + Spacer(modifier = Modifier.size(8.dp)) +} + +@Preview +@Composable +private fun NfcDropDownViewPreview() { + NordicTheme { + DropdownView( + items = listOf("English", "Spanish", "French"), + label = "Language", + defaultSelectedItem = "English", + placeholder = "Select language", + ) {} + } +} \ No newline at end of file diff --git a/feature/nfc/src/main/java/no/nordicsemi/android/wifi/provisioner/feature/nfc/uicomponent/NfcTextRow.kt b/feature/nfc/src/main/java/no/nordicsemi/android/wifi/provisioner/feature/nfc/uicomponent/NfcTextRow.kt new file mode 100644 index 00000000..4d0e582d --- /dev/null +++ b/feature/nfc/src/main/java/no/nordicsemi/android/wifi/provisioner/feature/nfc/uicomponent/NfcTextRow.kt @@ -0,0 +1,67 @@ +package no.nordicsemi.android.wifi.provisioner.feature.nfc.uicomponent + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha +import androidx.compose.ui.tooling.preview.Preview +import no.nordicsemi.android.common.theme.NordicTheme + +@Composable +internal fun NfcTextRow( + title: String, + text: String, + modifier: Modifier = Modifier, +) { + Column( + modifier = modifier + .fillMaxWidth(), + ) { + Text( + text = title, + modifier = Modifier, + style = MaterialTheme.typography.titleMedium + ) + Text( + text = text, + modifier = Modifier.alpha(0.7f), + style = MaterialTheme.typography.bodySmall, + ) + } +} + +@Preview +@Composable +private fun NfcTextRowPreview() { + NordicTheme { + NfcTextRow( + title = "Language", + text = "en", + ) + } +} + +@Composable +internal fun NfcPasswordRow( + title: String, + modifier: Modifier = Modifier, +) { + Column( + modifier = modifier + .fillMaxWidth(), + ) { + Text( + text = title, + modifier = Modifier, + style = MaterialTheme.typography.titleMedium + ) + Text( + text = "********", // Hide the password with asterisks. + modifier = Modifier.alpha(0.7f), + style = MaterialTheme.typography.bodySmall, + ) + } +} diff --git a/feature/nfc/src/main/java/no/nordicsemi/android/wifi/provisioner/feature/nfc/uicomponent/OutlinedCardItem.kt b/feature/nfc/src/main/java/no/nordicsemi/android/wifi/provisioner/feature/nfc/uicomponent/OutlinedCardItem.kt new file mode 100644 index 00000000..bca6f294 --- /dev/null +++ b/feature/nfc/src/main/java/no/nordicsemi/android/wifi/provisioner/feature/nfc/uicomponent/OutlinedCardItem.kt @@ -0,0 +1,98 @@ +package no.nordicsemi.android.wifi.provisioner.feature.nfc.uicomponent + +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.RowScope +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.TipsAndUpdates +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedCard +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import no.nordicsemi.android.common.theme.NordicTheme +import no.nordicsemi.android.common.theme.nordicBlue + + +/** Compose view for the NFC record item in the outlined card. + * @param headline The headline of the record. + * @param description The description of the record. + * @param icon The icon of the record. + * @param content The content of the record. + */ +@Composable +fun OutlinedCardItem( + headline: String, + description: @Composable (RowScope.(TextStyle) -> Unit), + icon: ImageVector, + onCardClick: () -> Unit = {}, + content: @Composable (RowScope.() -> Unit), +) { + OutlinedCard( + modifier = Modifier + .clip(RoundedCornerShape(12.dp)) + .clickable { onCardClick() } + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + modifier = Modifier + .padding(start= 16.dp, end = 8.dp, top = 8.dp, bottom = 8.dp) + .fillMaxWidth() + ) { + Icon( + imageVector = icon, + contentDescription = null, + tint = MaterialTheme.colorScheme.nordicBlue, + modifier = Modifier.size(28.dp) + ) + Column( + modifier = Modifier.padding(8.dp).weight(1f) + ) { + Text( + text = headline, + style = MaterialTheme.typography.titleMedium, + ) + Row( + horizontalArrangement = Arrangement.spacedBy(8.dp), + modifier = Modifier.alpha(0.7f) + ) { + description(MaterialTheme.typography.bodySmall) + } + } + content() + } + } +} + +@Preview +@Composable +private fun OutlinedCardItemPreview() { + NordicTheme { + OutlinedCardItem( + headline = "URI record", + description = { + Text( + text = "https://www.nordicsemi.no", + style = it, + ) + }, + icon = Icons.Default.TipsAndUpdates, + ) { + } + } +} diff --git a/feature/nfc/src/main/java/no/nordicsemi/android/wifi/provisioner/feature/nfc/uicomponent/PasswordDialog.kt b/feature/nfc/src/main/java/no/nordicsemi/android/wifi/provisioner/feature/nfc/uicomponent/PasswordDialog.kt new file mode 100644 index 00000000..d7431cd3 --- /dev/null +++ b/feature/nfc/src/main/java/no/nordicsemi/android/wifi/provisioner/feature/nfc/uicomponent/PasswordDialog.kt @@ -0,0 +1,116 @@ +package no.nordicsemi.android.wifi.provisioner.feature.nfc.uicomponent + +import android.net.wifi.ScanResult +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.Wifi +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import no.nordicsemi.android.wifi.provisioner.feature.nfc.R +import no.nordicsemi.android.wifi.provisioner.nfc.domain.AuthenticationMode +import no.nordicsemi.android.wifi.provisioner.nfc.domain.EncryptionMode +import no.nordicsemi.android.wifi.provisioner.nfc.domain.WifiData + +/** + * Composable function to show the dialog to enter the password for the selected Wi-Fi network. + * + * @param scanResult The selected Wi-Fi network. + * @param onCancelClick The lambda to be called when the cancel button is clicked. + * @param onConfirmClick The lambda to be called when the confirm button is clicked. + */ +@Composable +internal fun PasswordDialog( + scanResult: ScanResult, + onCancelClick: () -> Unit, + onConfirmClick: (WifiData) -> Unit, +) { + var password by rememberSaveable { mutableStateOf("") } + var isPasswordEmpty by rememberSaveable { mutableStateOf(false) } + val authMode = AuthenticationMode.get(scanResult) + val encryptionMode = EncryptionMode.get(scanResult) + + AlertDialog( + onDismissRequest = { }, + icon = { + Icon(imageVector = Icons.Outlined.Wifi, contentDescription = null) + }, + title = { + Text( + text = stringResource(id = R.string.setup_wifi_title), + modifier = Modifier.fillMaxWidth(), + textAlign = TextAlign.Center, + style = MaterialTheme.typography.titleLarge + ) + }, + text = { + Column( + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + var showPassword by rememberSaveable { mutableStateOf(false) } + + // Show the SSID of the selected network. The SSID is read-only. + OutlinedTextField( + value = scanResult.SSID, + readOnly = true, + label = { Text(text = stringResource(id = R.string.ssid_label)) }, + onValueChange = { } + ) + // Show the password field. + PasswordInputField( + input = password, + label = stringResource(id = R.string.password), + placeholder = stringResource(id = R.string.password_placeholder), + showPassword = showPassword, + isError = isPasswordEmpty && password.trim().isEmpty(), + errorMessage = stringResource(id = R.string.password_error), + onShowPassChange = { showPassword = !showPassword }, + onUpdate = { + password = it + isPasswordEmpty = password.trim().isEmpty() + }, + ) + } + }, + dismissButton = { + TextButton( + onClick = { + onCancelClick() + } + ) { Text(text = stringResource(id = R.string.cancel)) } + }, + confirmButton = { + TextButton( + onClick = { + if (password.trim().isEmpty()) { + isPasswordEmpty = true + } else { + onConfirmClick( + WifiData( + ssid = scanResult.SSID, + macAddress = scanResult.BSSID, + password = password, + authType = authMode.first(), + encryptionMode = encryptionMode + ) + ) + } + } + ) { Text(text = stringResource(id = R.string.confirm)) } + } + ) +} diff --git a/feature/nfc/src/main/java/no/nordicsemi/android/wifi/provisioner/feature/nfc/uicomponent/PasswordInputField.kt b/feature/nfc/src/main/java/no/nordicsemi/android/wifi/provisioner/feature/nfc/uicomponent/PasswordInputField.kt new file mode 100644 index 00000000..707d6c6c --- /dev/null +++ b/feature/nfc/src/main/java/no/nordicsemi/android/wifi/provisioner/feature/nfc/uicomponent/PasswordInputField.kt @@ -0,0 +1,113 @@ +package no.nordicsemi.android.wifi.provisioner.feature.nfc.uicomponent + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Error +import androidx.compose.material.icons.outlined.Visibility +import androidx.compose.material.icons.outlined.VisibilityOff +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.LocalContentColor +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.OutlinedTextFieldDefaults +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.text.input.PasswordVisualTransformation +import androidx.compose.ui.text.input.VisualTransformation +import androidx.compose.ui.unit.dp + +/** + * Compose view to input password in OutlinedTextField. + */ +@Composable +fun PasswordInputField( + input: String, + label: String, + placeholder: String, + isError: Boolean = false, + errorMessage: String = "", + hint: String = "", + showPassword: Boolean, + onShowPassChange: (Boolean) -> Unit = {}, + onUpdate: (String) -> Unit, +) { + var isShowPassword by remember { mutableStateOf(showPassword) } + val textColor = MaterialTheme.colorScheme.onSurface.copy( + alpha = if (input.isEmpty()) 0.5f else LocalContentColor.current.alpha + ) + val visibilityIcon = + if (isShowPassword) Icons.Outlined.Visibility else Icons.Outlined.VisibilityOff + OutlinedTextField( + value = input, + onValueChange = { onUpdate(it) }, + visualTransformation = if (input.isEmpty()) { + PlaceholderTransformation(placeholder) + } else { + if (isShowPassword) { + VisualTransformation.None + } else { + PasswordVisualTransformation() + } + }, + label = { Text(text = label) }, + placeholder = { + Text( + text = placeholder, + ) + }, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password), + supportingText = { + Column { + if (isError) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + Icon( + imageVector = Icons.Default.Error, + contentDescription = null, + tint = MaterialTheme.colorScheme.error, + ) + Text( + text = errorMessage, + color = MaterialTheme.colorScheme.error, + modifier = Modifier.alpha(1f) + ) + } + } + if (hint.isNotEmpty() && !isError) { + Text( + text = hint, + modifier = Modifier.alpha(0.38f) + ) + } + } + }, + trailingIcon = { + IconButton(onClick = { + + isShowPassword = !isShowPassword + onShowPassChange(isShowPassword) + }) { + Icon( + imageVector = visibilityIcon, + contentDescription = null + ) + } + }, + colors = OutlinedTextFieldDefaults.colors(textColor), + isError = isError, + ) +} \ No newline at end of file diff --git a/feature/nfc/src/main/java/no/nordicsemi/android/wifi/provisioner/feature/nfc/uicomponent/RssiIconView.kt b/feature/nfc/src/main/java/no/nordicsemi/android/wifi/provisioner/feature/nfc/uicomponent/RssiIconView.kt new file mode 100644 index 00000000..946950f2 --- /dev/null +++ b/feature/nfc/src/main/java/no/nordicsemi/android/wifi/provisioner/feature/nfc/uicomponent/RssiIconView.kt @@ -0,0 +1,78 @@ +package no.nordicsemi.android.wifi.provisioner.feature.nfc.uicomponent + +import androidx.compose.foundation.Image +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.size +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.NetworkWifi +import androidx.compose.material.icons.filled.NetworkWifi1Bar +import androidx.compose.material.icons.filled.NetworkWifi2Bar +import androidx.compose.material.icons.filled.NetworkWifi3Bar +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.ColorFilter +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import no.nordicsemi.android.common.theme.NordicTheme +import no.nordicsemi.android.common.theme.R +import no.nordicsemi.android.common.theme.nordicBlue + +// Constants used for different signal strengths +private const val FAIR_RSSI = -70 +private const val GOOD_RSSI = -60 +private const val EXCELLENT_RSSI = -50 + +/** + * A function to get the WiFi icon based on the RSSI value. + * The icon is selected based on the following criteria: + * - Weak signal: RSSI < -70 dBm + * - Fair signal: -70 dBm <= RSSI < -60 dBm + * - Good signal: -60 dBm <= RSSI < -50 dBm + * - Excellent signal: RSSI >= -50 dBm + * + * Selection criteria was inspired by [this](https://www.netspotapp.com/wifi-signal-strength/what-is-rssi-level.html) article. + * + * @param rssi The RSSI value. + */ +internal fun getWiFiIcon(rssi: Int): ImageVector { + return when (rssi) { + in Int.MIN_VALUE..FAIR_RSSI -> Icons.Default.NetworkWifi3Bar // Weak signal + in FAIR_RSSI..GOOD_RSSI -> Icons.Default.NetworkWifi2Bar // Fair signal + in GOOD_RSSI..EXCELLENT_RSSI -> Icons.Default.NetworkWifi1Bar // Good signal + else -> Icons.Default.NetworkWifi// Excellent signal + } +} + +/** + * A composable function to display the RSSI icon and value. + * + * @param rssi The RSSI value. + */ +@Composable +internal fun RssiIconView(rssi: Int) { + Column(horizontalAlignment = Alignment.CenterHorizontally) { + Image( + imageVector = getWiFiIcon(rssi), + contentDescription = null, + modifier = Modifier.size(28.dp), + colorFilter = ColorFilter.tint(MaterialTheme.colorScheme.nordicBlue) + ) + Text( + text = stringResource(id = R.string.dbm, rssi), + style = MaterialTheme.typography.labelSmall + ) + } +} + +@Preview +@Composable +private fun RssiIconViewPreview() { + NordicTheme { + RssiIconView(-50) + } +} diff --git a/feature/nfc/src/main/java/no/nordicsemi/android/wifi/provisioner/feature/nfc/uicomponent/TextInputField.kt b/feature/nfc/src/main/java/no/nordicsemi/android/wifi/provisioner/feature/nfc/uicomponent/TextInputField.kt new file mode 100644 index 00000000..63a2aef0 --- /dev/null +++ b/feature/nfc/src/main/java/no/nordicsemi/android/wifi/provisioner/feature/nfc/uicomponent/TextInputField.kt @@ -0,0 +1,169 @@ +package no.nordicsemi.android.wifi.provisioner.feature.nfc.uicomponent + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Error +import androidx.compose.material3.Icon +import androidx.compose.material3.LocalContentColor +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.OutlinedTextFieldDefaults +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.input.OffsetMapping +import androidx.compose.ui.text.input.TextFieldValue +import androidx.compose.ui.text.input.TransformedText +import androidx.compose.ui.text.input.VisualTransformation +import androidx.compose.ui.unit.dp + +/** + * Compose view to input text in OutlinedTextField. + */ +@Composable +internal fun TextInputField( + modifier: Modifier = Modifier, + input: String, + label: String, + hint: String = "", + placeholder: String = "", + errorMessage: String = "", + errorState: Boolean = false, + onUpdate: (String) -> Unit +) { + val textColor = MaterialTheme.colorScheme.onSurface.copy( + alpha = if (input.isEmpty()) 0.5f else LocalContentColor.current.alpha + ) + OutlinedTextField( + value = input, + onValueChange = { onUpdate(it) }, + visualTransformation = if (input.isEmpty()) + PlaceholderTransformation(placeholder) else VisualTransformation.None, + + modifier = modifier + .fillMaxWidth(), + label = { Text(text = label) }, + placeholder = { + Text( + text = placeholder, + ) + }, + supportingText = { + Column { + if (errorState) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + Icon( + imageVector = Icons.Default.Error, + contentDescription = null, + tint = MaterialTheme.colorScheme.error, + ) + Text( + text = errorMessage, + color = MaterialTheme.colorScheme.error, + modifier = Modifier.alpha(1f) + ) + } + } + if (hint.isNotEmpty() && !errorState) { + Text( + text = hint, + modifier = Modifier.alpha(0.38f) + ) + } + } + }, + colors = OutlinedTextFieldDefaults.colors(textColor), + isError = errorState, + ) +} + +@Composable +fun TextInputField( + modifier: Modifier = Modifier, + input: TextFieldValue, + label: String, + hint: String = "", + placeholder: String = "", + errorMessage: String = "", + errorState: Boolean = false, + onUpdate: (TextFieldValue) -> Unit +) { + val textColor = MaterialTheme.colorScheme.onSurface.copy( + alpha = if (input.text.isEmpty()) 0.5f else LocalContentColor.current.alpha + ) + OutlinedTextField( + value = input, + onValueChange = { onUpdate(it) }, + visualTransformation = if (input.text.isEmpty()) + PlaceholderTransformation(placeholder) else VisualTransformation.None, + + modifier = modifier + .fillMaxWidth(), + label = { Text(text = label) }, + placeholder = { + Text( + text = placeholder, + ) + }, + supportingText = { + Column { + if (errorState) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + Icon( + imageVector = Icons.Default.Error, + contentDescription = null, + tint = MaterialTheme.colorScheme.error, + ) + Text( + text = errorMessage, + color = MaterialTheme.colorScheme.error, + modifier = Modifier.alpha(1f) + ) + } + } + if (hint.isNotEmpty() && !errorState) { + Text( + text = hint, + modifier = Modifier.alpha(0.38f) + ) + } + } + }, + colors = OutlinedTextFieldDefaults.colors(textColor), + isError = errorState, + ) +} + +class PlaceholderTransformation(private val placeholder: String) : VisualTransformation { + override fun filter(text: AnnotatedString): TransformedText { + return placeholderFilter(placeholder) + } +} + +fun placeholderFilter(placeholder: String): TransformedText { + + val numberOffsetTranslator = object : OffsetMapping { + override fun originalToTransformed(offset: Int): Int { + return 0 + } + + override fun transformedToOriginal(offset: Int): Int { + return 0 + } + } + + return TransformedText(AnnotatedString(placeholder), numberOffsetTranslator) +} + diff --git a/feature/nfc/src/main/java/no/nordicsemi/android/wifi/provisioner/feature/nfc/uicomponent/VerticalBlueBar.kt b/feature/nfc/src/main/java/no/nordicsemi/android/wifi/provisioner/feature/nfc/uicomponent/VerticalBlueBar.kt new file mode 100644 index 00000000..bea0b86b --- /dev/null +++ b/feature/nfc/src/main/java/no/nordicsemi/android/wifi/provisioner/feature/nfc/uicomponent/VerticalBlueBar.kt @@ -0,0 +1,43 @@ +package no.nordicsemi.android.wifi.provisioner.feature.nfc.uicomponent + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ColumnScope +import androidx.compose.foundation.layout.IntrinsicSize +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp + +/** + * This composable is used to create a vertical blue bar with a content. + * + * @param content The content to be displayed in the blue bar. + */ +@Composable +internal fun VerticalBlueBar( + content: @Composable ColumnScope.() -> Unit, +) { + Row( + modifier = Modifier.height(IntrinsicSize.Min).padding(start = 8.dp, top = 8.dp, bottom = 8.dp) + ) { + Box( + modifier = Modifier + .fillMaxHeight() + .width(8.dp) + .background(MaterialTheme.colorScheme.primary, RoundedCornerShape(4.dp)) + ) + Column( + modifier = Modifier.padding(start = 8.dp), + ) { + content() + } + } +} \ No newline at end of file diff --git a/feature/nfc/src/main/java/no/nordicsemi/android/wifi/provisioner/feature/nfc/view/HomeScreen.kt b/feature/nfc/src/main/java/no/nordicsemi/android/wifi/provisioner/feature/nfc/view/HomeScreen.kt new file mode 100644 index 00000000..f97f6c2f --- /dev/null +++ b/feature/nfc/src/main/java/no/nordicsemi/android/wifi/provisioner/feature/nfc/view/HomeScreen.kt @@ -0,0 +1,136 @@ +package no.nordicsemi.android.wifi.provisioner.feature.nfc.view + +import android.os.Build +import androidx.annotation.RequiresApi +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Add +import androidx.compose.material.icons.filled.Search +import androidx.compose.material.icons.filled.Wifi +import androidx.compose.material.icons.filled.WifiFind +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.SnackbarHost +import androidx.compose.material3.SnackbarHostState +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import no.nordicsemi.android.common.theme.view.NordicAppBar +import no.nordicsemi.android.wifi.provisioner.feature.nfc.R +import no.nordicsemi.android.wifi.provisioner.feature.nfc.uicomponent.AddWifiManuallyDialog +import no.nordicsemi.android.wifi.provisioner.feature.nfc.uicomponent.OutlinedCardItem +import no.nordicsemi.android.wifi.provisioner.feature.nfc.viewmodel.NfcProvisioningViewEvent +import no.nordicsemi.android.wifi.provisioner.feature.nfc.viewmodel.NfcProvisioningViewModel +import no.nordicsemi.android.wifi.provisioner.feature.nfc.viewmodel.OnAddWifiNetworkClickEvent +import no.nordicsemi.android.wifi.provisioner.feature.nfc.viewmodel.OnBackClickEvent +import no.nordicsemi.android.wifi.provisioner.feature.nfc.viewmodel.OnScanClickEvent + +@RequiresApi(Build.VERSION_CODES.M) +@OptIn(ExperimentalMaterial3Api::class) +@Composable +internal fun HomeScreen() { + val viewModel: NfcProvisioningViewModel = hiltViewModel() + val onEvent: (NfcProvisioningViewEvent) -> Unit = { viewModel.onEvent(it) } + val snackbarHostState = remember { SnackbarHostState() } + + Scaffold( + contentWindowInsets = WindowInsets(0, 0, 0, 0), + topBar = { + NordicAppBar( + text = stringResource(id = R.string.wifi_provision_over_nfc_appbar), + showBackButton = true, + onNavigationButtonClick = { onEvent(OnBackClickEvent) } + ) + }, + snackbarHost = { + SnackbarHost(hostState = snackbarHostState) + }, + ) { innerPadding -> + Column( + modifier = Modifier + .fillMaxSize() + .padding(innerPadding) + .padding(16.dp), + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + // Show the home screen. + var isDialogOpen by rememberSaveable { mutableStateOf(false) } + + // Show an option to enter the WiFi credentials manually. + OutlinedCardItem( + headline = stringResource(id = R.string.enter_wifi_credentials), + description = { + Text( + text = stringResource(id = R.string.enter_wifi_credentials_des), + style = MaterialTheme.typography.bodyMedium, + ) + }, + icon = Icons.Default.Wifi, + onCardClick = { isDialogOpen = true } + ) { + Icon( + imageVector = Icons.Default.Add, + contentDescription = null, + modifier = Modifier + .clip(CircleShape) + .clickable { + isDialogOpen = true + } + .padding(8.dp) + ) + } + + // Show an option to search for a WiFi network. + OutlinedCardItem( + headline = stringResource(id = R.string.search_for_wifi_networks), + description = { + Text( + text = stringResource(id = R.string.search_for_wifi_networks_des), + style = MaterialTheme.typography.bodyMedium, + ) + }, + icon = Icons.Default.WifiFind, + onCardClick = { onEvent(OnScanClickEvent) } + ) { + Icon( + imageVector = Icons.Default.Search, + contentDescription = null, + modifier = Modifier + .clip(CircleShape) + .clickable { onEvent(OnScanClickEvent) } + .padding(8.dp) + ) + } + + if (isDialogOpen) { + // Open a dialog to enter the WiFi credentials manually. + AddWifiManuallyDialog( + onCancelClick = { + isDialogOpen = false + }, + onConfirmClick = { + isDialogOpen = false + onEvent(OnAddWifiNetworkClickEvent(it)) + }, + ) + } + } + } +} diff --git a/feature/nfc/src/main/java/no/nordicsemi/android/wifi/provisioner/feature/nfc/view/NfcPublishScreen.kt b/feature/nfc/src/main/java/no/nordicsemi/android/wifi/provisioner/feature/nfc/view/NfcPublishScreen.kt new file mode 100644 index 00000000..46c9bc27 --- /dev/null +++ b/feature/nfc/src/main/java/no/nordicsemi/android/wifi/provisioner/feature/nfc/view/NfcPublishScreen.kt @@ -0,0 +1,192 @@ +package no.nordicsemi.android.wifi.provisioner.feature.nfc.view + +import android.app.Activity +import androidx.activity.compose.BackHandler +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Edit +import androidx.compose.material.icons.filled.Wifi +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedCard +import androidx.compose.material3.Scaffold +import androidx.compose.material3.SnackbarHost +import androidx.compose.material3.SnackbarHostState +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import no.nordicsemi.android.common.permissions.nfc.RequireNfc +import no.nordicsemi.android.common.theme.view.NordicAppBar +import no.nordicsemi.android.common.theme.view.ProgressItem +import no.nordicsemi.android.common.theme.view.ProgressItemStatus +import no.nordicsemi.android.common.theme.view.WizardStepComponent +import no.nordicsemi.android.common.theme.view.WizardStepState +import no.nordicsemi.android.wifi.provisioner.feature.nfc.R +import no.nordicsemi.android.wifi.provisioner.feature.nfc.mapping.toDisplayString +import no.nordicsemi.android.wifi.provisioner.feature.nfc.uicomponent.NfcPasswordRow +import no.nordicsemi.android.wifi.provisioner.feature.nfc.uicomponent.NfcTextRow +import no.nordicsemi.android.wifi.provisioner.feature.nfc.viewmodel.NfcManagerViewModel +import no.nordicsemi.android.wifi.provisioner.nfc.Error +import no.nordicsemi.android.wifi.provisioner.nfc.Loading +import no.nordicsemi.android.wifi.provisioner.nfc.Success + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +internal fun NfcPublishScreen() { + val nfcManagerVm: NfcManagerViewModel = hiltViewModel() + val context = LocalContext.current + val nfcScanEvent by nfcManagerVm.nfcScanEvent.collectAsStateWithLifecycle() + val ndefMessage = nfcManagerVm.ndefMessage + val wifiData = nfcManagerVm.wifiData + val snackbarHostState = remember { SnackbarHostState() } + + // Handle back navigation. + BackHandler { + nfcManagerVm.onBackNavigation() + } + Scaffold( + contentWindowInsets = WindowInsets(0, 0, 0, 0), + topBar = { + NordicAppBar( + text = stringResource(id = R.string.ndef_publish_appbar), + showBackButton = true, + onNavigationButtonClick = { nfcManagerVm.onBackNavigation() } + ) + }, + snackbarHost = { + SnackbarHost(hostState = snackbarHostState) + }, + ) { innerPadding -> + Column( + modifier = Modifier + .fillMaxSize() + .padding(innerPadding), + ) { + RequireNfc { + DisposableEffect(key1 = nfcManagerVm) { + nfcManagerVm.onScan(context as Activity) + onDispose { nfcManagerVm.onPause(context) } + } + OutlinedCard( + modifier = Modifier + .verticalScroll(rememberScrollState()) + .padding(vertical = 16.dp, horizontal = 16.dp) + // Leave more space for the navigation bar. + ) { + Column( + modifier = Modifier.padding(16.dp) + ) { + // Show Ndef Record information. + WizardStepComponent( + icon = Icons.Default.Wifi, + title = stringResource(id = R.string.wifi_record), + state = WizardStepState.COMPLETED + ) { + NfcTextRow( + title = stringResource(id = R.string.ssid_title), + text = wifiData.ssid + ) + if (wifiData.password.isNotEmpty()) { + NfcPasswordRow(title = stringResource(id = R.string.password_title)) + } + if (wifiData.macAddress.isNotEmpty()) { + NfcTextRow( + title = stringResource(id = R.string.mac_address), + text = wifiData.macAddress.uppercase() + ) + } + NfcTextRow( + title = stringResource(id = R.string.authentication_title), + text = wifiData.authType.toDisplayString() + ) + NfcTextRow( + title = stringResource(id = R.string.encryption_title), + text = wifiData.encryptionMode.toDisplayString() + ) + NfcTextRow( + title = stringResource(id = R.string.message_size), + text = stringResource( + id = R.string.message_size_in_bytes, + ndefMessage.byteArrayLength + ) + ) + } + + WizardStepComponent( + icon = Icons.Default.Edit, + title = stringResource(id = R.string.discover_tag_title), + state = WizardStepState.CURRENT, + showVerticalDivider = false, + ) { + Column { + when (val e = nfcScanEvent) { + is Error -> { + // Show the error message. + ProgressItem( + text = stringResource(id = R.string.write_failed), + status = ProgressItemStatus.ERROR, + iconRightPadding = 24.dp, + ) + Text( + text = if (e.message.length > 35) e.message.slice(0..35) else e.message, + modifier = Modifier + .alpha(0.7f) + .padding(start = 48.dp), + style = MaterialTheme.typography.bodySmall, + ) + } + + Loading -> { + // Show the loading indicator. + ProgressItem( + text = stringResource(id = R.string.discovering_tag), + status = ProgressItemStatus.WORKING, + iconRightPadding = 24.dp, + ) + } + + Success -> { + ProgressItem( + text = stringResource(id = R.string.write_success), + status = ProgressItemStatus.SUCCESS, + iconRightPadding = 24.dp, + ) + Text( + text = stringResource(id = R.string.success_des), + modifier = Modifier + .alpha(0.7f) + .padding(start = 48.dp), + style = MaterialTheme.typography.bodySmall, + ) + } + + null -> { + ProgressItem( + text = stringResource(id = R.string.tap_nfc_tag), + status = ProgressItemStatus.WORKING, + iconRightPadding = 24.dp, + ) + } + } + } + } + } + } + } + } + } +} diff --git a/feature/nfc/src/main/java/no/nordicsemi/android/wifi/provisioner/feature/nfc/view/WifiScannerView.kt b/feature/nfc/src/main/java/no/nordicsemi/android/wifi/provisioner/feature/nfc/view/WifiScannerView.kt new file mode 100644 index 00000000..479de151 --- /dev/null +++ b/feature/nfc/src/main/java/no/nordicsemi/android/wifi/provisioner/feature/nfc/view/WifiScannerView.kt @@ -0,0 +1,383 @@ +package no.nordicsemi.android.wifi.provisioner.feature.nfc.view + +import android.net.wifi.ScanResult +import android.net.wifi.WifiSsid +import android.os.Build +import androidx.activity.compose.BackHandler +import androidx.annotation.RequiresApi +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.ExpandLess +import androidx.compose.material.icons.outlined.ExpandMore +import androidx.compose.material.icons.outlined.Group +import androidx.compose.material.icons.outlined.GroupRemove +import androidx.compose.material.icons.outlined.Lock +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.SnackbarHost +import androidx.compose.material3.SnackbarHostState +import androidx.compose.material3.Text +import androidx.compose.material3.pulltorefresh.PullToRefreshBox +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha +import androidx.compose.ui.draw.clip +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import no.nordicsemi.android.common.theme.view.NordicAppBar +import no.nordicsemi.android.wifi.provisioner.feature.nfc.R +import no.nordicsemi.android.wifi.provisioner.feature.nfc.mapping.Frequency +import no.nordicsemi.android.wifi.provisioner.feature.nfc.mapping.toDisplayString +import no.nordicsemi.android.wifi.provisioner.feature.nfc.permission.RequireLocationForWifi +import no.nordicsemi.android.wifi.provisioner.feature.nfc.permission.RequireWifi +import no.nordicsemi.android.wifi.provisioner.feature.nfc.uicomponent.PasswordDialog +import no.nordicsemi.android.wifi.provisioner.feature.nfc.uicomponent.RssiIconView +import no.nordicsemi.android.wifi.provisioner.feature.nfc.uicomponent.VerticalBlueBar +import no.nordicsemi.android.wifi.provisioner.feature.nfc.viewmodel.OnNavigateUpClickEvent +import no.nordicsemi.android.wifi.provisioner.feature.nfc.viewmodel.OnNetworkSelectEvent +import no.nordicsemi.android.wifi.provisioner.feature.nfc.viewmodel.OnPasswordCancelEvent +import no.nordicsemi.android.wifi.provisioner.feature.nfc.viewmodel.OnPasswordSetEvent +import no.nordicsemi.android.wifi.provisioner.feature.nfc.viewmodel.OnSortOptionSelected +import no.nordicsemi.android.wifi.provisioner.feature.nfc.viewmodel.WifiScannerViewEvent +import no.nordicsemi.android.wifi.provisioner.feature.nfc.viewmodel.WifiScannerViewModel +import no.nordicsemi.android.wifi.provisioner.nfc.domain.AuthenticationMode +import no.nordicsemi.android.wifi.provisioner.nfc.domain.EncryptionMode +import no.nordicsemi.android.wifi.provisioner.nfc.domain.Error +import no.nordicsemi.android.wifi.provisioner.nfc.domain.Loading +import no.nordicsemi.android.wifi.provisioner.nfc.domain.Success +import no.nordicsemi.android.wifi.provisioner.nfc.domain.WifiAuthTypeBelowTiramisu +import no.nordicsemi.android.wifi.provisioner.nfc.domain.WifiAuthTypeTiramisuOrAbove +import no.nordicsemi.android.wifi.provisioner.nfc.domain.WifiData +import no.nordicsemi.android.wifi.provisioner.ui.view.WifiSortView + +/** + * A composable function to display the list of available networks. + */ +@RequiresApi(Build.VERSION_CODES.M) +@OptIn(ExperimentalMaterial3Api::class) +@Composable +internal fun WifiScannerScreen() { + val wifiScannerViewModel = hiltViewModel() + val onEvent: (WifiScannerViewEvent) -> Unit = { wifiScannerViewModel.onEvent(it) } + val viewState by wifiScannerViewModel.viewState.collectAsStateWithLifecycle() + var isGroupedBySsid by rememberSaveable { mutableStateOf(false) } + val groupIcon = if (isGroupedBySsid) Icons.Outlined.GroupRemove else Icons.Outlined.Group + val snackbarHostState = remember { SnackbarHostState() } + + // Handle the back press. + BackHandler { + onEvent(OnNavigateUpClickEvent) + } + // Show the scanning screen. + Scaffold( + contentWindowInsets = WindowInsets(0, 0, 0, 0), + topBar = { + NordicAppBar( + text = stringResource(id = R.string.wifi_scanner_appbar), + showBackButton = true, + onNavigationButtonClick = { onEvent(OnNavigateUpClickEvent) }, + actions = { + // Show the group icon to group by SSID. + Icon( + imageVector = groupIcon, + contentDescription = null, + modifier = Modifier + .clip(RoundedCornerShape(8.dp)) + .clickable { + isGroupedBySsid = !isGroupedBySsid + } + .padding(8.dp) + + ) + } + ) + }, + snackbarHost = { + SnackbarHost(hostState = snackbarHostState) + }, + ) { innerPadding -> + Box( + modifier = Modifier.padding(innerPadding) + ) { + RequireWifi { + RequireLocationForWifi { + LaunchedEffect(key1 = it) { + wifiScannerViewModel.scanAvailableWifiNetworks() + } + + when (val scanningState = viewState.networks) { + is Error -> { + // Show the error message. + Column { + Box( + modifier = Modifier + .fillMaxSize(), + contentAlignment = Alignment.Center + ) { + Text(text = stringResource(id = R.string.error_while_scanning)) + Text( + text = scanningState.t.message ?: "Unknown error occurred." + ) + } + } + } + + is Loading -> { + // Show the loading indicator. + Box( + modifier = Modifier + .fillMaxSize(), + contentAlignment = Alignment.Center + ) { + CircularProgressIndicator( + modifier = Modifier.padding(16.dp) + ) + } + } + + is Success -> { + // Show the list of available networks. + Column { + WifiSortView(viewState.sortOption) { + onEvent(OnSortOptionSelected(it)) + } + PullToRefreshBox( + isRefreshing = viewState.isRefreshing, + onRefresh = { // Refreshing + wifiScannerViewModel.scanAvailableWifiNetworks() + }, + ) { + Column( + modifier = Modifier + .verticalScroll(rememberScrollState()) + .padding(bottom = 32.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + if (isGroupedBySsid) { + // Group by SSID + GroupBySsid(viewState.sortedItems, onEvent) + } else { + // Show the list of available networks grouped by SSIDs. + WifiList(viewState.sortedItems, onEvent) + } + } + } + } + } + } + when (val selectedNetwork = viewState.selectedNetwork) { + null -> { + // Do nothing + } + + else -> { + // Show the password dialog + PasswordDialog( + scanResult = selectedNetwork, + onCancelClick = { + // Dismiss the dialog + // Set the selected network to null + onEvent(OnPasswordCancelEvent) + }) { + onEvent(OnPasswordSetEvent(it)) + } + } + } + } + } + } + } +} + +/** + * A composable function to display the list of available networks. + * + * @param networks The list of available networks. + * @param onEvent The event callback. + */ +@Composable +internal fun WifiList( + networks: List, + onEvent: (WifiScannerViewEvent) -> Unit +) { + networks.forEach { network -> + NetworkItem( + network = network, + modifier = Modifier.padding(start = 16.dp, top = 8.dp, end = 16.dp, bottom = 8.dp), + onEvent = onEvent + ) + HorizontalDivider() + } +} + +@Composable +private fun NetworkItem( + network: ScanResult, + modifier: Modifier = Modifier, + onEvent: (WifiScannerViewEvent) -> Unit, +) { + val securityType = AuthenticationMode.get(network) + val isOpen = securityType.contains(WifiAuthTypeBelowTiramisu.OPEN) or + securityType.contains(WifiAuthTypeTiramisuOrAbove.OPEN) + + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + modifier = modifier + .fillMaxWidth() + .clickable { + if (isOpen) { + // Password dialog is not required for open networks. + val wifiData = WifiData( + ssid = network.SSID, + macAddress = network.BSSID, + password = "", // Empty password for open networks + authType = WifiAuthTypeBelowTiramisu.OPEN, + encryptionMode = EncryptionMode.NONE + ) + onEvent(OnPasswordSetEvent(wifiData)) + } else { + // Show the password dialog + onEvent(OnNetworkSelectEvent(network)) + } + }, + ) { + RssiIconView(network.level) + Column( + modifier = Modifier + .padding(8.dp) + .weight(1f), + verticalArrangement = Arrangement.spacedBy(4.dp) + ) { + // Display the SSID of the access point + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + Text( + text = getSSid(network.wifiSsid), + style = MaterialTheme.typography.bodyLarge + ) + } else { + Text( + text = network.SSID, + style = MaterialTheme.typography.bodyLarge + ) + } + // Display the address of the access point. + Text( + text = network.BSSID.uppercase(), + style = MaterialTheme.typography.bodyMedium, + modifier = Modifier.alpha(0.7f) + ) + // Display the security type of the access point. + Text( + text = securityType.joinToString(", ") { it.toDisplayString() }, + style = MaterialTheme.typography.bodyMedium, + modifier = Modifier.alpha(0.7f) + ) + Text( + text = stringResource( + id = R.string.channel_number, + Frequency.toChannelNumber(network.frequency), + Frequency.get(network.frequency) + ), + style = MaterialTheme.typography.bodyMedium, + modifier = Modifier.alpha(0.7f), + ) + } + if (!isOpen) { + // Show the lock icon for protected networks. + Icon( + imageVector = Icons.Outlined.Lock, + contentDescription = null, + ) + } + } +} + +/** + * Returns the SSID of the network from given [WifiSsid]. + */ +fun getSSid(wifiSsid: WifiSsid?): String { + return wifiSsid.toString().replace("\"", "") +} + +@Composable +private fun GroupBySsid( + networks: List, + onEvent: (WifiScannerViewEvent) -> Unit, +) { + networks.groupBy { it.SSID }.forEach { (ssid, network) -> + var isExpanded by rememberSaveable { mutableStateOf(false) } + val expandIcon = + if (isExpanded) Icons.Outlined.ExpandLess else Icons.Outlined.ExpandMore + + // Skip hidden networks. + if (ssid == null || ssid.isEmpty()) { + return@forEach + } + // Show the network. + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + modifier = Modifier + .fillMaxWidth() + .clickable { isExpanded = !isExpanded } + .padding(start = 16.dp, 8.dp) + ) { + Text(text = ssid) + Spacer(modifier = Modifier.weight(1f)) + Icon( + imageVector = expandIcon, + contentDescription = null, + modifier = Modifier.padding(8.dp) + ) + } + // Show networks under the same SSID. + AnimatedVisibility(visible = isExpanded) { + Column { + HorizontalDivider() + VerticalBlueBar { + network.forEach { scanResult -> + NetworkItem( + network = scanResult, + modifier = Modifier.padding(8.dp), + onEvent = onEvent + ) + if (scanResult != network.last()) { + HorizontalDivider( + modifier = Modifier.padding(start = 16.dp), + thickness = 0.5.dp + ) + } + } + } + } + } + HorizontalDivider() + } +} diff --git a/feature/nfc/src/main/java/no/nordicsemi/android/wifi/provisioner/feature/nfc/viewmodel/NfcManagerViewModel.kt b/feature/nfc/src/main/java/no/nordicsemi/android/wifi/provisioner/feature/nfc/viewmodel/NfcManagerViewModel.kt new file mode 100644 index 00000000..4891ef86 --- /dev/null +++ b/feature/nfc/src/main/java/no/nordicsemi/android/wifi/provisioner/feature/nfc/viewmodel/NfcManagerViewModel.kt @@ -0,0 +1,42 @@ +package no.nordicsemi.android.wifi.provisioner.feature.nfc.viewmodel + +import android.app.Activity +import android.nfc.NdefMessage +import androidx.lifecycle.SavedStateHandle +import dagger.hilt.android.lifecycle.HiltViewModel +import no.nordicsemi.android.common.navigation.Navigator +import no.nordicsemi.android.common.navigation.viewmodel.SimpleNavigationViewModel +import no.nordicsemi.android.wifi.provisioner.feature.nfc.NfcPublishDestination +import no.nordicsemi.android.wifi.provisioner.nfc.NdefMessageBuilder +import no.nordicsemi.android.wifi.provisioner.nfc.NfcManagerForWifi +import javax.inject.Inject + +/** + * ViewModel for the NFC manager. + */ +@HiltViewModel +internal class NfcManagerViewModel @Inject constructor( + private val nfcManagerForWifi: NfcManagerForWifi, + ndefMessageBuilder: NdefMessageBuilder, + private val navigator: Navigator, + savedStateHandle: SavedStateHandle, +) : SimpleNavigationViewModel(navigator, savedStateHandle) { + val wifiData = parameterOf(NfcPublishDestination) + val ndefMessage: NdefMessage = ndefMessageBuilder.createNdefMessage(wifiData) + val nfcScanEvent = nfcManagerForWifi.nfcScanEvent + + fun onScan(activity: Activity) { + nfcManagerForWifi.onNfcTap( + activity = activity, + message = ndefMessage + ) + } + + fun onPause(activity: Activity) { + nfcManagerForWifi.onPause(activity) + } + + fun onBackNavigation() { + navigator.navigateUp() + } +} \ No newline at end of file diff --git a/feature/nfc/src/main/java/no/nordicsemi/android/wifi/provisioner/feature/nfc/viewmodel/NfcProvisioningViewEvent.kt b/feature/nfc/src/main/java/no/nordicsemi/android/wifi/provisioner/feature/nfc/viewmodel/NfcProvisioningViewEvent.kt new file mode 100644 index 00000000..f58e5d89 --- /dev/null +++ b/feature/nfc/src/main/java/no/nordicsemi/android/wifi/provisioner/feature/nfc/viewmodel/NfcProvisioningViewEvent.kt @@ -0,0 +1,27 @@ +package no.nordicsemi.android.wifi.provisioner.feature.nfc.viewmodel + +import no.nordicsemi.android.wifi.provisioner.nfc.domain.WifiData + +/** + * A sealed class to represent the events that can be triggered from the UI. + */ +internal sealed interface NfcProvisioningViewEvent + +/** + * Event triggered when the wifi ssid scan button is clicked. + */ +internal data object OnScanClickEvent : NfcProvisioningViewEvent + +/** + * Event triggered when the wifi network is added manually. + * + * @param wifiData The Wi-Fi data. + */ +internal data class OnAddWifiNetworkClickEvent( + val wifiData: WifiData, +) : NfcProvisioningViewEvent + +/** + * Event triggered when the back button is clicked. + */ +internal data object OnBackClickEvent : NfcProvisioningViewEvent diff --git a/feature/nfc/src/main/java/no/nordicsemi/android/wifi/provisioner/feature/nfc/viewmodel/NfcProvisioningViewModel.kt b/feature/nfc/src/main/java/no/nordicsemi/android/wifi/provisioner/feature/nfc/viewmodel/NfcProvisioningViewModel.kt new file mode 100644 index 00000000..c066557b --- /dev/null +++ b/feature/nfc/src/main/java/no/nordicsemi/android/wifi/provisioner/feature/nfc/viewmodel/NfcProvisioningViewModel.kt @@ -0,0 +1,33 @@ +package no.nordicsemi.android.wifi.provisioner.feature.nfc.viewmodel + +import android.os.Build +import androidx.annotation.RequiresApi +import androidx.lifecycle.ViewModel +import dagger.hilt.android.lifecycle.HiltViewModel +import no.nordicsemi.android.common.navigation.Navigator +import no.nordicsemi.android.wifi.provisioner.feature.nfc.NfcPublishDestination +import no.nordicsemi.android.wifi.provisioner.feature.nfc.WifiScannerDestination +import javax.inject.Inject + +@RequiresApi(Build.VERSION_CODES.M) +@HiltViewModel +internal class NfcProvisioningViewModel @Inject constructor( + private val navigator: Navigator, +) : ViewModel() { + /** + * Handles the events from the UI. + */ + fun onEvent(event: NfcProvisioningViewEvent) { + when (event) { + is OnScanClickEvent -> { + navigator.navigateTo(WifiScannerDestination) + } + + OnBackClickEvent -> navigator.navigateUp() + is OnAddWifiNetworkClickEvent -> { + // Navigate to the NFC screen with the Wi-Fi data. + navigator.navigateTo(NfcPublishDestination, event.wifiData) + } + } + } +} diff --git a/feature/nfc/src/main/java/no/nordicsemi/android/wifi/provisioner/feature/nfc/viewmodel/WifiScannerViewModel.kt b/feature/nfc/src/main/java/no/nordicsemi/android/wifi/provisioner/feature/nfc/viewmodel/WifiScannerViewModel.kt new file mode 100644 index 00000000..3b8f69a9 --- /dev/null +++ b/feature/nfc/src/main/java/no/nordicsemi/android/wifi/provisioner/feature/nfc/viewmodel/WifiScannerViewModel.kt @@ -0,0 +1,169 @@ +package no.nordicsemi.android.wifi.provisioner.feature.nfc.viewmodel + +import android.net.wifi.ScanResult +import android.os.Build +import androidx.annotation.RequiresApi +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.launch +import no.nordicsemi.android.common.navigation.Navigator +import no.nordicsemi.android.wifi.provisioner.feature.nfc.NfcPublishDestination +import no.nordicsemi.android.wifi.provisioner.feature.nfc.WifiScannerDestination +import no.nordicsemi.android.wifi.provisioner.nfc.WifiManagerRepository +import no.nordicsemi.android.wifi.provisioner.nfc.domain.AuthenticationMode +import no.nordicsemi.android.wifi.provisioner.nfc.domain.EncryptionMode +import no.nordicsemi.android.wifi.provisioner.nfc.domain.Loading +import no.nordicsemi.android.wifi.provisioner.nfc.domain.NetworkState +import no.nordicsemi.android.wifi.provisioner.nfc.domain.Success +import no.nordicsemi.android.wifi.provisioner.nfc.domain.WifiAuthTypeBelowTiramisu +import no.nordicsemi.android.wifi.provisioner.nfc.domain.WifiAuthTypeTiramisuOrAbove +import no.nordicsemi.android.wifi.provisioner.nfc.domain.WifiData +import no.nordicsemi.kotlin.wifi.provisioner.feature.common.event.WifiSortOption +import javax.inject.Inject +import kotlin.time.Duration.Companion.seconds + +/** + * A sealed class to represent the events that can be triggered from the UI. + */ +internal sealed interface WifiScannerViewEvent + +/** + * Event triggered when the wifi network is selected. + * + * @param network The selected network. + */ +data class OnNetworkSelectEvent( + val network: ScanResult, +) : WifiScannerViewEvent + +/** + * Event triggered when the password is confirmed. + * + * @param wifiData The Wi-Fi data. + */ +data class OnPasswordSetEvent( + val wifiData: WifiData, +) : WifiScannerViewEvent + +data object OnPasswordCancelEvent : WifiScannerViewEvent + +/** + * Event triggered when the back button is clicked. + */ +internal data object OnNavigateUpClickEvent : WifiScannerViewEvent + +internal data class OnSortOptionSelected(val sortOption: WifiSortOption) : WifiScannerViewEvent + +/** + * A wrapper class to represent the view state of the Wi-Fi scanner screen. + * + * @param networks The state of the available Wi-Fi networks. + * @param selectedNetwork The selected Wi-Fi network. + * @param sortOption The selected sort option. + */ +data class WifiScannerViewState( + val networks: NetworkState> = Loading(), + val selectedNetwork: ScanResult? = null, + val sortOption: WifiSortOption = WifiSortOption.RSSI, + val isRefreshing: Boolean = false, +) { + private val items = (networks as? Success)?.data ?: emptyList() + val sortedItems: List = when (sortOption) { + WifiSortOption.NAME -> items.sortedBy { it.SSID.lowercase() } + WifiSortOption.RSSI -> items.sortedByDescending { it.level } + } +} + +@RequiresApi(Build.VERSION_CODES.M) +@HiltViewModel +internal class WifiScannerViewModel @Inject constructor( + private val navigator: Navigator, + private val wifiManager: WifiManagerRepository, +) : ViewModel() { + private val _viewState = MutableStateFlow(WifiScannerViewState()) + val viewState = _viewState.asStateFlow() + + /** + * Scans for available Wi-Fi networks. + */ + fun scanAvailableWifiNetworks() { + try { + _viewState.value = _viewState.value.copy(isRefreshing = true) + wifiManager.onScan() + wifiManager.networkState.onEach { scanResults -> + _viewState.value = _viewState.value.copy(networks = scanResults) + }.launchIn(viewModelScope) + viewModelScope.launch { + delay(5.seconds) + _viewState.value = _viewState.value.copy(isRefreshing = false) + } + } catch (e: Exception) { + e.printStackTrace() + } + } + + private fun onBackClick() { + navigator.navigateUp() + } + + fun onEvent(event: WifiScannerViewEvent) { + when (event) { + is OnNetworkSelectEvent -> { + val isOpen = AuthenticationMode.get(event.network) + .contains(WifiAuthTypeBelowTiramisu.OPEN) or AuthenticationMode.get(event.network) + .contains(WifiAuthTypeTiramisuOrAbove.OPEN) + // If the network is open, navigate to the NFC screen + if (isOpen) { + navigateToNfcScan( + WifiData( + ssid = event.network.SSID, + macAddress = event.network.BSSID, + password = "", // Empty password for open network. + authType = WifiAuthTypeBelowTiramisu.OPEN, + encryptionMode = EncryptionMode.NONE // No encryption for open network. + ) + ) + } else { + // Show the dialog to enter the password for the selected network. + _viewState.value = _viewState.value.copy(selectedNetwork = event.network) + } + } + + OnNavigateUpClickEvent -> onBackClick() + is OnPasswordSetEvent -> { + // Close the dialog and navigate to the NFC screen. + // Needed to clear the selected network, otherwise the dialog will be shown on back press. + _viewState.value = _viewState.value.copy(selectedNetwork = null) + navigateToNfcScan(event.wifiData) + } + + OnPasswordCancelEvent -> _viewState.value = + _viewState.value.copy(selectedNetwork = null) + + is OnSortOptionSelected -> _viewState.value = + _viewState.value.copy(sortOption = event.sortOption) + } + } + + /** + * Navigates to the NFC scan screen. + * + * @param wifiData The Wi-Fi data. + */ + private fun navigateToNfcScan(wifiData: WifiData) { + navigator.navigateTo( + to = NfcPublishDestination, + args = wifiData + ) { + popUpTo(WifiScannerDestination.toString()) { + inclusive = true + } + } + } +} diff --git a/feature/nfc/src/main/res/values/strings.xml b/feature/nfc/src/main/res/values/strings.xml new file mode 100644 index 00000000..93d62d91 --- /dev/null +++ b/feature/nfc/src/main/res/values/strings.xml @@ -0,0 +1,65 @@ + + + Provision over NFC + Wi-Fi networks + Publish + + Enter Wi-Fi credentials + Enter the Wi-Fi credentials manually. + Search Wi-Fi networks + Scans for nearby Wi-Fi networks. + + + LOCATION PERMISSION REQUIRED + Location permission is required to scan for Wi-Fi networks. + + WI-FI PERMISSION REQUIRED + Wi-Fi permission is required to scan for Wi-Fi networks. + WI-FI DISABLED + Wi-Fi is disabled. Please enable Wi-Fi to scan for Wi-Fi networks. + Enable Wi-Fi + WI-FI NOT AVAILABLE + Wi-Fi is not available on this device. We won\'t be able to provision the device. + + Grant Permission + Settings + + Error occurred while scanning Wi-Fi networks. + Unknown error occurred. + + + Setup Wi-Fi + Authentication + Select authentication + SSID + Enter SSID + SSID is required + MAC address + MAC address (optional) + Enter MAC address + The MAC address consists of hexadecimal digits in the format XX:XX:XX:XX:XX:XX. + Encryption + Select encryption + Password + Enter password + Password is required + Channel number: %d - %s + + + Wi-Fi record + SSID + Password + Encryption + Authentication + Message size + %d bytes + Discover tag + Tap an NFC tag + Tag written successfully + The device may take a few seconds to connect to Wi-Fi. + Discovering tag… + Failed to write to the NFC tag + + Cancel + Confirm + \ No newline at end of file diff --git a/feature/softap/src/main/java/no/nordicsemi/android/wifi/provisioner/softap/view/ProvisionOverWifiSection.kt b/feature/softap/src/main/java/no/nordicsemi/android/wifi/provisioner/softap/view/ProvisionOverWifiSection.kt deleted file mode 100644 index 5812bafa..00000000 --- a/feature/softap/src/main/java/no/nordicsemi/android/wifi/provisioner/softap/view/ProvisionOverWifiSection.kt +++ /dev/null @@ -1,39 +0,0 @@ -package no.nordicsemi.android.wifi.provisioner.softap.view - -import androidx.compose.foundation.clickable -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.layout.width -import androidx.compose.foundation.layout.widthIn -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.OutlinedCard -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.ui.Modifier -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.unit.dp -import no.nordicsemi.android.wifi.provisioner.feature.softap.R -import no.nordicsemi.android.wifi.provisioner.ui.view.section.SectionTitle - - -@Composable -fun ProvisionOverWifiSection(onClick: () -> Unit) { - OutlinedCard( - modifier = Modifier - .widthIn(max = 600.dp) - .padding(all = 8.dp) - .clickable(onClick = onClick) - ) { - Column(modifier = Modifier.padding(all = 16.dp)) { - SectionTitle(text = stringResource(R.string.provision_over_wifi)) - Spacer(modifier = Modifier.size(8.dp)) - Text( - text = stringResource(R.string.provision_over_wifi_rationale), - style = MaterialTheme.typography.bodyMedium - ) - } - } -} \ No newline at end of file diff --git a/feature/softap/src/main/res/values/strings.xml b/feature/softap/src/main/res/values/strings.xml index e914b1a7..c7b5b10e 100644 --- a/feature/softap/src/main/res/values/strings.xml +++ b/feature/softap/src/main/res/values/strings.xml @@ -1,6 +1,6 @@ - "Provision over Wi-Fi" + Provision over Wi-Fi Connect to Device Enter the SSID of the SoftAP you want to connect to. \n\nNote: Make sure the nRF 700x device is powered on and in range. @@ -41,9 +41,6 @@ Wi-Fi credentials sent Send Wi-Fi credentials Optionally verify provisioning state - This mode uses temporary a Wi-Fi network (SoftAP) - created by the provisionee to send Wi-Fi credentials. Communication is encrypted using TLS. - Locating provisioned device… Verification completed Please enable Wi-Fi! diff --git a/feature/ui/src/main/java/no/nordicsemi/android/wifi/provisioner/ui/view/section/ProvisionSection.kt b/feature/ui/src/main/java/no/nordicsemi/android/wifi/provisioner/ui/view/section/ProvisionSection.kt new file mode 100644 index 00000000..a5d4bacd --- /dev/null +++ b/feature/ui/src/main/java/no/nordicsemi/android/wifi/provisioner/ui/view/section/ProvisionSection.kt @@ -0,0 +1,57 @@ +package no.nordicsemi.android.wifi.provisioner.ui.view.section + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.widthIn +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedCard +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import no.nordicsemi.android.common.theme.NordicTheme + +/** + * A composable that displays a section with a title and a rationale. + * + * @param sectionTitle The title of the section. + * @param sectionRational The rationale of the section. + * @param onClick The action to be performed when the section is clicked. + */ +@Composable +fun ProvisionSection( + sectionTitle: String, + sectionRational: String, + onClick: () -> Unit +) { + OutlinedCard( + modifier = Modifier + .widthIn(max = 600.dp) + .clickable { onClick() } + ) { + Column(modifier = Modifier.padding(16.dp)) { + SectionTitle(text = sectionTitle) + Spacer(modifier = Modifier.size(8.dp)) + Text( + text = sectionRational, + style = MaterialTheme.typography.bodyMedium + ) + } + } +} + +@Preview +@Composable +private fun ProvisionSectionPreview() { + NordicTheme { + ProvisionSection( + sectionTitle = "Provision over BLE", + sectionRational = "Provision over BLE rationale.", + onClick = {} + ) + } +} \ No newline at end of file diff --git a/feature/ui/src/main/res/values/strings.xml b/feature/ui/src/main/res/values/strings.xml index f53f1fd5..0359b547 100644 --- a/feature/ui/src/main/res/values/strings.xml +++ b/feature/ui/src/main/res/values/strings.xml @@ -1,54 +1,17 @@ - Change - - Sort by: - Name - RSSI - - Device status - Provisioning data - Upload status - Unknown error + Icon representing data available in the section. - Start - Next device - Finish - Set password - Password - **** **** - Dismiss Accept + Dismiss Clear - Device status - Disconnected - Wi-Fi status - Selected Wi-Fi - Start provisioning - Version - Scanning error - Unprovision - Select password - Provision - Provisioning status - Unrovisioning status - Success - - Icon representing data available in the section. - - Authentication - Association - Obtaining IP - Connected - Disconnected - Unprovisioned - Error occurred during provisioning. - Icon indicating wifi and it\'s authentication method. - - Wi-Fi + Sort by: + Name + RSSI + Select Wi-Fi IPv4: %s SSID: %s BSSID: %s @@ -59,32 +22,8 @@ 5 GHz Any - Authentication error. - The specified network could not be find. - Timeout occurred. - Could not obtain IP from provided provisioning information. - Could not connect to provisioned network. - - Connection info - Wi-Fi info - - Scan params - Passive: %s - Period: %s [ms] - Group channels: %s - + Password + Set password Hide password Show password - - Authentication - Association - Obtaining IP - Result - Connected - Connection failed - - V %d - Select Wi-Fi - - Persistent storage \ No newline at end of file diff --git a/lib/nfc/build.gradle.kts b/lib/nfc/build.gradle.kts new file mode 100644 index 00000000..3a619667 --- /dev/null +++ b/lib/nfc/build.gradle.kts @@ -0,0 +1,13 @@ +plugins { + alias(libs.plugins.nordic.library) + alias(libs.plugins.nordic.kotlin) + alias(libs.plugins.kotlin.parcelize) +} + +android { + namespace = "no.nordicsemi.android.wifi.provisioner.nfc" +} + +dependencies { + implementation(libs.nordic.ble.ktx) +} \ No newline at end of file diff --git a/lib/nfc/src/main/AndroidManifest.xml b/lib/nfc/src/main/AndroidManifest.xml new file mode 100644 index 00000000..1d26c87a --- /dev/null +++ b/lib/nfc/src/main/AndroidManifest.xml @@ -0,0 +1,2 @@ + + \ No newline at end of file diff --git a/lib/nfc/src/main/java/no/nordicsemi/android/wifi/provisioner/nfc/NdefMessageBuilder.kt b/lib/nfc/src/main/java/no/nordicsemi/android/wifi/provisioner/nfc/NdefMessageBuilder.kt new file mode 100644 index 00000000..bd85e2cb --- /dev/null +++ b/lib/nfc/src/main/java/no/nordicsemi/android/wifi/provisioner/nfc/NdefMessageBuilder.kt @@ -0,0 +1,158 @@ +package no.nordicsemi.android.wifi.provisioner.nfc + +import android.nfc.NdefMessage +import android.nfc.NdefRecord +import no.nordicsemi.android.wifi.provisioner.nfc.domain.AuthenticationMode +import no.nordicsemi.android.wifi.provisioner.nfc.domain.EncryptionMode +import no.nordicsemi.android.wifi.provisioner.nfc.domain.WifiAuthTypeBelowTiramisu +import no.nordicsemi.android.wifi.provisioner.nfc.domain.WifiAuthTypeTiramisuOrAbove +import no.nordicsemi.android.wifi.provisioner.nfc.domain.WifiData +import no.nordicsemi.android.wifi.provisioner.nfc.domain.WifiHandoverDataType.AUTH_TYPE_EXPECTED_SIZE +import no.nordicsemi.android.wifi.provisioner.nfc.domain.WifiHandoverDataType.AUTH_TYPE_FIELD_ID +import no.nordicsemi.android.wifi.provisioner.nfc.domain.WifiHandoverDataType.AUTH_TYPE_OPEN +import no.nordicsemi.android.wifi.provisioner.nfc.domain.WifiHandoverDataType.AUTH_TYPE_SHARED +import no.nordicsemi.android.wifi.provisioner.nfc.domain.WifiHandoverDataType.AUTH_TYPE_WPA2_EAP +import no.nordicsemi.android.wifi.provisioner.nfc.domain.WifiHandoverDataType.AUTH_TYPE_WPA2_PSK +import no.nordicsemi.android.wifi.provisioner.nfc.domain.WifiHandoverDataType.AUTH_TYPE_WPA_EAP +import no.nordicsemi.android.wifi.provisioner.nfc.domain.WifiHandoverDataType.AUTH_TYPE_WPA_PSK +import no.nordicsemi.android.wifi.provisioner.nfc.domain.WifiHandoverDataType.AUTH_TYPE_WPA_WPA2_PSK +import no.nordicsemi.android.wifi.provisioner.nfc.domain.WifiHandoverDataType.CREDENTIAL_FIELD_ID +import no.nordicsemi.android.wifi.provisioner.nfc.domain.WifiHandoverDataType.ENC_TYPE_AES +import no.nordicsemi.android.wifi.provisioner.nfc.domain.WifiHandoverDataType.ENC_TYPE_AES_TKIP +import no.nordicsemi.android.wifi.provisioner.nfc.domain.WifiHandoverDataType.ENC_TYPE_FIELD_ID +import no.nordicsemi.android.wifi.provisioner.nfc.domain.WifiHandoverDataType.ENC_TYPE_NONE +import no.nordicsemi.android.wifi.provisioner.nfc.domain.WifiHandoverDataType.ENC_TYPE_TKIP +import no.nordicsemi.android.wifi.provisioner.nfc.domain.WifiHandoverDataType.ENC_TYPE_WEP +import no.nordicsemi.android.wifi.provisioner.nfc.domain.WifiHandoverDataType.MAC_ADDRESS_FIELD_ID +import no.nordicsemi.android.wifi.provisioner.nfc.domain.WifiHandoverDataType.MAX_MAC_ADDRESS_SIZE_BYTES +import no.nordicsemi.android.wifi.provisioner.nfc.domain.WifiHandoverDataType.NETWORK_KEY_FIELD_ID +import no.nordicsemi.android.wifi.provisioner.nfc.domain.WifiHandoverDataType.NFC_TOKEN_MIME_TYPE +import no.nordicsemi.android.wifi.provisioner.nfc.domain.WifiHandoverDataType.SSID_FIELD_ID +import java.nio.ByteBuffer +import java.nio.charset.Charset + +/** + * This class is responsible for creating the NDEF message for the WiFi data. + */ +class NdefMessageBuilder { + + /** + * Creates the NDEF message for the WiFi data. + * + * @param wifiNetwork the WiFi data to be written to the NDEF message. + * @return the NDEF message for the WiFi data. + */ + fun createNdefMessage(wifiNetwork: WifiData): NdefMessage { + return generateNdefMessage(wifiNetwork) + } + + /** + * Generates the NDEF message for the given WiFi network. + * + * @param wifiNetwork the WiFi network to be written to the NDEF message. + */ + private fun generateNdefMessage(wifiNetwork: WifiData): NdefMessage { + val payload: ByteArray = generateNdefPayload(wifiNetwork) + val empty = byteArrayOf() + + val mimeRecord = NdefRecord( + NdefRecord.TNF_MIME_MEDIA, + NFC_TOKEN_MIME_TYPE.toByteArray(Charset.forName("US-ASCII")), + empty, + payload + ) + + return NdefMessage(arrayOf(mimeRecord)) + } + + /** + * Generates the NDEF payload for the given WiFi network. + * + * @param wifiNetwork the WiFi network to be written to the NDEF message. + */ + private fun generateNdefPayload(wifiNetwork: WifiData): ByteArray { + val ssid: String = wifiNetwork.ssid + val ssidSize = ssid.toByteArray().size.toShort() + val authType: Short = getAuthBytes(wifiNetwork.authType) + val networkKey: String = wifiNetwork.password + val networkKeySize = networkKey.toByteArray().size.toShort() + val encType = getEncByte(wifiNetwork.encryptionMode) + + val macAddressBufferSize = if (wifiNetwork.macAddress.isNotEmpty()) 10 else 0 + /* Fill buffer */ + // size of required credential attributes + val bufferSize = 24 + ssidSize + networkKeySize + macAddressBufferSize + + // Create a buffer with the required size + val buffer = ByteBuffer.allocate(bufferSize) + buffer.putShort(CREDENTIAL_FIELD_ID) + buffer.putShort((bufferSize - 4).toShort()) + + // Add the SSID + buffer.putShort(SSID_FIELD_ID) + buffer.putShort(ssidSize) + buffer.put(ssid.toByteArray()) + + // Add authentication type + buffer.putShort(AUTH_TYPE_FIELD_ID) + buffer.putShort(AUTH_TYPE_EXPECTED_SIZE) + buffer.putShort(authType) + + // Add encryption type + buffer.putShort(ENC_TYPE_FIELD_ID) + buffer.putShort(2.toShort()) + buffer.putShort(encType) + + // Add network key / password + buffer.putShort(NETWORK_KEY_FIELD_ID) + buffer.putShort(networkKeySize) + buffer.put(networkKey.toByteArray()) + + // Add MAC address if available + if (wifiNetwork.macAddress.isNotEmpty()) { + // Convert the MAC address string to a ByteArray + val macAddress = wifiNetwork.macAddress.split(":") + .map { it.toInt(16).toByte() } + .toByteArray() + + // Add the MAC address + buffer.putShort(MAC_ADDRESS_FIELD_ID) + buffer.putShort(MAX_MAC_ADDRESS_SIZE_BYTES) + buffer.put(macAddress) + } + + return buffer.array() + } + + /** + * Returns the encryption type in bytes. + * + * @param enc the encryption type. + */ + private fun getEncByte(enc: EncryptionMode): Short { + return when (enc) { + EncryptionMode.NONE -> ENC_TYPE_NONE + EncryptionMode.WEP -> ENC_TYPE_WEP + EncryptionMode.TKIP -> ENC_TYPE_TKIP + EncryptionMode.AES -> ENC_TYPE_AES + EncryptionMode.AES_TKIP -> ENC_TYPE_AES_TKIP + } + } + + /** + * Returns the authentication type in bytes. + * + * @param auth the authentication type. + */ + private fun getAuthBytes(auth: AuthenticationMode): Short { + return when (auth) { + WifiAuthTypeBelowTiramisu.WEP, WifiAuthTypeTiramisuOrAbove.WEP -> AUTH_TYPE_SHARED + WifiAuthTypeBelowTiramisu.WPA_PSK, WifiAuthTypeTiramisuOrAbove.WPA_PSK -> AUTH_TYPE_WPA_PSK + WifiAuthTypeBelowTiramisu.WPA_EAP -> AUTH_TYPE_WPA_EAP + WifiAuthTypeBelowTiramisu.WPA2_PSK -> AUTH_TYPE_WPA2_PSK + WifiAuthTypeBelowTiramisu.WPA_WPA2_PSK -> AUTH_TYPE_WPA_WPA2_PSK + WifiAuthTypeBelowTiramisu.WPA2_EAP, WifiAuthTypeTiramisuOrAbove.WPA2_EAP -> AUTH_TYPE_WPA2_EAP + else -> AUTH_TYPE_OPEN + } + } +} \ No newline at end of file diff --git a/lib/nfc/src/main/java/no/nordicsemi/android/wifi/provisioner/nfc/NfcManagerForWifi.kt b/lib/nfc/src/main/java/no/nordicsemi/android/wifi/provisioner/nfc/NfcManagerForWifi.kt new file mode 100644 index 00000000..70dfed82 --- /dev/null +++ b/lib/nfc/src/main/java/no/nordicsemi/android/wifi/provisioner/nfc/NfcManagerForWifi.kt @@ -0,0 +1,121 @@ +package no.nordicsemi.android.wifi.provisioner.nfc + +import android.app.Activity +import android.nfc.NdefMessage +import android.nfc.NfcAdapter +import android.nfc.Tag +import android.nfc.tech.Ndef +import android.nfc.tech.NdefFormatable +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow + +enum class NfcFlags(val value: Int) { + NFC_A(NfcAdapter.FLAG_READER_NFC_A), + NFC_B(NfcAdapter.FLAG_READER_NFC_B), + NFC_F(NfcAdapter.FLAG_READER_NFC_F), + NFC_V(NfcAdapter.FLAG_READER_NFC_V), + NFC_BARCODE(NfcAdapter.FLAG_READER_NFC_BARCODE), +} + +/** The Nfc reader flags for the NfcAdapter. */ +private val flags = setOf( + NfcFlags.NFC_A, + NfcFlags.NFC_B, + NfcFlags.NFC_F, + NfcFlags.NFC_V, + NfcFlags.NFC_BARCODE +).fold(0) { acc, flag -> acc or flag.value } + +sealed interface NfcScanEvent +data object Loading : NfcScanEvent +data object Success : NfcScanEvent +data class Error(val message: String) : NfcScanEvent + +/** + * A class that manages the NFC adapter for the wifi provisioning. + */ +class NfcManagerForWifi( + private val nfcAdapter: NfcAdapter?, +) { + private val _nfcScanEvent = MutableStateFlow(null) + val nfcScanEvent = _nfcScanEvent.asStateFlow() + private var ndefMessage: NdefMessage? = null + + /** + * Handles the NFC tap event. + * @param activity the activity. + * @param message the Ndef message. + */ + fun onNfcTap(activity: Activity, message: NdefMessage) { + _nfcScanEvent.value = null + ndefMessage = message + nfcAdapter?.takeIf { it.isEnabled }?.let { + val readerFlag = getReaderFlag() + it.enableReaderMode(activity, ::onTagDiscovered, readerFlag, null) + } + } + + /** + * Callback when tag is discovered. + * @param tag the discovered tag. + */ + private fun onTagDiscovered(tag: Tag?) { + _nfcScanEvent.value = Loading + try { + tag?.let { + ndefMessage?.let { + if (tag.techList.contains(Ndef::class.java.name)) { + // Write the Ndef message to the tag. + val ndef = Ndef.get(tag) + try { + ndef.connect() + ndef.writeNdefMessage(it) + ndef.close() + _nfcScanEvent.value = Success + } catch (e: Exception) { + _nfcScanEvent.value = Error(e.message ?: "Error writing NDEF message.") + e.printStackTrace() + } + } else if (tag.techList.contains(NdefFormatable::class.java.name)) { + // Format the tag and write the Ndef message. + val ndefFormatable = NdefFormatable.get(tag) + try { + ndefFormatable.connect() + ndefFormatable.format(it) + ndefFormatable.close() + _nfcScanEvent.value = Success + } catch (e: Exception) { + _nfcScanEvent.value = Error(e.message ?: "Error formatting NDEF message.") + e.printStackTrace() + } + } else { + // The tag does not support Ndef or NdefFormatable. + // Show an error message. + _nfcScanEvent.value = Error("Tag does not support Ndef or NdefFormatable.") + } + } + } + } catch (e: Exception) { + _nfcScanEvent.value = Error(e.message ?: "Unknown error occurred") + e.printStackTrace() + } + + } + + /** + * Returns each NfcFlags and plays sounds while scanning if the isSoundOn parameter is set to ON. + */ + private fun getReaderFlag(): Int { + val soundFlag = 0 + return flags or soundFlag + } + + + /** + * Pauses the NFC reader. + * @param activity the activity. + */ + fun onPause(activity: Activity) { + nfcAdapter?.disableReaderMode(activity) + } +} \ No newline at end of file diff --git a/lib/nfc/src/main/java/no/nordicsemi/android/wifi/provisioner/nfc/WifiManagerRepository.kt b/lib/nfc/src/main/java/no/nordicsemi/android/wifi/provisioner/nfc/WifiManagerRepository.kt new file mode 100644 index 00000000..247cc16d --- /dev/null +++ b/lib/nfc/src/main/java/no/nordicsemi/android/wifi/provisioner/nfc/WifiManagerRepository.kt @@ -0,0 +1,66 @@ +package no.nordicsemi.android.wifi.provisioner.nfc + +import android.Manifest +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import android.content.IntentFilter +import android.net.wifi.ScanResult +import android.net.wifi.WifiManager +import android.os.Build +import androidx.annotation.RequiresApi +import androidx.annotation.RequiresPermission +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +import no.nordicsemi.android.wifi.provisioner.nfc.domain.Error +import no.nordicsemi.android.wifi.provisioner.nfc.domain.Loading +import no.nordicsemi.android.wifi.provisioner.nfc.domain.NetworkState +import no.nordicsemi.android.wifi.provisioner.nfc.domain.Success + +/** + * A repository class to manage the Wi-Fi scan. + */ +class WifiManagerRepository( + context: Context, +) { + private var wifiManager: WifiManager = + context.applicationContext.getSystemService(Context.WIFI_SERVICE) as WifiManager + private val _networkState = MutableStateFlow>>(Loading()) + val networkState = _networkState.asStateFlow() + + /** + * Broadcast receiver to receive the scan results. + */ + private val wifiScanReceiver = object : BroadcastReceiver() { + @RequiresApi(Build.VERSION_CODES.M) + @RequiresPermission( + allOf = [ + Manifest.permission.ACCESS_FINE_LOCATION, + Manifest.permission.ACCESS_WIFI_STATE, + ] + ) + override fun onReceive(context: Context, intent: Intent) { + try { + intent.getBooleanExtra(WifiManager.EXTRA_RESULTS_UPDATED, false) + _networkState.value = Success(wifiManager.scanResults) + } catch (e: Exception) { + e.printStackTrace() + _networkState.value = Error(e) + } + } + } + + init { + // Register the broadcast receiver + val intentFilter = IntentFilter() + intentFilter.addAction(WifiManager.SCAN_RESULTS_AVAILABLE_ACTION) + context.registerReceiver(wifiScanReceiver, intentFilter) + } + + /** + * This method is used to start the wifi scan. + */ + fun onScan() { + wifiManager.startScan() + } +} \ No newline at end of file diff --git a/lib/nfc/src/main/java/no/nordicsemi/android/wifi/provisioner/nfc/domain/AuthenticationMode.kt b/lib/nfc/src/main/java/no/nordicsemi/android/wifi/provisioner/nfc/domain/AuthenticationMode.kt new file mode 100644 index 00000000..598be336 --- /dev/null +++ b/lib/nfc/src/main/java/no/nordicsemi/android/wifi/provisioner/nfc/domain/AuthenticationMode.kt @@ -0,0 +1,107 @@ +package no.nordicsemi.android.wifi.provisioner.nfc.domain + +import android.net.wifi.ScanResult +import android.os.Build +import android.os.Parcelable +import kotlinx.parcelize.Parcelize + +/** + * Interface that represents the authentication mode of a wifi network. + */ +@Parcelize +sealed interface AuthenticationMode : Parcelable { + + companion object { + + fun get(scanResult: ScanResult): List { + return when { + Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU -> { + WifiAuthTypeTiramisuOrAbove.getMatchedAuthMode(scanResult.securityTypes) + } + + else -> { + listOf(WifiAuthTypeBelowTiramisu.getSecurityTypes(scanResult)) + } + } + } + } +} + +/** + * Enum class that represents the authentication mode of a wifi network. + * + * Note: This is for Build version sdk below Tiramisu. + */ +@Parcelize +enum class WifiAuthTypeBelowTiramisu(private val stringRep: String) : AuthenticationMode { + OPEN("Open"), + WEP("WEP"), + WPA_PSK("WPA-PSK"), + WPA2_PSK("WPA2-PSK"), + WPA_EAP("WPA-EAP"), + WPA_WPA2_PSK("WPA/WPA2-PSK"), + WPA2_EAP("WPA2-EAP"), + WPA3_PSK("WPA3-PSK"); + + companion object { + + /** + * @return The security of a given [ScanResult]. + */ + fun getSecurityTypes(scanResult: ScanResult): WifiAuthTypeBelowTiramisu { + val cap = scanResult.capabilities + val securityModes = entries.map { it.stringRep } + for (i in securityModes.indices.reversed()) { + if (cap.contains(securityModes[i])) { + WifiAuthTypeBelowTiramisu.entries.find { it.stringRep == securityModes[i] } + ?.let { + return it + } + } + } + return OPEN + } + + } +} + +/** + * Enum class that represents the authentication mode of a wifi network. + * + * Note: This is for Build version sdk Tiramisu and above. + */ +@Parcelize +enum class WifiAuthTypeTiramisuOrAbove(val id: Int) : AuthenticationMode { + UNKNOWN(-1), + OPEN(0), + WEP(1), // Shared + WPA_PSK(2), + WPA2_EAP(3), + WPA3_PSK(4), // WPA3-Personal (SAE) password) + EAP_WPA3_ENTERPRISE_192_BIT(5), + OWE(6), // Opportunistic Wireless Encryption + WAPI_PSK(7), // WAPI pre-shared key (PSK) + WAPI_CERT(8), // WAPI certificate to be specified. + EAP_WPA3_ENTERPRISE(9), + OSEN(10), // Hotspot 2. + PASSPOINT_R1_R2(11), + PASSPOINT_R3(12), + DPP(13); // Security type for Easy Connect (DPP) network + + companion object { + + /** + * Returns the authentication for provided security type. + */ + fun getMatchedAuthMode(securityTypes: IntArray): List { + val matchedType = mutableListOf() + securityTypes.forEach { type -> + val authMode = WifiAuthTypeTiramisuOrAbove.entries.find { it.id == type } + if (authMode != null) { + matchedType.add(authMode) + } + } + return matchedType.toList() + } + } +} diff --git a/lib/nfc/src/main/java/no/nordicsemi/android/wifi/provisioner/nfc/domain/EncryptionMode.kt b/lib/nfc/src/main/java/no/nordicsemi/android/wifi/provisioner/nfc/domain/EncryptionMode.kt new file mode 100644 index 00000000..18486e5e --- /dev/null +++ b/lib/nfc/src/main/java/no/nordicsemi/android/wifi/provisioner/nfc/domain/EncryptionMode.kt @@ -0,0 +1,67 @@ +package no.nordicsemi.android.wifi.provisioner.nfc.domain + +import android.net.wifi.ScanResult + +/** + * This enum class represents the encryption mode of a Wi-Fi network. + */ +enum class EncryptionMode { + NONE, + WEP, + TKIP, + AES, + AES_TKIP; + + companion object { + // Constants for the encryption modes. + private const val WPA2_PSK = "WPA2-PSK" + private const val WPA3_PSK = "WPA3-PSK" + private const val WPA2_EAP = "WPA2-EAP" + private const val WPA_EAP = "WPA2-EAP" + private const val WPA_PSK = "WPA-PSK" + private const val WPA_WPA2_PSK = "WPA/WPA2-PSK" + private const val WEP_S = "WEP" + + /** + * Returns the encryption mode of the given scan result. + * + * Examples of capabilities: + * 1. `[WPA2-PSK-CCMP][ESS]` + * 2. `[WPA2-PSK-CCMP+TKIP][ESS]` + * 3. `[WPA-PSK-CCMP+TKIP][WPA2-PSK-CCMP+TKIP][ESS]` + * [see more](https://stackoverflow.com/questions/11956874/scanresult-capabilities-interpretation), [here](https://security.stackexchange.com/questions/229986/does-wpa2-use-tkip-or-not), + * [here](https://developer.android.com/reference/kotlin/android/net/wifi/ScanResult#capabilities) + * @param result the scan result. + * @return the encryption mode. + */ + fun get(result: ScanResult): EncryptionMode { + return try { + val firstCapabilities = + result.capabilities.substring(1, result.capabilities.indexOf("]")) + val capabilities = + firstCapabilities.split("-".toRegex()).dropLastWhile { it.isEmpty() } + .toTypedArray() + val auth = capabilities[0] + "-" + capabilities[1] + return when { + auth.contains(WPA2_PSK) || + auth.contains(WPA3_PSK) || + auth.contains(WPA2_EAP) -> AES + + auth.contains(WPA_PSK) -> TKIP + auth.contains(WPA_EAP) || + auth.contains(WPA_WPA2_PSK) -> AES_TKIP + + auth.contains(WEP_S) -> WEP + else -> NONE + } + + } catch (e: ArrayIndexOutOfBoundsException) { + // Handle the case where capabilities array doesn't have enough elements + NONE + } catch (e: StringIndexOutOfBoundsException) { + // Handle the case where substring or indexOf operations fail + NONE + } + } + } +} diff --git a/lib/nfc/src/main/java/no/nordicsemi/android/wifi/provisioner/nfc/domain/NetworkState.kt b/lib/nfc/src/main/java/no/nordicsemi/android/wifi/provisioner/nfc/domain/NetworkState.kt new file mode 100644 index 00000000..ebce9a19 --- /dev/null +++ b/lib/nfc/src/main/java/no/nordicsemi/android/wifi/provisioner/nfc/domain/NetworkState.kt @@ -0,0 +1,23 @@ +package no.nordicsemi.android.wifi.provisioner.nfc.domain + +/** + * NetworkState is a sealed interface that holds the different network states. + */ +sealed interface NetworkState + +/** + * Success is a data class that represents the success state of the network. + * @param data The data that is returned from the network. + */ +data class Success(val data: T) : NetworkState + +/** + * Error is a data class that represents the error state of the network. + * @param t The throwable that is returned from the network. + */ +data class Error(val t: Throwable) : NetworkState + +/** + * Loading is a class that represents the loading state of the network. + */ +class Loading : NetworkState diff --git a/lib/nfc/src/main/java/no/nordicsemi/android/wifi/provisioner/nfc/domain/WifiData.kt b/lib/nfc/src/main/java/no/nordicsemi/android/wifi/provisioner/nfc/domain/WifiData.kt new file mode 100644 index 00000000..27b7b0d0 --- /dev/null +++ b/lib/nfc/src/main/java/no/nordicsemi/android/wifi/provisioner/nfc/domain/WifiData.kt @@ -0,0 +1,20 @@ +package no.nordicsemi.android.wifi.provisioner.nfc.domain + +import android.os.Parcelable +import kotlinx.parcelize.Parcelize + +/** + * WifiData is a data class that holds the wifi data. + * @param ssid The ssid of the wifi network. + * @param password The password of the wifi network. + * @param authType The authentication type of the wifi network. + * @param encryptionMode The encryption mode of the wifi network. + */ +@Parcelize +data class WifiData( + val ssid: String, + val password: String, + val macAddress: String, + val authType: AuthenticationMode, + val encryptionMode: EncryptionMode, +) : Parcelable \ No newline at end of file diff --git a/lib/nfc/src/main/java/no/nordicsemi/android/wifi/provisioner/nfc/domain/WifiHandoverDataType.kt b/lib/nfc/src/main/java/no/nordicsemi/android/wifi/provisioner/nfc/domain/WifiHandoverDataType.kt new file mode 100644 index 00000000..1147e4fa --- /dev/null +++ b/lib/nfc/src/main/java/no/nordicsemi/android/wifi/provisioner/nfc/domain/WifiHandoverDataType.kt @@ -0,0 +1,64 @@ +package no.nordicsemi.android.wifi.provisioner.nfc.domain + +/** + * WifiHandoverDataType is an object that holds the data types for the Wi-Fi handover data. + */ +internal object WifiHandoverDataType { + + /** + * The MIME type for the Wi-Fi Simple Configuration Token. + */ + const val NFC_TOKEN_MIME_TYPE: String = "application/vnd.wfa.wsc" + + /** + * The Credential field ID. + */ + const val CREDENTIAL_FIELD_ID: Short = 0x100e + + /** + * The Network Index field ID. + */ + const val NETWORK_INDEX_FIELD_ID: Short = 0x1026 + const val NETWORK_INDEX_DEFAULT_VALUE: Byte = 0x01.toByte() + + /** + * The SSID field ID. + */ + const val SSID_FIELD_ID: Short = 0x1045 + const val MAX_SSID_SIZE_BYTES: Int = 32 + + /** + * The Encryption Type field ID and encryption types. + */ + const val ENC_TYPE_FIELD_ID: Short = 0x100f + const val ENC_TYPE_NONE: Short = 0x0001 + const val ENC_TYPE_WEP: Short = 0x0002 // deprecated + const val ENC_TYPE_TKIP: Short = 0x0004 // deprecated -> only with mixed mode (0x000c) + const val ENC_TYPE_AES: Short = 0x0008 // includes CCMP and GCMP + const val ENC_TYPE_AES_TKIP: Short = 0x000c // mixed mode + + /** + * The Authentication Type field ID and authentication types. + */ + const val AUTH_TYPE_FIELD_ID: Short = 0x1003 + const val AUTH_TYPE_EXPECTED_SIZE: Short = 2 + const val AUTH_TYPE_OPEN: Short = 0x0001 + const val AUTH_TYPE_WPA_PSK: Short = 0x0002 + const val AUTH_TYPE_WPA_EAP: Short = 0x0008 + const val AUTH_TYPE_WPA2_EAP: Short = 0x0010 + const val AUTH_TYPE_WPA2_PSK: Short = 0x0020 + const val AUTH_TYPE_WPA_WPA2_PSK: Short = 0x0022 + const val AUTH_TYPE_SHARED: Short = 0x0004 // deprecated "WEP" type + + /** + * The Network key (wifi password) field ID. + */ + const val NETWORK_KEY_FIELD_ID: Short = 0x1027 + const val MAX_NETWORK_KEY_SIZE_BYTES: Int = 64 + + /** + * The MAC Address field ID. + */ + const val MAC_ADDRESS_FIELD_ID: Short = 0x1020 + const val MAX_MAC_ADDRESS_SIZE_BYTES: Short = 6 +} diff --git a/settings.gradle.kts b/settings.gradle.kts index fcd7a520..6acd5314 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -57,10 +57,12 @@ rootProject.name = "Android-nRF-Wifi-Provisioner" include(":app") include(":feature:ble") include(":feature:softap") +include(":feature:nfc") include(":feature:ui") include(":feature:common") include(":lib:ble") include(":lib:softap") +include(":lib:nfc") include(":lib:domain") //if (file('../Android-Common-Libraries').exists()) {