diff --git a/app/build.gradle.kts b/app/build.gradle.kts index b7e70ab83..cbd321e50 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -50,6 +50,9 @@ dependencies { implementation(project(":permissions-wifi")) implementation(project(":permissions-internet")) implementation(project(":permissions-notification")) + implementation(project(":scanner")) + + implementation(libs.nordic.blek.client.android) implementation(libs.androidx.compose.material.iconsExtended) diff --git a/scanner/src/main/java/no/nordicsemi/android/kotlin/ble/ui/scanner/HiltModule.kt b/app/src/main/java/no/nordicsemi/android/common/test/main/di/CentralManagerModule.kt similarity index 80% rename from scanner/src/main/java/no/nordicsemi/android/kotlin/ble/ui/scanner/HiltModule.kt rename to app/src/main/java/no/nordicsemi/android/common/test/main/di/CentralManagerModule.kt index fca5e3f15..344fd6eab 100644 --- a/scanner/src/main/java/no/nordicsemi/android/kotlin/ble/ui/scanner/HiltModule.kt +++ b/app/src/main/java/no/nordicsemi/android/common/test/main/di/CentralManagerModule.kt @@ -1,5 +1,5 @@ /* - * Copyright (c) 2022, Nordic Semiconductor + * Copyright (c) 2025, Nordic Semiconductor * All rights reserved. * * Redistribution and use in source and binary forms, with or without modification, are @@ -29,7 +29,7 @@ * EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. */ -package no.nordicsemi.android.kotlin.ble.ui.scanner +package no.nordicsemi.android.common.test.main.di import android.content.Context import dagger.Module @@ -37,17 +37,19 @@ import dagger.Provides import dagger.hilt.InstallIn import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.components.SingletonComponent -import no.nordicsemi.android.kotlin.ble.scanner.BleScanner +import kotlinx.coroutines.CoroutineScope +import no.nordicsemi.kotlin.ble.client.android.CentralManager +import no.nordicsemi.kotlin.ble.client.android.native @Module @InstallIn(SingletonComponent::class) -internal class HiltModule { +object CentralManagerModule { @Provides - fun providesScanner( - @ApplicationContext - context: Context + fun provideCentralManager( + @ApplicationContext context: Context, + scope: CoroutineScope ): CentralManager { - return BleScanner(context) + return CentralManager.Factory.native(context, scope) } } diff --git a/app/src/main/java/no/nordicsemi/android/common/test/main/di/CoroutineScopeModule.kt b/app/src/main/java/no/nordicsemi/android/common/test/main/di/CoroutineScopeModule.kt new file mode 100644 index 000000000..8ba77fd92 --- /dev/null +++ b/app/src/main/java/no/nordicsemi/android/common/test/main/di/CoroutineScopeModule.kt @@ -0,0 +1,17 @@ +package no.nordicsemi.android.common.test.main.di + +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob + +@Module +@InstallIn(SingletonComponent::class) +class CoroutineScopeModule { + + @Provides + fun applicationScope() = CoroutineScope(SupervisorJob() + Dispatchers.IO) +} \ No newline at end of file diff --git a/app/src/main/java/no/nordicsemi/android/common/test/main/page/BasicViewsPage.kt b/app/src/main/java/no/nordicsemi/android/common/test/main/page/BasicViewsPage.kt index 6ba5dbee2..f3e07b453 100644 --- a/app/src/main/java/no/nordicsemi/android/common/test/main/page/BasicViewsPage.kt +++ b/app/src/main/java/no/nordicsemi/android/common/test/main/page/BasicViewsPage.kt @@ -68,6 +68,7 @@ import dagger.hilt.android.lifecycle.HiltViewModel import no.nordicsemi.android.common.navigation.Navigator import no.nordicsemi.android.common.test.R import no.nordicsemi.android.common.test.simple.Hello +import no.nordicsemi.android.common.test.simple.ScannerDestinationId import no.nordicsemi.android.common.theme.NordicTheme import no.nordicsemi.android.common.ui.view.NordicSliderDefaults import no.nordicsemi.android.common.ui.view.PagerViewItem @@ -76,7 +77,13 @@ import javax.inject.Inject val BasicsPage = PagerViewItem("Basics") { val vm = hiltViewModel() - BasicViewsScreen(onOpenSimple = { vm.openSimple() },) + BasicViewsScreen( + onOpenSimple = { vm.openSimple() }, + onOpenScanner = { + // Navigate to the scanner destination + vm.openScanner() + }, + ) } @HiltViewModel @@ -88,11 +95,17 @@ class BasicPageViewModel @Inject constructor( fun openSimple() { navigator.navigateTo(Hello, 1) } + + fun openScanner() { + // Navigate to the scanner destination + navigator.navigateTo(ScannerDestinationId) + } } @Composable private fun BasicViewsScreen( onOpenSimple: () -> Unit, + onOpenScanner: () -> Unit, ) { Column( modifier = Modifier @@ -107,6 +120,10 @@ private fun BasicViewsScreen( ) { Text(text = stringResource(id = R.string.action_simple)) } + + Button(onClick = { onOpenScanner() }) { + Text(text = stringResource(id = R.string.action_scanner)) + } } BasicViewsScreen() @@ -256,6 +273,7 @@ private fun ContentPreview() { NordicTheme { BasicViewsScreen( onOpenSimple = {}, + onOpenScanner = {}, ) } } \ No newline at end of file diff --git a/app/src/main/java/no/nordicsemi/android/common/test/simple/Hello.kt b/app/src/main/java/no/nordicsemi/android/common/test/simple/Hello.kt index f0ae2dbcc..294c67a20 100644 --- a/app/src/main/java/no/nordicsemi/android/common/test/simple/Hello.kt +++ b/app/src/main/java/no/nordicsemi/android/common/test/simple/Hello.kt @@ -106,7 +106,7 @@ private val HelloDialogDestination = defineDialogDestination(HelloDialog) { ) } -val HelloDestinations = HelloDestination + HelloDialogDestination +val HelloDestinations = HelloDestination + HelloDialogDestination + ScannerDestination @Composable private fun HelloScreen( diff --git a/scanner/src/main/java/no/nordicsemi/android/kotlin/ble/ui/scanner/repository/ScanningState.kt b/app/src/main/java/no/nordicsemi/android/common/test/simple/ScannerDestination.kt similarity index 54% rename from scanner/src/main/java/no/nordicsemi/android/kotlin/ble/ui/scanner/repository/ScanningState.kt rename to app/src/main/java/no/nordicsemi/android/common/test/simple/ScannerDestination.kt index e25abce4e..20600a859 100644 --- a/scanner/src/main/java/no/nordicsemi/android/kotlin/ble/ui/scanner/repository/ScanningState.kt +++ b/app/src/main/java/no/nordicsemi/android/common/test/simple/ScannerDestination.kt @@ -1,5 +1,5 @@ /* - * Copyright (c) 2024, Nordic Semiconductor + * Copyright (c) 2025, Nordic Semiconductor * All rights reserved. * * Redistribution and use in source and binary forms, with or without modification, are @@ -29,27 +29,36 @@ * EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. */ -package no.nordicsemi.android.kotlin.ble.ui.scanner.repository +package no.nordicsemi.android.common.test.simple -import no.nordicsemi.android.kotlin.ble.core.scanner.BleScanResults +import androidx.hilt.navigation.compose.hiltViewModel +import no.nordicsemi.android.common.navigation.createDestination +import no.nordicsemi.android.common.navigation.defineDestination +import no.nordicsemi.android.common.navigation.viewmodel.SimpleNavigationViewModel +import no.nordicsemi.android.common.scanner.DeviceSelected +import no.nordicsemi.android.common.scanner.ScannerScreen +import no.nordicsemi.android.common.scanner.ScanningCancelled +import no.nordicsemi.kotlin.ble.client.android.ScanResult -sealed class ScanningState { +val ScannerDestinationId = createDestination("ble-scanner") - data object Loading : ScanningState() +val ScannerDestination = defineDestination(ScannerDestinationId) { + val navigationVM = hiltViewModel() - data class Error(val errorCode: Int) : ScanningState() + ScannerScreen( + cancellable = true, + onResultSelected = { + when (it) { + is DeviceSelected -> { + navigationVM.navigateUpWithResult(ScannerDestinationId, it.scanResult) + println("Device selected: ${it.scanResult}") + } - data class DevicesDiscovered(val devices: List) : ScanningState() { - val bonded: List = devices.filter { it.device.isBonded } - - val notBonded: List = devices.filter { !it.device.isBonded } - - fun size(): Int = bonded.size + notBonded.size - - fun isEmpty(): Boolean = devices.isEmpty() - } - - fun isRunning(): Boolean { - return this is Loading || this is DevicesDiscovered - } + ScanningCancelled -> { + navigationVM.navigateUp() + println("Scanning cancelled") + } + } + } + ) } diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 3dde8243a..fb3702006 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,6 +1,6 @@ [versions] accompanist = "0.37.0" -androidGradlePlugin = "8.7.3" +androidGradlePlugin = "8.10.1" androidxActivity = "1.9.3" androidxComposeBom = "2024.12.01" # https://developer.android.com/jetpack/compose/bom/bom-mapping androidxCore = "1.15.0" @@ -21,7 +21,8 @@ publishPlugin = "1.3.0" leakcanary = "2.14" nordic-log = "2.5.0" -nordicPlugins = "2.6.1" +nordic-blek = "2.0.0-alpha02" +nordicPlugins = "2.7" dokkaPlugin = "2.0.0" googleServicesPlugins = "4.4.2" @@ -58,6 +59,7 @@ leakcanary = { group = "com.squareup.leakcanary", name = "leakcanary-android", v # Nordic nordic-log = { group = "no.nordicsemi.android", name = "log", version.ref = "nordic-log" } +nordic-blek-client-android = { group = "no.nordicsemi.kotlin.ble", name = "client-android", version.ref = "nordic-blek" } [plugins] nordic-application = { id = "no.nordicsemi.android.plugin.application", version.ref = "nordicPlugins" } diff --git a/scanner/build.gradle.kts b/scanner/build.gradle.kts index 209efd9a0..2ef153fb2 100644 --- a/scanner/build.gradle.kts +++ b/scanner/build.gradle.kts @@ -32,20 +32,19 @@ plugins { alias(libs.plugins.nordic.feature) alias(libs.plugins.nordic.nexus.android) - alias(libs.plugins.kotlin.parcelize) } group = "no.nordicsemi.android.common" nordicNexusPublishing { POM_ARTIFACT_ID = "scanner" - POM_NAME = "Nordic Kotlin library for BLE server side." + POM_NAME = "Nordic library for BLE scanner." - POM_DESCRIPTION = "Nordic Android Kotlin BLE library" - POM_URL = "https://github.com/NordicPlayground/Kotlin-BLE-Library" - POM_SCM_URL = "https://github.com/NordicPlayground/Kotlin-BLE-Library" - POM_SCM_CONNECTION = "scm:git@github.com:NordicPlayground/Kotlin-BLE-Library.git" - POM_SCM_DEV_CONNECTION = "scm:git@github.com:NordicPlayground/Kotlin-BLE-Library.git" + POM_DESCRIPTION = "Nordic Android Common Libraries" + POM_URL = "https://github.com/NordicPlayground/Android-Common-Libraries" + POM_SCM_URL = "https://github.com/NordicPlayground/Android-Common-Libraries" + POM_SCM_CONNECTION = "scm:git@github.com:NordicPlayground/Android-Common-Libraries.git" + POM_SCM_DEV_CONNECTION = "scm:git@github.com:NordicPlayground/Android-Common-Libraries.git" } android { @@ -53,12 +52,9 @@ android { } dependencies { - implementation(project(":theme")) - implementation(project(":core")) implementation(project(":permissions-ble")) - - api(libs.nordic.blek.client.android) - - implementation(libs.androidx.compose.material3) + implementation(project(":ui")) + implementation(libs.nordic.blek.client.android) implementation(libs.androidx.compose.material.iconsExtended) + } diff --git a/scanner/src/main/java/no/nordicsemi/android/common/scanner/ScannerScreen.kt b/scanner/src/main/java/no/nordicsemi/android/common/scanner/ScannerScreen.kt new file mode 100644 index 000000000..0d326ccc6 --- /dev/null +++ b/scanner/src/main/java/no/nordicsemi/android/common/scanner/ScannerScreen.kt @@ -0,0 +1,111 @@ +/* + * Copyright (c) 2025, Nordic Semiconductor + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without modification, are + * permitted provided that the following conditions are met: + * + * 1. Redistributions of source code must retain the above copyright notice, this list of + * conditions and the following disclaimer. + * + * 2. Redistributions in binary form must reproduce the above copyright notice, this list + * of conditions and the following disclaimer in the documentation and/or other materials + * provided with the distribution. + * + * 3. Neither the name of the copyright holder nor the names of its contributors may be + * used to endorse or promote products derived from this software without specific prior + * written permission. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS + * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED + * TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A + * PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT + * HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, + * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT + * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, + * OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY + * OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING + * NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, + * EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ + +package no.nordicsemi.android.common.scanner + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import no.nordicsemi.android.common.scanner.data.Filter +import no.nordicsemi.android.common.scanner.data.GroupByName +import no.nordicsemi.android.common.scanner.data.OnlyBonded +import no.nordicsemi.android.common.scanner.data.OnlyNearby +import no.nordicsemi.android.common.scanner.data.OnlyWithNames +import no.nordicsemi.android.common.scanner.data.SortBy +import no.nordicsemi.android.common.scanner.view.DeviceListItem +import no.nordicsemi.android.common.scanner.view.ScannerAppBar +import no.nordicsemi.android.common.scanner.view.ScannerView +import no.nordicsemi.android.common.scanner.viewmodel.ScannerViewModel +import no.nordicsemi.kotlin.ble.client.android.ScanResult + +val Default_Filters = listOf( + OnlyWithNames(), + OnlyNearby(), + OnlyBonded(), + SortBy(), + GroupByName(), + + ) + +@Composable +fun ScannerScreen( + title: @Composable () -> Unit = { Text(stringResource(id = R.string.scanner_screen)) }, + cancellable: Boolean, + availableFilters: List = Default_Filters, + filters: List = emptyList(), + onResultSelected: (ScannerScreenResult) -> Unit, + deviceItem: @Composable (ScanResult) -> Unit = { scanResult -> + DeviceListItem(scanResult) + } +) { + val viewModel = hiltViewModel() + val uiState by viewModel.uiState.collectAsStateWithLifecycle() + + // If filters to be passed to the view model. + LaunchedEffect(filters.isNotEmpty()) { + viewModel.setFilters(filters) + } + + Column( + modifier = Modifier.fillMaxSize() + ) { + if (cancellable) { + ScannerAppBar( + title = title, + uiState = uiState, + availableFilters = availableFilters, + onFilterSelected = viewModel::onClick, + ) { onResultSelected(ScanningCancelled) } + } else { + ScannerAppBar( + title = title, + uiState = uiState, + availableFilters = availableFilters, + onFilterSelected = viewModel::onClick, + ) + } + + ScannerView( + uiState = uiState, + startScanning = viewModel::startScanning, + onEvent = viewModel::onClick, + onScanResultSelected = { onResultSelected(DeviceSelected(it)) }, + deviceItem = deviceItem, + ) + } +} diff --git a/scanner/src/main/java/no/nordicsemi/android/common/scanner/ScannerScreenResult.kt b/scanner/src/main/java/no/nordicsemi/android/common/scanner/ScannerScreenResult.kt new file mode 100644 index 000000000..244190d83 --- /dev/null +++ b/scanner/src/main/java/no/nordicsemi/android/common/scanner/ScannerScreenResult.kt @@ -0,0 +1,21 @@ +package no.nordicsemi.android.common.scanner + +import no.nordicsemi.kotlin.ble.client.android.ScanResult + +/** + * Represents the result of the scanner screen interaction. + * This sealed interface defines the possible outcomes when the user interacts with the scanner screen. + */ +sealed interface ScannerScreenResult + +/** + * Represents the cancellation of the scanning process. + * This object is used to indicate that the user has cancelled the scanning operation. + */ +data object ScanningCancelled : ScannerScreenResult + +/** + * Represents the selection of a device from the scan results. + * @property scanResult The selected scan result containing details about the device. + */ +data class DeviceSelected(val scanResult: ScanResult) : ScannerScreenResult diff --git a/scanner/src/main/java/no/nordicsemi/android/common/scanner/data/Filter.kt b/scanner/src/main/java/no/nordicsemi/android/common/scanner/data/Filter.kt new file mode 100644 index 000000000..9e7eaf0db --- /dev/null +++ b/scanner/src/main/java/no/nordicsemi/android/common/scanner/data/Filter.kt @@ -0,0 +1,196 @@ +package no.nordicsemi.android.common.scanner.data + +import android.annotation.SuppressLint +import androidx.annotation.StringRes +import androidx.compose.runtime.Composable +import androidx.compose.ui.res.stringResource +import no.nordicsemi.android.common.scanner.R +import no.nordicsemi.android.common.scanner.view.FilterButton +import no.nordicsemi.android.common.scanner.view.GroupByNameDropdown +import no.nordicsemi.android.common.scanner.view.SortByView +import no.nordicsemi.kotlin.ble.client.android.ScanResult +import no.nordicsemi.kotlin.ble.core.BondState +import kotlin.uuid.ExperimentalUuidApi +import kotlin.uuid.Uuid + +sealed interface Filter { + val title: StringResource + val filter: (ScanResult) -> Boolean +} + +/** + * Sort the scan result. + * @param sortType The type of sorting to be applied. + */ +data class SortBy( + val sortType: SortType? = null, // Nullable to allow for default sorting + override val filter: (ScanResult) -> Boolean = { true } // Default filter that allows all scan results +) : Filter { + override val title: StringResource + get() = when (sortType) { + SortType.RSSI -> StringResource.RSSI + SortType.ALPHABETICAL -> StringResource.ALPHABETICAL + null -> { + // Default case when sortType is null, can be used for initial state or no sorting + StringResource.RSSI // Default to RSSI sorting + } + } + + @Composable + internal fun Draw( + sortByFilters: List = listOf( + SortBy(SortType.RSSI), + SortBy(SortType.ALPHABETICAL) + ), + activeFilters: List, + onSortOptionSelected: (FilterEvent) -> Unit + ) { + SortByView( + sortByFilters = sortByFilters, + activeFilters = activeFilters, + onSortOptionSelected = onSortOptionSelected + ) + } +} + + +/** + * Filter that allows scan results with no empty names. + */ +data class OnlyWithNames( + override val title: StringResource = StringResource.ONLY_WITH_NAMES, +) : Filter { + override val filter: (ScanResult) -> Boolean + get() = { scanResult -> + scanResult.peripheral.name?.isNotEmpty() == true + } + + @SuppressLint("ComposableNaming") + @Composable + fun draw( + isSelected: Boolean, + onClick: () -> Unit + ) { + FilterButton( + title = stringResource(id = title.value), + isSelected = isSelected, + onClick = onClick + ) + } +} + +/** + * Group scan results by name. + */ +data class GroupByName( + val name: String = "", + override val title: StringResource = StringResource.GROUP_BY_NAME, +) : Filter { + override val filter: (ScanResult) -> Boolean + get() = { scanResult -> + scanResult.peripheral.name == name + } + + @Composable + internal fun Draw( + dropdownLabel: String, + onLabelChange: (String) -> Unit, + scanResults: List, + onItemSelected: (FilterEvent) -> Unit + ) { + GroupByNameDropdown( + title = stringResource(title.value), + dropdownLabel = dropdownLabel, + onLabelChange = { onLabelChange(it) }, + scanResults = scanResults, + onItemSelected = { onItemSelected(it) } + ) + } +} + +/** + * Filter bonded devices. + * isBonded is true if the device is bonded, false otherwise. + */ +data class OnlyBonded( + override val title: StringResource = StringResource.ONLY_BONDED, +) : Filter { + override val filter: (ScanResult) -> Boolean + get() = { scanResult -> + scanResult.peripheral.bondState.value == BondState.BONDED + } + + @Composable + internal fun Draw( + isSelected: Boolean, + onClick: () -> Unit + ) { + FilterButton( + title = stringResource(id = title.value), + isSelected = isSelected, + onClick = onClick + ) + } +} + + +/** + * Filter nearby devices based on RSSI value. It will allow devices with RSSI value greater + * or equal to the -50 dBm. + */ +data class OnlyNearby( + val rssi: Int = -50, // Default RSSI value to filter nearby devices + override val title: StringResource = StringResource.ONLY_NEARBY, +) : Filter { + override val filter: (ScanResult) -> Boolean + get() = { scanResult -> + scanResult.rssi >= rssi + } + + @Composable + internal fun Draw( + isSelected: Boolean, + onClick: () -> Unit + ) { + FilterButton( + title = stringResource(id = title.value), + isSelected = isSelected, + onClick = onClick + ) + } +} + +@OptIn(ExperimentalUuidApi::class) +data class WithServiceUuid( + val uuid: Uuid?, + override val title: StringResource = StringResource.WITH_SERVICE_UUID, +) : Filter { + override val filter: (ScanResult) -> Boolean + get() = { scanResult -> + scanResult.advertisingData.serviceUuids.contains(uuid) + } +} + +/** + * Custom filter. + * + * The filter shows only devices that match the given predicate. + */ +data class CustomFilter( + override val title: StringResource, + override val filter: (scanResult: ScanResult) -> Boolean, +) : Filter + +/** + * Enum class for Filter item string resources. + */ +enum class StringResource(@StringRes val value: Int) { + ONLY_NEARBY(R.string.filter_only_nearby), + ONLY_BONDED(R.string.filter_only_bonded), + ONLY_WITH_NAMES(R.string.filter_only_with_names), + GROUP_BY_NAME(R.string.filter_group_by_name), + WITH_SERVICE_UUID(R.string.filter_with_service_uuid), + RSSI(R.string.filter_rssi), + ALPHABETICAL(R.string.filter_alphabetical), +} + diff --git a/scanner/src/main/java/no/nordicsemi/android/common/scanner/data/SortingFilter.kt b/scanner/src/main/java/no/nordicsemi/android/common/scanner/data/SortingFilter.kt new file mode 100644 index 000000000..33ef3be7f --- /dev/null +++ b/scanner/src/main/java/no/nordicsemi/android/common/scanner/data/SortingFilter.kt @@ -0,0 +1,26 @@ +package no.nordicsemi.android.common.scanner.data + +/** + * Represents the type of sorting to be applied to scan results. + */ +enum class SortType { + + /** + * Sort results by signal strength (RSSI), typically in descending order. + * Devices with stronger signals appear first. + */ + RSSI, + + /** + * Sort results alphabetically by device name. + * Devices are ordered based on their names in ascending order. + */ + ALPHABETICAL, ; + + override fun toString(): String { + return when (this) { + RSSI -> "RSSI" + ALPHABETICAL -> "Alphabetical" + } + } +} \ No newline at end of file diff --git a/scanner/src/main/java/no/nordicsemi/android/common/scanner/data/UiEvent.kt b/scanner/src/main/java/no/nordicsemi/android/common/scanner/data/UiEvent.kt new file mode 100644 index 000000000..be1c491d1 --- /dev/null +++ b/scanner/src/main/java/no/nordicsemi/android/common/scanner/data/UiEvent.kt @@ -0,0 +1,30 @@ +package no.nordicsemi.android.common.scanner.data + +/** + * Represents the events that can occur in the scanner UI. + */ +sealed interface UiEvent + +/** + * Event triggered when the user requests to reload or refresh the scan results. + */ +data object OnReloadScanResults : UiEvent + +/** + * Base interface for UI events related to filtering actions. + */ +sealed interface FilterEvent : UiEvent + +/** + * Event triggered when the user selects a specific filter option. + * + * @property filter The selected filter option to apply to the scan results. + */ +data class OnFilterSelected( + val filter: Filter +) : FilterEvent + +/** + * Event triggered when the user resets or clears all active filters. + */ +data object OnFilterReset : FilterEvent diff --git a/scanner/src/main/java/no/nordicsemi/android/kotlin/ble/ui/scanner/di/ScannerHiltModule.kt b/scanner/src/main/java/no/nordicsemi/android/common/scanner/di/BluetoothAdapterHiltModule.kt similarity index 95% rename from scanner/src/main/java/no/nordicsemi/android/kotlin/ble/ui/scanner/di/ScannerHiltModule.kt rename to scanner/src/main/java/no/nordicsemi/android/common/scanner/di/BluetoothAdapterHiltModule.kt index 2919f7115..4e73a4c10 100644 --- a/scanner/src/main/java/no/nordicsemi/android/kotlin/ble/ui/scanner/di/ScannerHiltModule.kt +++ b/scanner/src/main/java/no/nordicsemi/android/common/scanner/di/BluetoothAdapterHiltModule.kt @@ -29,7 +29,7 @@ * EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. */ -package no.nordicsemi.android.kotlin.ble.ui.scanner.di +package no.nordicsemi.android.common.scanner.di import android.bluetooth.BluetoothAdapter import android.bluetooth.BluetoothManager @@ -43,7 +43,7 @@ import dagger.hilt.components.SingletonComponent @Suppress("unused") @Module @InstallIn(SingletonComponent::class) -internal class ScannerHiltModule { +internal class BluetoothAdapterHiltModule { @Provides fun provideBluetoothAdapter( diff --git a/scanner/src/main/java/no/nordicsemi/android/common/scanner/spec/ServiceUuids.kt b/scanner/src/main/java/no/nordicsemi/android/common/scanner/spec/ServiceUuids.kt new file mode 100644 index 000000000..a11309bd3 --- /dev/null +++ b/scanner/src/main/java/no/nordicsemi/android/common/scanner/spec/ServiceUuids.kt @@ -0,0 +1,104 @@ +/* + * Copyright (c) 2025, Nordic Semiconductor + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without modification, are + * permitted provided that the following conditions are met: + * + * 1. Redistributions of source code must retain the above copyright notice, this list of + * conditions and the following disclaimer. + * + * 2. Redistributions in binary form must reproduce the above copyright notice, this list + * of conditions and the following disclaimer in the documentation and/or other materials + * provided with the distribution. + * + * 3. Neither the name of the copyright holder nor the names of its contributors may be + * used to endorse or promote products derived from this software without specific prior + * written permission. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS + * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED + * TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A + * PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT + * HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, + * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT + * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, + * OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY + * OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING + * NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, + * EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ + +package no.nordicsemi.android.common.scanner.spec + +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.BatteryStd +import androidx.compose.material.icons.filled.Bloodtype +import androidx.compose.material.icons.filled.SocialDistance +import androidx.compose.material.icons.filled.SyncAlt +import androidx.compose.material.icons.filled.Thermostat +import androidx.compose.ui.graphics.vector.ImageVector +import java.util.UUID +import kotlin.uuid.ExperimentalUuidApi +import kotlin.uuid.Uuid +import kotlin.uuid.toKotlinUuid + +internal val HTS_SERVICE_UUID = UUID.fromString("00001809-0000-1000-8000-00805f9b34fb") +internal val BPS_SERVICE_UUID: UUID = UUID.fromString("00001810-0000-1000-8000-00805f9b34fb") +internal val CSC_SERVICE_UUID: UUID = UUID.fromString("00001816-0000-1000-8000-00805f9b34fb") +internal val CGMS_SERVICE_UUID: UUID = UUID.fromString("0000181F-0000-1000-8000-00805f9b34fb") +internal val DF_SERVICE_UUID: UUID = UUID.fromString("21490000-494a-4573-98af-f126af76f490") +internal val GLS_SERVICE_UUID: UUID = UUID.fromString("00001808-0000-1000-8000-00805f9b34fb") +internal val HRS_SERVICE_UUID: UUID = UUID.fromString("0000180D-0000-1000-8000-00805f9b34fb") +internal val PRX_SERVICE_UUID: UUID = UUID.fromString("00001802-0000-1000-8000-00805f9b34fb") +internal val RSCS_SERVICE_UUID: UUID = UUID.fromString("00001814-0000-1000-8000-00805F9B34FB") +internal val UART_SERVICE_UUID: UUID = UUID.fromString("6E400001-B5A3-F393-E0A9-E50E24DCCA9E") +internal val BATTERY_SERVICE_UUID: UUID = UUID.fromString("0000180F-0000-1000-8000-00805f9b34fb") +internal val THROUGHPUT_SERVICE_UUID: UUID = UUID.fromString("0483DADD-6C9D-6CA9-5D41-03AD4FFF4ABB") +internal val CHANNEL_SOUND_SERVICE_UUID: UUID = + UUID.fromString("0000185B-0000-1000-8000-00805F9B34FB") + +// TODO: make a callback which checks the uuid and returns the name and icon for the service. +object ServiceUuids { + + @OptIn(ExperimentalUuidApi::class) + fun getServiceInfo(uuid: Uuid): ServiceNameWithIcon? { + return when (uuid) { + HTS_SERVICE_UUID.toKotlinUuid() -> ServiceNameWithIcon("Health Thermometer", Icons.Default.Thermostat) + BPS_SERVICE_UUID.toKotlinUuid() -> ServiceNameWithIcon("Blood Pressure", Icons.Default.Bloodtype) + CSC_SERVICE_UUID.toKotlinUuid() -> ServiceNameWithIcon( + "Cycling Speed and Cadence", + Icons.Default.Thermostat + ) + + CGMS_SERVICE_UUID.toKotlinUuid() -> ServiceNameWithIcon( + "Continuous Glucose Monitoring", + Icons.Default.Thermostat + ) + + DF_SERVICE_UUID.toKotlinUuid() -> ServiceNameWithIcon("DFU", Icons.Default.Thermostat) + GLS_SERVICE_UUID.toKotlinUuid() -> ServiceNameWithIcon("Glucose", Icons.Default.Thermostat) + HRS_SERVICE_UUID.toKotlinUuid() -> ServiceNameWithIcon("Heart Rate", Icons.Default.Thermostat) + PRX_SERVICE_UUID.toKotlinUuid() -> ServiceNameWithIcon("Proximity", Icons.Default.Thermostat) + RSCS_SERVICE_UUID.toKotlinUuid() -> ServiceNameWithIcon( + "Running Speed and Cadence", + Icons.Default.Thermostat + ) + + UART_SERVICE_UUID.toKotlinUuid() -> ServiceNameWithIcon("UART", Icons.Default.Thermostat) + BATTERY_SERVICE_UUID.toKotlinUuid() -> ServiceNameWithIcon("Battery", Icons.Default.BatteryStd) + THROUGHPUT_SERVICE_UUID.toKotlinUuid() -> ServiceNameWithIcon("Throughput", Icons.Default.SyncAlt) + CHANNEL_SOUND_SERVICE_UUID.toKotlinUuid() -> ServiceNameWithIcon( + "Channel Sounding", + Icons.Default.SocialDistance + ) + + else -> null + } + } +} + +data class ServiceNameWithIcon( + val service: String, + val icon: ImageVector, +) \ No newline at end of file diff --git a/scanner/src/main/java/no/nordicsemi/android/common/scanner/view/FilterButton.kt b/scanner/src/main/java/no/nordicsemi/android/common/scanner/view/FilterButton.kt new file mode 100644 index 000000000..5d8c59e85 --- /dev/null +++ b/scanner/src/main/java/no/nordicsemi/android/common/scanner/view/FilterButton.kt @@ -0,0 +1,54 @@ +package no.nordicsemi.android.common.scanner.view + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Row +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Close +import androidx.compose.material.icons.filled.Done +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.Icon +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp + +@Composable +internal fun FilterButton( + title: String, + isSelected: Boolean, + containerColorEnabled: Color = ButtonDefaults.buttonColors().containerColor, + containerColorDisabled: Color = ButtonDefaults.buttonColors().disabledContainerColor, + onClick: () -> Unit = {}, +) { + val containerColor = if (isSelected) + containerColorEnabled else containerColorDisabled + Button( + onClick = onClick, + colors = ButtonDefaults.buttonColors( + containerColor = containerColor, + ) + ) { + Row( + horizontalArrangement = Arrangement.spacedBy(4.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon( + imageVector = if (isSelected) Icons.Default.Close else Icons.Default.Done, + contentDescription = null, + ) + Text(title) + } + } +} + +@Preview(showBackground = true) +@Composable +private fun FilterButtonPreview() { + FilterButton( + title = "Nearby", + isSelected = true + ) +} diff --git a/scanner/src/main/java/no/nordicsemi/android/common/scanner/view/FilterView.kt b/scanner/src/main/java/no/nordicsemi/android/common/scanner/view/FilterView.kt new file mode 100644 index 000000000..85dd273b8 --- /dev/null +++ b/scanner/src/main/java/no/nordicsemi/android/common/scanner/view/FilterView.kt @@ -0,0 +1,305 @@ +package no.nordicsemi.android.common.scanner.view + +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.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Delete +import androidx.compose.material.icons.filled.ExpandLess +import androidx.compose.material.icons.filled.ExpandMore +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.rememberModalBottomSheetState +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.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import no.nordicsemi.android.common.scanner.R +import no.nordicsemi.android.common.scanner.data.Filter +import no.nordicsemi.android.common.scanner.data.FilterEvent +import no.nordicsemi.android.common.scanner.data.GroupByName +import no.nordicsemi.android.common.scanner.data.OnFilterReset +import no.nordicsemi.android.common.scanner.data.OnFilterSelected +import no.nordicsemi.android.common.scanner.data.OnlyBonded +import no.nordicsemi.android.common.scanner.data.OnlyNearby +import no.nordicsemi.android.common.scanner.data.OnlyWithNames +import no.nordicsemi.android.common.scanner.data.SortBy +import no.nordicsemi.android.common.scanner.data.SortType +import no.nordicsemi.kotlin.ble.client.android.ScanResult + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +internal fun FilterDialog( + availableFilters: List, + scannedResults: List, + activeFilters: List, + onDismissRequest: () -> Unit, + onFilterSelected: (FilterEvent) -> Unit, +) { + val sheetState = rememberModalBottomSheetState() + + ModalBottomSheet( + onDismissRequest = { + onDismissRequest() + }, + sheetState = sheetState, + shape = RoundedCornerShape(topStart = 16.dp, topEnd = 16.dp), + containerColor = MaterialTheme.colorScheme.surface, + tonalElevation = 16.dp, + dragHandle = { + Box( + modifier = Modifier + .padding(8.dp) + .width(50.dp) + .height(6.dp) + .clip(RoundedCornerShape(50)) + .background(MaterialTheme.colorScheme.primary) + ) + } + ) { + FilterContent( + availableFilters = availableFilters, + scannedResults = scannedResults, + activeFilters + ) { + onFilterSelected(it) + } + } +} + +@Composable +private fun FilterContent( + availableFilters: List, + scannedResults: List, + activeFilters: List, + onFilterSelected: (FilterEvent) -> Unit, +) { + var dropdownLabel by rememberSaveable { mutableStateOf("") } + + Column( + modifier = Modifier.padding(8.dp), + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + FilterTopView( + isSelectedFilterEmpty = activeFilters.isEmpty(), + onFilterReset = { + dropdownLabel = "" + onFilterSelected(it) + } + ) + Row( + modifier = Modifier + .fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + availableFilters.any { it::class == OnlyNearby::class }.let { isAvailable -> + if (isAvailable) { + OnlyNearby().Draw( + isSelected = activeFilters.any { it::class == OnlyNearby::class } + ) { + onFilterSelected(OnFilterSelected(OnlyNearby())) + } + } + } + availableFilters.any { it::class == OnlyWithNames::class }.let { isAvailable -> + if (isAvailable) { + OnlyWithNames().draw( + isSelected = activeFilters.any { it::class == OnlyWithNames::class } + ) { + onFilterSelected(OnFilterSelected(OnlyWithNames())) + } + } + } + availableFilters.any { it::class == OnlyBonded::class }.let { isAvailable -> + if (isAvailable) { + OnlyBonded().Draw( + isSelected = activeFilters.any { it::class == OnlyBonded::class } + ) { + onFilterSelected(OnFilterSelected(OnlyBonded())) + } + } + } + } + + availableFilters.any { it::class == SortBy::class }.let { isAvailable -> + if (isAvailable) { + SortBy().Draw( + activeFilters = activeFilters, + onSortOptionSelected = onFilterSelected, + ) + } + } + availableFilters.any { it::class == GroupByName::class }.let { isAvailable -> + if (isAvailable && scannedResults.isNotEmpty()) { + GroupByName(dropdownLabel).Draw( + dropdownLabel = dropdownLabel, + onLabelChange = { dropdownLabel = it }, + scanResults = scannedResults, + onItemSelected = { onFilterSelected(it) }, + ) + } + } + } +} + +@Composable +internal fun GroupByNameDropdown( + title: String = stringResource(id = R.string.group_by_title), + dropdownLabel: String, + onLabelChange: (String) -> Unit, + scanResults: List, + onItemSelected: (FilterEvent) -> Unit, +) { + var isExpanded by rememberSaveable { mutableStateOf(false) } + + Text(title) + Row( + modifier = Modifier + .fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + Column { + Button(onClick = { isExpanded = !isExpanded }) { + Row( + horizontalArrangement = Arrangement.spacedBy(4.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Text(dropdownLabel.takeIf { it.isNotEmpty() } + ?: stringResource(id = R.string.empty_dropdown_label)) + Icon( + imageVector = if (isExpanded) Icons.Default.ExpandLess else Icons.Default.ExpandMore, + contentDescription = null + ) + } + } + DropdownMenu( + expanded = isExpanded, + onDismissRequest = { isExpanded = false }, + ) { + val groupedResults = scanResults.groupBy { it.peripheral.name } + groupedResults.forEach { (name, _) -> + name?.let { + DropdownMenuItem( + text = { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(8.dp), + verticalArrangement = Arrangement.spacedBy(4.dp), + horizontalAlignment = Alignment.Start, + + ) { + Text(name) + // show horizontal divider if there are multiple items + if (groupedResults.values.size > 1) { + HorizontalDivider() + } + } + }, + onClick = { + onItemSelected(OnFilterSelected(GroupByName(name))) + onLabelChange(name) + isExpanded = false + } + ) + } + } + } + } + } +} + +@Composable +private fun FilterTopView( + isSelectedFilterEmpty: Boolean, + containerColor: Color = MaterialTheme.colorScheme.tertiary, + contentColor: Color = MaterialTheme.colorScheme.onTertiary, + onFilterReset: (FilterEvent) -> Unit +) { + Row( + horizontalArrangement = Arrangement.Start, + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = stringResource(id = R.string.filter_title), + modifier = Modifier + .weight(1f) + ) + if (!isSelectedFilterEmpty) { + TextButton( + onClick = { + onFilterReset(OnFilterReset) + }, + + colors = ButtonDefaults.buttonColors( + containerColor = containerColor, + contentColor = contentColor + ) + ) { + Row( + horizontalArrangement = Arrangement.spacedBy(4.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon( + imageVector = Icons.Default.Delete, + contentDescription = null, + ) + + Text( + text = stringResource(id = R.string.clear_all), + ) + } + } + } + } +} + +@Preview(showBackground = true) +@Composable +private fun FilterTopViewPreview() { + FilterTopView( + isSelectedFilterEmpty = false, + onFilterReset = { } + ) +} + +@Preview(showBackground = true) +@Composable +private fun FilterDetailsPreview() { + FilterContent( + availableFilters = listOf( + OnlyNearby(), + OnlyWithNames(), + OnlyBonded(), + SortBy(SortType.RSSI), + GroupByName(""), + ), + scannedResults = emptyList(), + activeFilters = emptyList(), + onFilterSelected = {} + ) +} diff --git a/scanner/src/main/java/no/nordicsemi/android/kotlin/ble/ui/scanner/view/internal/ScanEmptyView.kt b/scanner/src/main/java/no/nordicsemi/android/common/scanner/view/ScanEmptyView.kt similarity index 69% rename from scanner/src/main/java/no/nordicsemi/android/kotlin/ble/ui/scanner/view/internal/ScanEmptyView.kt rename to scanner/src/main/java/no/nordicsemi/android/common/scanner/view/ScanEmptyView.kt index 8c001ea47..95ef55986 100644 --- a/scanner/src/main/java/no/nordicsemi/android/kotlin/ble/ui/scanner/view/internal/ScanEmptyView.kt +++ b/scanner/src/main/java/no/nordicsemi/android/common/scanner/view/ScanEmptyView.kt @@ -1,5 +1,5 @@ /* - * Copyright (c) 2024, Nordic Semiconductor + * Copyright (c) 2025, Nordic Semiconductor * All rights reserved. * * Redistribution and use in source and binary forms, with or without modification, are @@ -29,7 +29,7 @@ * EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. */ -package no.nordicsemi.android.kotlin.ble.ui.scanner.view.internal +package no.nordicsemi.android.common.scanner.view import android.content.Context import android.content.Intent @@ -44,36 +44,37 @@ 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.text.AnnotatedString +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.withStyle import androidx.compose.ui.tooling.preview.Devices import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp -import no.nordicsemi.android.common.core.parseBold -import no.nordicsemi.android.common.theme.NordicTheme +import no.nordicsemi.android.common.scanner.R import no.nordicsemi.android.common.ui.view.WarningView -import no.nordicsemi.android.kotlin.ble.ui.scanner.R @Composable -internal fun ScanEmptyView( - requireLocation: Boolean, -) { +internal fun ScanEmptyView(locationRequiredAndDisabled: Boolean) { WarningView( modifier = Modifier .fillMaxWidth() .padding(16.dp), imageVector = Icons.AutoMirrored.Filled.BluetoothSearching, - title = stringResource(id = R.string.no_device_guide_title), - hint = stringResource(id = R.string.no_device_guide_info) + if (requireLocation) { + title = stringResource(id = R.string.scan_empty_title), + hint = stringResource(id = R.string.no_device_guide_info) + if (locationRequiredAndDisabled) { "\n\n" + stringResource(id = R.string.no_device_guide_location_info) } else { "" }.parseBold(), hintTextAlign = TextAlign.Justify, ) { - if (requireLocation) { + if (locationRequiredAndDisabled) { val context = LocalContext.current Button(onClick = { openLocationSettings(context) }) { - Text(text = stringResource(id = R.string.action_location_settings)) + Text(text = stringResource(id = R.string.enable_location)) } } } @@ -85,22 +86,38 @@ private fun openLocationSettings(context: Context) { context.startActivity(intent) } -@Preview +@Preview(showBackground = true) @Composable private fun ScanEmptyViewPreview_RequiredLocation() { - NordicTheme { - ScanEmptyView( - requireLocation = true, - ) - } + ScanEmptyView( + locationRequiredAndDisabled = true, + ) } -@Preview(device = Devices.TABLET) +@Preview(device = Devices.TABLET, showBackground = true) @Composable private fun ScanEmptyViewPreview() { - NordicTheme { - ScanEmptyView( - requireLocation = false, - ) + ScanEmptyView( + locationRequiredAndDisabled = false, + ) +} + +/** + * Parses the string and makes the text between `` and `` bold using [AnnotatedString]. + */ +internal fun String.parseBold(): AnnotatedString { + val parts = this.split("", "") + return buildAnnotatedString { + var bold = false + for (part in parts) { + if (bold) { + withStyle(style = SpanStyle(fontWeight = FontWeight.Bold)) { + append(part) + } + } else { + append(part) + } + bold = !bold + } } -} \ No newline at end of file +} diff --git a/scanner/src/main/java/no/nordicsemi/android/kotlin/ble/ui/scanner/view/internal/ScanErrorView.kt b/scanner/src/main/java/no/nordicsemi/android/common/scanner/view/ScanErrorView.kt similarity index 84% rename from scanner/src/main/java/no/nordicsemi/android/kotlin/ble/ui/scanner/view/internal/ScanErrorView.kt rename to scanner/src/main/java/no/nordicsemi/android/common/scanner/view/ScanErrorView.kt index 49fe61246..b86fd5c8a 100644 --- a/scanner/src/main/java/no/nordicsemi/android/kotlin/ble/ui/scanner/view/internal/ScanErrorView.kt +++ b/scanner/src/main/java/no/nordicsemi/android/common/scanner/view/ScanErrorView.kt @@ -1,5 +1,5 @@ /* - * Copyright (c) 2024, Nordic Semiconductor + * Copyright (c) 2025, Nordic Semiconductor * All rights reserved. * * Redistribution and use in source and binary forms, with or without modification, are @@ -29,7 +29,7 @@ * EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. */ -package no.nordicsemi.android.kotlin.ble.ui.scanner.view.internal +package no.nordicsemi.android.common.scanner.view import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding @@ -40,28 +40,26 @@ import androidx.compose.ui.Modifier 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.scanner.R import no.nordicsemi.android.common.ui.view.WarningView -import no.nordicsemi.android.kotlin.ble.ui.scanner.R @Composable internal fun ScanErrorView( - error: Int, + error: String?, ) { + val message = error ?: "Unknown reason" WarningView( modifier = Modifier .fillMaxWidth() .padding(16.dp), imageVector = Icons.AutoMirrored.Filled.BluetoothSearching, - title = stringResource(id = R.string.scanner_error), - hint = stringResource(id = R.string.scan_failed, error), + title = stringResource(id = R.string.scan_failed_title), + hint = stringResource(id = R.string.scan_failed_info, message), ) } -@Preview +@Preview(showBackground = true) @Composable private fun ErrorSectionPreview() { - NordicTheme { - ScanErrorView(3) - } + ScanErrorView(null) } \ No newline at end of file diff --git a/scanner/src/main/java/no/nordicsemi/android/kotlin/ble/ui/scanner/view/ScannerAppBar.kt b/scanner/src/main/java/no/nordicsemi/android/common/scanner/view/ScannerAppBar.kt similarity index 51% rename from scanner/src/main/java/no/nordicsemi/android/kotlin/ble/ui/scanner/view/ScannerAppBar.kt rename to scanner/src/main/java/no/nordicsemi/android/common/scanner/view/ScannerAppBar.kt index 73779dd5b..b040a43ff 100644 --- a/scanner/src/main/java/no/nordicsemi/android/kotlin/ble/ui/scanner/view/ScannerAppBar.kt +++ b/scanner/src/main/java/no/nordicsemi/android/common/scanner/view/ScannerAppBar.kt @@ -29,40 +29,87 @@ * EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. */ -package no.nordicsemi.android.kotlin.ble.ui.scanner.view +package no.nordicsemi.android.common.scanner.view +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.filled.FilterList import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme 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.draw.clip import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.unit.dp +import no.nordicsemi.android.common.scanner.data.Filter +import no.nordicsemi.android.common.scanner.data.FilterEvent +import no.nordicsemi.android.common.scanner.viewmodel.UiState +import no.nordicsemi.android.common.scanner.viewmodel.ScanningState import no.nordicsemi.android.common.ui.view.NordicAppBar @OptIn(ExperimentalMaterial3Api::class) @Composable -fun ScannerAppBar( - text: String, - showProgress: Boolean = false, +internal fun ScannerAppBar( + title: @Composable () -> Unit, + uiState: UiState, + availableFilters : List, backButtonIcon: ImageVector = Icons.AutoMirrored.Filled.ArrowBack, + onFilterSelected: (FilterEvent) -> Unit, onNavigationButtonClick: (() -> Unit)? = null, ) { + var expandFilterBottomSheet by rememberSaveable { mutableStateOf(false) } + NordicAppBar( - text = text, + title = title, backButtonIcon = backButtonIcon, onNavigationButtonClick = onNavigationButtonClick, actions = { - if (showProgress) { - CircularProgressIndicator( - modifier = Modifier.padding(horizontal = 16.dp).size(30.dp), - color = MaterialTheme.colorScheme.onPrimary + Row( + modifier = Modifier.padding(8.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + if (uiState.isScanning) { + CircularProgressIndicator( + modifier = Modifier + .size(30.dp), + color = MaterialTheme.colorScheme.onPrimary + ) + } + Icon( + imageVector = Icons.Default.FilterList, + contentDescription = null, + modifier = Modifier + .clip(CircleShape) + .clickable { + expandFilterBottomSheet = true + } + .padding(8.dp) ) } }, ) -} \ No newline at end of file + + if (expandFilterBottomSheet) { + FilterDialog( + availableFilters = availableFilters, + scannedResults = (uiState.scanningState as ScanningState.DevicesDiscovered).result, + activeFilters = uiState.scanningState.filters, + onDismissRequest = { expandFilterBottomSheet = false }, + onFilterSelected = { onFilterSelected(it) } + ) + } +} diff --git a/scanner/src/main/java/no/nordicsemi/android/common/scanner/view/ScannerView.kt b/scanner/src/main/java/no/nordicsemi/android/common/scanner/view/ScannerView.kt new file mode 100644 index 000000000..19dde99a0 --- /dev/null +++ b/scanner/src/main/java/no/nordicsemi/android/common/scanner/view/ScannerView.kt @@ -0,0 +1,332 @@ +/* + * Copyright (c) 2025, Nordic Semiconductor + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without modification, are + * permitted provided that the following conditions are met: + * + * 1. Redistributions of source code must retain the above copyright notice, this list of + * conditions and the following disclaimer. + * + * 2. Redistributions in binary form must reproduce the above copyright notice, this list + * of conditions and the following disclaimer in the documentation and/or other materials + * provided with the distribution. + * + * 3. Neither the name of the copyright holder nor the names of its contributors may be + * used to endorse or promote products derived from this software without specific prior + * written permission. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS + * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED + * TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A + * PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT + * HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, + * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT + * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, + * OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY + * OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING + * NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, + * EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ + +package no.nordicsemi.android.common.scanner.view + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.expandIn +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.shrinkOut +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.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyListScope +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Bluetooth +import androidx.compose.material.icons.outlined.ExpandLess +import androidx.compose.material.icons.outlined.ExpandMore +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.pulltorefresh.PullToRefreshBox +import androidx.compose.material3.pulltorefresh.rememberPullToRefreshState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.rememberCoroutineScope +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.graphics.vector.ImageVector +import androidx.compose.ui.unit.dp +import kotlinx.coroutines.launch +import no.nordicsemi.android.common.permissions.ble.RequireBluetooth +import no.nordicsemi.android.common.permissions.ble.RequireLocation +import no.nordicsemi.android.common.scanner.data.OnReloadScanResults +import no.nordicsemi.android.common.scanner.data.UiEvent +import no.nordicsemi.android.common.scanner.spec.ServiceUuids +import no.nordicsemi.android.common.scanner.viewmodel.ScanningState +import no.nordicsemi.android.common.scanner.viewmodel.UiState +import no.nordicsemi.android.common.ui.view.CircularIcon +import no.nordicsemi.android.common.ui.view.RssiIcon +import no.nordicsemi.kotlin.ble.client.android.ScanResult +import kotlin.uuid.ExperimentalUuidApi + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +internal fun ScannerView( + uiState: UiState, + startScanning: () -> Unit, + onEvent: (UiEvent) -> Unit, + onScanResultSelected: (ScanResult) -> Unit, + deviceItem: @Composable (ScanResult) -> Unit = { scanResult -> + DeviceListItem(scanResult, peripheralIcon = Icons.Default.Bluetooth) + } +) { + val pullToRefreshState = rememberPullToRefreshState() + val scope = rememberCoroutineScope() + + Column( + modifier = Modifier.fillMaxSize() + ) { + RequireBluetooth { + RequireLocation { isLocationRequiredAndDisabled -> + // Both Bluetooth and Location permissions are granted. + // If the permission is not granted then the scanning will not start. + // So to start scanning we need to check if the location permission is granted. + LaunchedEffect(isLocationRequiredAndDisabled) { + startScanning() + } + Column(modifier = Modifier.fillMaxSize()) { + PullToRefreshBox( + isRefreshing = uiState.scanningState is ScanningState.Loading, + onRefresh = { + onEvent(OnReloadScanResults) + scope.launch { + pullToRefreshState.animateToHidden() + } + }, + state = pullToRefreshState, + content = { + DeviceListView( + isLocationRequiredAndDisabled = isLocationRequiredAndDisabled, + uiState = uiState, + onClick = { onScanResultSelected(it) }, + deviceItem = deviceItem + ) + } + ) + } + } + } + } +} + +@Composable +internal fun DeviceListView( + isLocationRequiredAndDisabled: Boolean, + uiState: UiState, + onClick: (ScanResult) -> Unit, + deviceItem: @Composable (ScanResult) -> Unit = { scanResult -> + DeviceListItem(scanResult, peripheralIcon = Icons.Default.Bluetooth) + }, +) { + LazyColumn { + when (uiState.scanningState) { + is ScanningState.Loading -> item { + ScanEmptyView( + locationRequiredAndDisabled = isLocationRequiredAndDisabled, + ) + } + + is ScanningState.Error -> item { ScanErrorView(uiState.scanningState.error) } + is ScanningState.DevicesDiscovered -> { + if (uiState.scanningState.result.isEmpty()) { + item { ScanEmptyView(isLocationRequiredAndDisabled) } + } else { + if (uiState.isGroupByNameEnabled) { + item { + GroupByNameDeviceList( + devices = uiState.scanningState.result, + onClick = onClick, + deviceItem = deviceItem, + ) + } + } else { + DeviceListItems( + uiState.scanningState.result, + onClick, + deviceItem + ) + } + } + } + } + } +} + +@Composable +private fun GroupByNameDeviceList( + devices: List, + peripheralIcon: ImageVector = Icons.Default.Bluetooth, + onClick: (ScanResult) -> Unit, + deviceItem: @Composable (ScanResult) -> Unit = { scanResult -> + DeviceListItem(scanResult, peripheralIcon = null) + }, +) { + var isExpanded by rememberSaveable { mutableStateOf(false) } + val expandIcon = if (isExpanded) Icons.Outlined.ExpandLess else Icons.Outlined.ExpandMore + Column( + verticalArrangement = Arrangement.spacedBy(8.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + modifier = Modifier + .fillMaxWidth() + .clickable { isExpanded = !isExpanded } + .padding(8.dp) + ) { + CircularIcon(peripheralIcon) + Text(text = devices.first().peripheral.name ?: "Unknown Device") + Spacer(modifier = Modifier.weight(1f)) + Icon( + imageVector = expandIcon, + contentDescription = null, + modifier = Modifier.padding(8.dp) + ) + } + + AnimatedVisibility( + visible = isExpanded, + enter = expandIn(expandFrom = Alignment.TopCenter) + fadeIn(), + exit = shrinkOut(shrinkTowards = Alignment.TopCenter) + fadeOut() + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(start = 8.dp) + ) { + HorizontalDivider() + VerticalBlueBar { + devices.forEach { scanResult -> + Row( + modifier = Modifier.clickable { + onClick(scanResult) + } + ) { + deviceItem(scanResult) + // Add a divider between items if not the last item + if (scanResult != devices.last()) { + HorizontalDivider() + } + } + } + } + } + } + HorizontalDivider() + } +} + +@Suppress("FunctionName") +internal fun LazyListScope.DeviceListItems( + devices: List, + onScanResultSelected: (ScanResult) -> Unit, + deviceItem: @Composable (ScanResult) -> Unit = { scanResult -> + DeviceListItem(scanResult, peripheralIcon = Icons.Default.Bluetooth) + }, +) { + items(devices.size) { index -> + Box( + modifier = Modifier + .clickable { + onScanResultSelected(devices[index]) + } + ) { + deviceItem(devices[index]) + if (index < devices.size) { + // Add a divider between items + HorizontalDivider() + } + } + } +} + +@OptIn(ExperimentalUuidApi::class) +@Composable +internal fun DeviceListItem( + device: ScanResult, + peripheralIcon: ImageVector? = Icons.Default.Bluetooth, +) { + val serviceUuids = device.advertisingData.serviceUuids + Row( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(16.dp) + ) { + peripheralIcon?.let { CircularIcon(peripheralIcon) } + + Column( + horizontalAlignment = Alignment.Start, + verticalArrangement = Arrangement.spacedBy(4.dp), + ) { + Row { + Column( + verticalArrangement = Arrangement.spacedBy(4.dp) + ) { + Text( + text = device.peripheral.name ?: "Unknown Device", + style = MaterialTheme.typography.titleMedium, + ) + Text( + text = device.peripheral.address, + style = MaterialTheme.typography.bodyMedium, + ) + } + Spacer(modifier = Modifier.weight(1f)) + + // Show RSSI if available + RssiIcon(device.rssi) + } + if (serviceUuids.isNotEmpty()) { + // Each service UUID is displayed with its icon in the row. instead of a column. + Row( + horizontalArrangement = Arrangement.spacedBy(4.dp) + ) { + serviceUuids.forEach { + ServiceUuids.getServiceInfo(it)?.let { serviceInfo -> + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(2.dp) + ) { + Text( + text = serviceInfo.service, + style = MaterialTheme.typography.bodyMedium + ) + Icon( + imageVector = serviceInfo.icon, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + ) + } + } + } + } + } + } + } +} diff --git a/scanner/src/main/java/no/nordicsemi/android/common/scanner/view/SortByView.kt b/scanner/src/main/java/no/nordicsemi/android/common/scanner/view/SortByView.kt new file mode 100644 index 000000000..2c0ab2c39 --- /dev/null +++ b/scanner/src/main/java/no/nordicsemi/android/common/scanner/view/SortByView.kt @@ -0,0 +1,87 @@ +package no.nordicsemi.android.common.scanner.view + +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.foundation.selection.selectable +import androidx.compose.foundation.selection.selectableGroup +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.RadioButton +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.clip +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import no.nordicsemi.android.common.scanner.R +import no.nordicsemi.android.common.scanner.data.Filter +import no.nordicsemi.android.common.scanner.data.FilterEvent +import no.nordicsemi.android.common.scanner.data.OnFilterSelected +import no.nordicsemi.android.common.scanner.data.SortBy +import no.nordicsemi.android.common.scanner.data.SortType + +@Composable +internal fun SortByView( + sortByFilters: List, + activeFilters: List, + onSortOptionSelected: (FilterEvent) -> Unit +) { + val currentSortByFilter = activeFilters.filterIsInstance() + .firstOrNull() + Column( + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + HorizontalDivider() + Text(text = stringResource(id = R.string.sort_by_title)) + Column( + modifier = Modifier.selectableGroup() + ) { + sortByFilters.forEach { sortBy -> + Row( + modifier = Modifier + .clip(RoundedCornerShape(8.dp)) + .selectable( + selected = sortBy.sortType == currentSortByFilter?.sortType, + onClick = { onSortOptionSelected(OnFilterSelected(SortBy(sortBy.sortType))) }, + role = Role.RadioButton + ), + ) { + Row( + modifier = Modifier + .fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(4.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + RadioButton( + selected = sortBy.sortType == currentSortByFilter?.sortType, + onClick = { onSortOptionSelected(OnFilterSelected(SortBy(sortBy.sortType))) } + ) + Text( + text = sortBy.sortType.toString(), + ) + + } + } + } + } + HorizontalDivider() + } +} + +@Preview(showBackground = true) +@Composable +private fun SortByViewPreview() { + SortByView( + sortByFilters = listOf( + SortBy(SortType.RSSI), + SortBy(SortType.ALPHABETICAL) + ), + activeFilters = listOf(), + onSortOptionSelected = {} + ) +} diff --git a/scanner/src/main/java/no/nordicsemi/android/common/scanner/view/VerticalBlueBar.kt b/scanner/src/main/java/no/nordicsemi/android/common/scanner/view/VerticalBlueBar.kt new file mode 100644 index 000000000..e4c4bb592 --- /dev/null +++ b/scanner/src/main/java/no/nordicsemi/android/common/scanner/view/VerticalBlueBar.kt @@ -0,0 +1,45 @@ +package no.nordicsemi.android.common.scanner.view + +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() + } + } +} diff --git a/scanner/src/main/java/no/nordicsemi/android/common/scanner/viewmodel/ScannerViewModel.kt b/scanner/src/main/java/no/nordicsemi/android/common/scanner/viewmodel/ScannerViewModel.kt new file mode 100644 index 000000000..f8a1b1291 --- /dev/null +++ b/scanner/src/main/java/no/nordicsemi/android/common/scanner/viewmodel/ScannerViewModel.kt @@ -0,0 +1,285 @@ +/* + * Copyright (c) 2025, Nordic Semiconductor + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without modification, are + * permitted provided that the following conditions are met: + * + * 1. Redistributions of source code must retain the above copyright notice, this list of + * conditions and the following disclaimer. + * + * 2. Redistributions in binary form must reproduce the above copyright notice, this list + * of conditions and the following disclaimer in the documentation and/or other materials + * provided with the distribution. + * + * 3. Neither the name of the copyright holder nor the names of its contributors may be + * used to endorse or promote products derived from this software without specific prior + * written permission. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS + * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED + * TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A + * PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT + * HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, + * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT + * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, + * OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY + * OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING + * NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, + * EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ + +package no.nordicsemi.android.common.scanner.viewmodel + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.cancellable +import kotlinx.coroutines.flow.catch +import kotlinx.coroutines.flow.filter +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onCompletion +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.flow.onStart +import kotlinx.coroutines.flow.update +import no.nordicsemi.android.common.scanner.data.CustomFilter +import no.nordicsemi.android.common.scanner.data.Filter +import no.nordicsemi.android.common.scanner.data.GroupByName +import no.nordicsemi.android.common.scanner.data.OnFilterReset +import no.nordicsemi.android.common.scanner.data.OnFilterSelected +import no.nordicsemi.android.common.scanner.data.OnReloadScanResults +import no.nordicsemi.android.common.scanner.data.OnlyBonded +import no.nordicsemi.android.common.scanner.data.OnlyNearby +import no.nordicsemi.android.common.scanner.data.OnlyWithNames +import no.nordicsemi.android.common.scanner.data.SortBy +import no.nordicsemi.android.common.scanner.data.SortType +import no.nordicsemi.android.common.scanner.data.UiEvent +import no.nordicsemi.android.common.scanner.data.WithServiceUuid +import no.nordicsemi.kotlin.ble.client.android.CentralManager +import no.nordicsemi.kotlin.ble.client.android.ScanResult +import no.nordicsemi.kotlin.ble.client.android.exception.ScanningFailedToStartException +import javax.inject.Inject +import kotlin.time.Duration.Companion.milliseconds +import kotlin.uuid.ExperimentalUuidApi +import kotlin.uuid.Uuid + +/** + * This class is responsible for managing the ui states of the scanner screen. + * + * @param isScanning True if the scanner is scanning. + * @param scanningState The current scanning state. + */ +internal data class UiState( + val isScanning: Boolean = false, + val scanningState: ScanningState = ScanningState.Loading, + val isGroupByNameEnabled: Boolean = false, +) + +@OptIn(ExperimentalUuidApi::class) +@HiltViewModel +internal class ScannerViewModel @Inject constructor( + private val centralManager: CentralManager, +) : ViewModel() { + private val _uiState = MutableStateFlow(UiState()) + val uiState = _uiState.asStateFlow() + private var job: Job? = null + private var uuid: Uuid? = null + + private val _scanResultFilter = MutableStateFlow>(emptyList()) + + private val _originalScanResults = MutableStateFlow>(emptyList()) + + init { + _scanResultFilter.onEach { filters -> + // Apply the filter to the scan results. + val originalResults = _originalScanResults.value + val filteredResults = originalResults.applyFilter(filters) + _uiState.update { + it.copy( + scanningState = ScanningState.DevicesDiscovered( + result = filteredResults, + filters = filters, + ) + ) + } + }.launchIn(viewModelScope) + } + + @OptIn(ExperimentalUuidApi::class) + fun startScanning( + scanDuration: Long = 2000L, + ) { + job?.cancel() + job = centralManager.scan(scanDuration.milliseconds) { uuid?.let { ServiceUuid(it) } } + // Filter out the scan results based on the provided filter in the scanResultFilter. + .filter { it.isConnectable } + .onStart { + // Update the scanning state to loading when the scan starts. + _uiState.update { + it.copy( + isScanning = true, + scanningState = ScanningState.DevicesDiscovered( + result = emptyList(), + filters = _scanResultFilter.value + ) + ) + } + } + .cancellable() + .onEach { scanResult -> + val scanResults = + _uiState.value.scanningState.let { state -> + if (state is ScanningState.DevicesDiscovered) state.result else emptyList() + } + // Check if the device is already in the list. + val isExistingDevice = + scanResults.firstOrNull { it.peripheral.address == scanResult.peripheral.address } + // Add the device to the list if it is not already in the list, otherwise ignore it. + if (isExistingDevice == null) { + val result = (scanResults + scanResult) + _originalScanResults.value = result + _uiState.update { + it.copy( + scanningState = ScanningState.DevicesDiscovered( + result = result.applyFilter(_scanResultFilter.value), + filters = _scanResultFilter.value + ) + ) + } + } + } + // Update the scanning state when the scan is completed. + .onCompletion { + _uiState.update { it.copy(isScanning = false) } + job?.cancel() + } + .catch { throwable -> + (throwable as? ScanningFailedToStartException)?.let { exception -> + _uiState.update { + it.copy( + isScanning = false, + scanningState = ScanningState.Error(exception.message) + ) + } + } + } + .launchIn(viewModelScope) + } + + private fun List.applyFilter(filters: List): List { + if (filters.isEmpty()) return this + val sortBy = filters.filterIsInstance().firstOrNull() + + // Apply all filters to all options except SortBy + val filtered = this.filter { scanResult -> + filters.filterNot { it is SortBy }.all { it.filter(scanResult) } + }.distinct() + + return when (sortBy?.sortType) { + SortType.RSSI -> filtered.sortedByDescending { it.rssi } + SortType.ALPHABETICAL -> filtered.sortedWith( + compareBy(nullsLast()) { it.peripheral.name } + ) + + null -> filtered + } + } + + fun onClick(event: UiEvent) { + when (event) { + OnReloadScanResults -> { + _uiState.update { + it.copy( + scanningState = ScanningState.DevicesDiscovered( + result = emptyList(), + filters = _scanResultFilter.value + ) + ) + } + startScanning() + } + + OnFilterReset -> { + _scanResultFilter.update { emptyList() } + val originalResults = _originalScanResults.value + _uiState.update { + it.copy( + isGroupByNameEnabled = false, + scanningState = ScanningState.DevicesDiscovered( + result = originalResults, + filters = _scanResultFilter.value + ) + ) + } + } + + is OnFilterSelected -> { + // Update the filter list with the selected filter. + val currentFilter = _scanResultFilter.value.toMutableList() + when (event.filter) { + is CustomFilter, + is OnlyBonded, + is OnlyNearby, + is OnlyWithNames, + // todo: remove the service uuid from here later. It should go on the scan result. + is WithServiceUuid -> { + currentFilter.addOrRemove(event.filter) + _scanResultFilter.update { + currentFilter + } + } + + is GroupByName -> { + val updatedFilter = + currentFilter.filterNot { it is GroupByName } + .toMutableList() + _uiState.update { + it.copy(isGroupByNameEnabled = true) + } + updatedFilter.add(event.filter) + _scanResultFilter.update { updatedFilter } + } + + is SortBy -> { + val updatedFilter = + currentFilter.filterNot { it is SortBy }.toMutableList() + updatedFilter.add(event.filter) + _scanResultFilter.update { updatedFilter } + + } + } + } + } + } + + /** + * Sets the filters for the scanner. + */ + fun setFilters(filters: List) { + filters.forEach { filter -> + when (filter) { + // If uuid is set, it will be used to filter the scan results. + is WithServiceUuid -> uuid = filter.uuid + else -> { + // For other filters, we will add or remove them from the filter list. + val currentFilter = _scanResultFilter.value.toMutableList() + currentFilter.addOrRemove(filter) + _scanResultFilter.update { + currentFilter + } + } + } + } + } + + private fun MutableList.addOrRemove(item: T) { + if (contains(item)) { + remove(item) + } else { + add(item) + } + } +} \ No newline at end of file diff --git a/scanner/src/main/java/no/nordicsemi/android/kotlin/ble/ui/scanner/ScannerScreenResult.kt b/scanner/src/main/java/no/nordicsemi/android/common/scanner/viewmodel/ScanningState.kt similarity index 66% rename from scanner/src/main/java/no/nordicsemi/android/kotlin/ble/ui/scanner/ScannerScreenResult.kt rename to scanner/src/main/java/no/nordicsemi/android/common/scanner/viewmodel/ScanningState.kt index f2887458a..4b708e0b2 100644 --- a/scanner/src/main/java/no/nordicsemi/android/kotlin/ble/ui/scanner/ScannerScreenResult.kt +++ b/scanner/src/main/java/no/nordicsemi/android/common/scanner/viewmodel/ScanningState.kt @@ -1,5 +1,5 @@ /* - * Copyright (c) 2024, Nordic Semiconductor + * Copyright (c) 2025, Nordic Semiconductor * All rights reserved. * * Redistribution and use in source and binary forms, with or without modification, are @@ -29,12 +29,29 @@ * EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. */ -package no.nordicsemi.android.kotlin.ble.ui.scanner +package no.nordicsemi.android.common.scanner.viewmodel -import no.nordicsemi.android.kotlin.ble.core.scanner.BleScanResults +import no.nordicsemi.android.common.scanner.data.Filter +import no.nordicsemi.kotlin.ble.client.android.ScanResult -sealed interface ScannerScreenResult +/** ScanningState represents the state of the scanning process. */ +internal sealed interface ScanningState { -data object ScanningCancelled : ScannerScreenResult + /** Loading state. */ + data object Loading : ScanningState -data class DeviceSelected(val scanResults: BleScanResults) : ScannerScreenResult + /** Devices discovered state. + * + * @param result The list of discovered devices. + */ + data class DevicesDiscovered( + val result: List, + val filters: List + ) : ScanningState + + /** Error state. + * + * @param error The error that occurred. + */ + data class Error(val error: String?) : ScanningState +} \ No newline at end of file diff --git a/scanner/src/main/java/no/nordicsemi/android/kotlin/ble/ui/scanner/ScannerScreen.kt b/scanner/src/main/java/no/nordicsemi/android/kotlin/ble/ui/scanner/ScannerScreen.kt deleted file mode 100644 index 89841d4f0..000000000 --- a/scanner/src/main/java/no/nordicsemi/android/kotlin/ble/ui/scanner/ScannerScreen.kt +++ /dev/null @@ -1,70 +0,0 @@ -/* - * Copyright (c) 2024, Nordic Semiconductor - * All rights reserved. - * - * Redistribution and use in source and binary forms, with or without modification, are - * permitted provided that the following conditions are met: - * - * 1. Redistributions of source code must retain the above copyright notice, this list of - * conditions and the following disclaimer. - * - * 2. Redistributions in binary form must reproduce the above copyright notice, this list - * of conditions and the following disclaimer in the documentation and/or other materials - * provided with the distribution. - * - * 3. Neither the name of the copyright holder nor the names of its contributors may be - * used to endorse or promote products derived from this software without specific prior - * written permission. - * - * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS - * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED - * TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A - * PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT - * HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, - * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT - * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, - * OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY - * OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING - * NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, - * EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. - */ -package no.nordicsemi.android.kotlin.ble.ui.scanner - -import android.os.ParcelUuid -import androidx.compose.foundation.layout.Column -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.res.stringResource -import no.nordicsemi.android.kotlin.ble.ui.scanner.main.DeviceListItem -import no.nordicsemi.android.kotlin.ble.ui.scanner.view.ScannerAppBar -import no.nordicsemi.android.kotlin.ble.core.scanner.BleScanResults - -@Composable -fun ScannerScreen( - title: String = stringResource(id = R.string.scanner_screen), - uuid: ParcelUuid?, - cancellable: Boolean = true, - onResult: (ScannerScreenResult) -> Unit, - deviceItem: @Composable (BleScanResults) -> Unit = { - DeviceListItem(it.advertisedName ?: it.device.name, it.device.address) - } -) { - var isScanning by rememberSaveable { mutableStateOf(false) } - - Column { - if (cancellable) { - ScannerAppBar(title, isScanning) { onResult(ScanningCancelled) } - } else { - ScannerAppBar(title, isScanning) - } - ScannerView( - uuid = uuid, - onScanningStateChanged = { isScanning = it }, - onResult = { onResult(DeviceSelected(it)) }, - deviceItem = deviceItem, - ) - } -} diff --git a/scanner/src/main/java/no/nordicsemi/android/kotlin/ble/ui/scanner/ScannerView.kt b/scanner/src/main/java/no/nordicsemi/android/kotlin/ble/ui/scanner/ScannerView.kt deleted file mode 100644 index ebbea08d6..000000000 --- a/scanner/src/main/java/no/nordicsemi/android/kotlin/ble/ui/scanner/ScannerView.kt +++ /dev/null @@ -1,124 +0,0 @@ -/* - * Copyright (c) 2024, Nordic Semiconductor - * All rights reserved. - * - * Redistribution and use in source and binary forms, with or without modification, are - * permitted provided that the following conditions are met: - * - * 1. Redistributions of source code must retain the above copyright notice, this list of - * conditions and the following disclaimer. - * - * 2. Redistributions in binary form must reproduce the above copyright notice, this list - * of conditions and the following disclaimer in the documentation and/or other materials - * provided with the distribution. - * - * 3. Neither the name of the copyright holder nor the names of its contributors may be - * used to endorse or promote products derived from this software without specific prior - * written permission. - * - * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS - * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED - * TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A - * PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT - * HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, - * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT - * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, - * OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY - * OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING - * NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, - * EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. - */ - -package no.nordicsemi.android.kotlin.ble.ui.scanner - -import android.os.ParcelUuid -import androidx.compose.foundation.background -import androidx.compose.foundation.layout.* -import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.pulltorefresh.PullToRefreshContainer -import androidx.compose.material3.pulltorefresh.rememberPullToRefreshState -import androidx.compose.runtime.* -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clipToBounds -import androidx.compose.ui.input.nestedscroll.nestedScroll -import androidx.compose.ui.res.colorResource -import androidx.compose.ui.unit.dp -import androidx.hilt.navigation.compose.hiltViewModel -import androidx.lifecycle.compose.collectAsStateWithLifecycle -import no.nordicsemi.android.common.permissions.ble.RequireBluetooth -import no.nordicsemi.android.common.permissions.ble.RequireLocation -import no.nordicsemi.android.common.theme.R -import no.nordicsemi.android.kotlin.ble.core.scanner.BleScanResults -import no.nordicsemi.android.kotlin.ble.ui.scanner.main.DeviceListItem -import no.nordicsemi.android.kotlin.ble.ui.scanner.main.DevicesListView -import no.nordicsemi.android.kotlin.ble.ui.scanner.main.viewmodel.ScannerViewModel -import no.nordicsemi.android.kotlin.ble.ui.scanner.repository.ScanningState -import no.nordicsemi.android.kotlin.ble.ui.scanner.view.internal.FilterView - -@OptIn(ExperimentalMaterial3Api::class) -@Composable -fun ScannerView( - uuid: ParcelUuid?, - onScanningStateChanged: (Boolean) -> Unit = {}, - onResult: (BleScanResults) -> Unit, - deviceItem: @Composable (BleScanResults) -> Unit = { - DeviceListItem(it.advertisedName ?: it.device.name, it.device.address) - }, - showFilter: Boolean = true -) { - RequireBluetooth( - onChanged = { onScanningStateChanged(it) } - ) { - RequireLocation( - onChanged = { onScanningStateChanged(it) } - ) { isLocationRequiredAndDisabled -> - val viewModel = hiltViewModel() - .apply { setFilterUuid(uuid) } - - val state by viewModel.state.collectAsStateWithLifecycle(ScanningState.Loading) - val config by viewModel.filterConfig.collectAsStateWithLifecycle() - - Column(modifier = Modifier.fillMaxSize()) { - if (showFilter) - FilterView( - config = config, - onChanged = { viewModel.setFilter(it) }, - modifier = Modifier - .fillMaxWidth() - .background(colorResource(id = R.color.appBarColor)) - .padding(horizontal = 16.dp), - ) - - val pullRefreshState = rememberPullToRefreshState() - if (pullRefreshState.isRefreshing) { - LaunchedEffect(true) { - viewModel.refresh() - pullRefreshState.endRefresh() - } - } - - Box( - modifier = Modifier - .nestedScroll(pullRefreshState.nestedScrollConnection) - .clipToBounds() - ) { - if (!pullRefreshState.isRefreshing) { - DevicesListView( - isLocationRequiredAndDisabled = isLocationRequiredAndDisabled, - state = state, - modifier = Modifier.fillMaxSize(), - onClick = { onResult(it) }, - deviceItem = deviceItem, - ) - } - - PullToRefreshContainer( - modifier = Modifier.align(Alignment.TopCenter), - state = pullRefreshState, - ) - } - } - } - } -} diff --git a/scanner/src/main/java/no/nordicsemi/android/kotlin/ble/ui/scanner/main/DeviceListItem.kt b/scanner/src/main/java/no/nordicsemi/android/kotlin/ble/ui/scanner/main/DeviceListItem.kt deleted file mode 100644 index 297aeb318..000000000 --- a/scanner/src/main/java/no/nordicsemi/android/kotlin/ble/ui/scanner/main/DeviceListItem.kt +++ /dev/null @@ -1,107 +0,0 @@ -/* - * Copyright (c) 2024, Nordic Semiconductor - * All rights reserved. - * - * Redistribution and use in source and binary forms, with or without modification, are - * permitted provided that the following conditions are met: - * - * 1. Redistributions of source code must retain the above copyright notice, this list of - * conditions and the following disclaimer. - * - * 2. Redistributions in binary form must reproduce the above copyright notice, this list - * of conditions and the following disclaimer in the documentation and/or other materials - * provided with the distribution. - * - * 3. Neither the name of the copyright holder nor the names of its contributors may be - * used to endorse or promote products derived from this software without specific prior - * written permission. - * - * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS - * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED - * TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A - * PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT - * HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, - * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT - * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, - * OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY - * OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING - * NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, - * EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. - */ - -package no.nordicsemi.android.kotlin.ble.ui.scanner.main - -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.width -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.Bluetooth -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.draw.alpha -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.ui.view.CircularIcon -import no.nordicsemi.android.common.ui.view.RssiIcon -import no.nordicsemi.android.kotlin.ble.ui.scanner.R - -@Composable -fun DeviceListItem( - name: String?, - address: String, - modifier: Modifier = Modifier, - extras: @Composable () -> Unit = {}, -) { - Row( - modifier = modifier, - verticalAlignment = Alignment.CenterVertically) - { - CircularIcon(Icons.Default.Bluetooth) - - Spacer(modifier = Modifier.width(16.dp)) - - Column( - modifier = Modifier - .fillMaxWidth() - .weight(1f) - ) { - name?.takeIf { it.isNotEmpty() }?.let { name -> - Text( - text = name, - style = MaterialTheme.typography.titleMedium - ) - } ?: Text( - text = stringResource(id = R.string.device_no_name), - style = MaterialTheme.typography.titleMedium, - modifier = Modifier.alpha(0.7f) - ) - Text( - text = address, - style = MaterialTheme.typography.bodyMedium - ) - } - - extras() - } -} - -@Preview -@Composable -private fun DeviceListItemPreview() { - NordicTheme { - DeviceListItem( - name = "Device name", - address = "AA:BB:CC:DD:EE:FF", - extras = { - RssiIcon(rssi = -45) - } - ) - } -} \ No newline at end of file diff --git a/scanner/src/main/java/no/nordicsemi/android/kotlin/ble/ui/scanner/main/DeviceListItems.kt b/scanner/src/main/java/no/nordicsemi/android/kotlin/ble/ui/scanner/main/DeviceListItems.kt deleted file mode 100644 index 6e88b3b0a..000000000 --- a/scanner/src/main/java/no/nordicsemi/android/kotlin/ble/ui/scanner/main/DeviceListItems.kt +++ /dev/null @@ -1,100 +0,0 @@ -/* - * Copyright (c) 2024, Nordic Semiconductor - * All rights reserved. - * - * Redistribution and use in source and binary forms, with or without modification, are - * permitted provided that the following conditions are met: - * - * 1. Redistributions of source code must retain the above copyright notice, this list of - * conditions and the following disclaimer. - * - * 2. Redistributions in binary form must reproduce the above copyright notice, this list - * of conditions and the following disclaimer in the documentation and/or other materials - * provided with the distribution. - * - * 3. Neither the name of the copyright holder nor the names of its contributors may be - * used to endorse or promote products derived from this software without specific prior - * written permission. - * - * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS - * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED - * TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A - * PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT - * HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, - * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT - * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, - * OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY - * OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING - * NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, - * EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. - */ - -package no.nordicsemi.android.kotlin.ble.ui.scanner.main - -import androidx.compose.foundation.clickable -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.lazy.LazyListScope -import androidx.compose.foundation.shape.RoundedCornerShape -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.clip -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.unit.dp -import no.nordicsemi.android.kotlin.ble.ui.scanner.repository.ScanningState -import no.nordicsemi.android.kotlin.ble.core.scanner.BleScanResults -import no.nordicsemi.android.kotlin.ble.ui.scanner.R - -@Suppress("FunctionName") -fun LazyListScope.DeviceListItems( - devices: ScanningState.DevicesDiscovered, - onClick: (BleScanResults) -> Unit, - deviceView: @Composable (BleScanResults) -> Unit, -) { - val bondedDevices = devices.bonded - val discoveredDevices = devices.notBonded - - if (bondedDevices.isNotEmpty()) { - item { - Text( - text = stringResource(id = R.string.bonded_devices), - style = MaterialTheme.typography.titleSmall, - modifier = Modifier.padding(start = 8.dp, bottom = 8.dp) - ) - } - items(bondedDevices.size) { - ClickableDeviceItem(bondedDevices[it], onClick, deviceView) - } - } - - if (discoveredDevices.isNotEmpty()) { - item { - Text( - text = stringResource(id = R.string.discovered_devices), - style = MaterialTheme.typography.titleSmall, - modifier = Modifier.padding(start = 8.dp, bottom = 8.dp) - ) - } - - items(discoveredDevices.size) { - ClickableDeviceItem(discoveredDevices[it], onClick, deviceView) - } - } -} - -@Composable -private fun ClickableDeviceItem( - device: BleScanResults, - onClick: (BleScanResults) -> Unit, - deviceView: @Composable (BleScanResults) -> Unit, -) { - Box(modifier = Modifier - .clip(RoundedCornerShape(10.dp)) - .clickable { onClick(device) } - .padding(8.dp) - ) { - deviceView(device) - } -} diff --git a/scanner/src/main/java/no/nordicsemi/android/kotlin/ble/ui/scanner/main/DevicesListView.kt b/scanner/src/main/java/no/nordicsemi/android/kotlin/ble/ui/scanner/main/DevicesListView.kt deleted file mode 100644 index e3ae3fa5d..000000000 --- a/scanner/src/main/java/no/nordicsemi/android/kotlin/ble/ui/scanner/main/DevicesListView.kt +++ /dev/null @@ -1,120 +0,0 @@ -/* - * Copyright (c) 2024, Nordic Semiconductor - * All rights reserved. - * - * Redistribution and use in source and binary forms, with or without modification, are - * permitted provided that the following conditions are met: - * - * 1. Redistributions of source code must retain the above copyright notice, this list of - * conditions and the following disclaimer. - * - * 2. Redistributions in binary form must reproduce the above copyright notice, this list - * of conditions and the following disclaimer in the documentation and/or other materials - * provided with the distribution. - * - * 3. Neither the name of the copyright holder nor the names of its contributors may be - * used to endorse or promote products derived from this software without specific prior - * written permission. - * - * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS - * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED - * TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A - * PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT - * HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, - * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT - * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, - * OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY - * OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING - * NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, - * EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. - */ - -package no.nordicsemi.android.kotlin.ble.ui.scanner.main - -import androidx.compose.foundation.layout.PaddingValues -import androidx.compose.foundation.lazy.LazyColumn -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 -import no.nordicsemi.android.kotlin.ble.ui.scanner.repository.ScanningState -import no.nordicsemi.android.kotlin.ble.ui.scanner.view.internal.ScanEmptyView -import no.nordicsemi.android.kotlin.ble.ui.scanner.view.internal.ScanErrorView -import no.nordicsemi.android.kotlin.ble.core.scanner.BleScanResults - -@Composable -fun DevicesListView( - isLocationRequiredAndDisabled: Boolean, - state: ScanningState, - onClick: (BleScanResults) -> Unit, - modifier: Modifier = Modifier, - deviceItem: @Composable (BleScanResults) -> Unit = { - DeviceListItem(it.device.name, it.device.address) - }, -) { - LazyColumn( - modifier = modifier, - contentPadding = PaddingValues(horizontal = 8.dp, vertical = 16.dp) - ) { - when (state) { - is ScanningState.Loading -> item { ScanEmptyView(isLocationRequiredAndDisabled) } - is ScanningState.DevicesDiscovered -> { - if (state.isEmpty()) { - item { ScanEmptyView(isLocationRequiredAndDisabled) } - } else { - DeviceListItems(state, onClick, deviceItem) - } - } - is ScanningState.Error -> item { ScanErrorView(state.errorCode) } - } - } -} - -@Preview(name = "Location required") -@Composable -private fun DeviceListView_Preview_LocationRequired() { - NordicTheme { - DevicesListView( - isLocationRequiredAndDisabled = true, - state = ScanningState.Loading, - onClick = {} - ) - } -} - -@Preview -@Composable -private fun DeviceListView_Preview_LocationNotRequired() { - NordicTheme { - DevicesListView( - isLocationRequiredAndDisabled = false, - state = ScanningState.Loading, - onClick = {} - ) - } -} - -@Preview -@Composable -private fun DeviceListView_Preview_Error() { - NordicTheme { - DevicesListView( - isLocationRequiredAndDisabled = true, - state = ScanningState.Error(1), - onClick = {} - ) - } -} - -@Preview -@Composable -private fun DeviceListView_Preview_Empty() { - NordicTheme { - DevicesListView( - isLocationRequiredAndDisabled = true, - state = ScanningState.DevicesDiscovered(emptyList()), - onClick = {} - ) - } -} diff --git a/scanner/src/main/java/no/nordicsemi/android/kotlin/ble/ui/scanner/main/viewmodel/ScannerViewModel.kt b/scanner/src/main/java/no/nordicsemi/android/kotlin/ble/ui/scanner/main/viewmodel/ScannerViewModel.kt deleted file mode 100644 index 2f2b9a195..000000000 --- a/scanner/src/main/java/no/nordicsemi/android/kotlin/ble/ui/scanner/main/viewmodel/ScannerViewModel.kt +++ /dev/null @@ -1,131 +0,0 @@ -/* - * Copyright (c) 2024, Nordic Semiconductor - * All rights reserved. - * - * Redistribution and use in source and binary forms, with or without modification, are - * permitted provided that the following conditions are met: - * - * 1. Redistributions of source code must retain the above copyright notice, this list of - * conditions and the following disclaimer. - * - * 2. Redistributions in binary form must reproduce the above copyright notice, this list - * of conditions and the following disclaimer in the documentation and/or other materials - * provided with the distribution. - * - * 3. Neither the name of the copyright holder nor the names of its contributors may be - * used to endorse or promote products derived from this software without specific prior - * written permission. - * - * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS - * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED - * TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A - * PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT - * HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, - * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT - * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, - * OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY - * OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING - * NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, - * EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. - */ - -package no.nordicsemi.android.kotlin.ble.ui.scanner.main.viewmodel - -import android.os.ParcelUuid -import androidx.lifecycle.ViewModel -import androidx.lifecycle.viewModelScope -import dagger.hilt.android.lifecycle.HiltViewModel -import kotlinx.coroutines.Job -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.asStateFlow -import kotlinx.coroutines.flow.cancellable -import kotlinx.coroutines.flow.catch -import kotlinx.coroutines.flow.combine -import kotlinx.coroutines.flow.filter -import kotlinx.coroutines.flow.launchIn -import kotlinx.coroutines.flow.map -import kotlinx.coroutines.flow.onEach -import kotlinx.coroutines.flow.onStart -import no.nordicsemi.android.kotlin.ble.core.scanner.BleScanResults -import no.nordicsemi.android.kotlin.ble.scanner.aggregator.BleScanResultAggregator -import no.nordicsemi.android.kotlin.ble.scanner.errors.ScanFailedError -import no.nordicsemi.android.kotlin.ble.scanner.errors.ScanningFailedException -import no.nordicsemi.android.kotlin.ble.ui.scanner.repository.DevicesScanFilter -import no.nordicsemi.android.kotlin.ble.ui.scanner.repository.ScannerRepository -import no.nordicsemi.android.kotlin.ble.ui.scanner.repository.ScanningState -import javax.inject.Inject - -private const val FILTER_RSSI = -50 // [dBm] - -@HiltViewModel -internal class ScannerViewModel @Inject constructor( - private val scannerRepository: ScannerRepository, -) : ViewModel() { - private var uuid: ParcelUuid? = null - - val filterConfig = MutableStateFlow( - DevicesScanFilter( - filterUuidRequired = true, - filterNearbyOnly = false, - filterWithNames = true - ) - ) - - private var currentJob: Job? = null - - private val _state = MutableStateFlow(ScanningState.Loading) - val state = _state.asStateFlow() - - init { - relaunchScanning() - } - - private fun relaunchScanning() { - currentJob?.cancel() - val aggregator = BleScanResultAggregator() - currentJob = scannerRepository.getScannerState() - .map { aggregator.aggregate(it) } - .filter { it.isNotEmpty() } - .combine(filterConfig) { result, config -> - result.applyFilters(config) - } - .onStart { _state.value = ScanningState.Loading } - .cancellable() - .onEach { - _state.value = ScanningState.DevicesDiscovered(it) - } - .catch { e -> - _state.value = (e as? ScanningFailedException)?.let { - ScanningState.Error(it.errorCode.value) - } ?: ScanningState.Error(ScanFailedError.UNKNOWN.value) - } - .launchIn(viewModelScope) - } - - // This can't be observed in View Model Scope, as it can exist even when the - // scanner is not visible. Scanner state stops scanning when it is not observed. - // .stateIn(viewModelScope, SharingStarted.Lazily, ScanningState.Loading) - private fun List.applyFilters(config: DevicesScanFilter) = - filter { - uuid == null || - config.filterUuidRequired == false || - it.lastScanResult?.scanRecord?.serviceUuids?.contains(uuid) == true - } - .filter { !config.filterNearbyOnly || it.highestRssi >= FILTER_RSSI } - .filter { !config.filterWithNames || it.device.hasName || it.advertisedName?.isNotEmpty() == true } - - fun setFilterUuid(uuid: ParcelUuid?) { - this.uuid = uuid - if (uuid == null) { - filterConfig.value = filterConfig.value.copy(filterUuidRequired = null) - } - } - - fun setFilter(config: DevicesScanFilter) { - this.filterConfig.value = config - } - - fun refresh() { - relaunchScanning() - } -} diff --git a/scanner/src/main/java/no/nordicsemi/android/kotlin/ble/ui/scanner/repository/DevicesScanFilter.kt b/scanner/src/main/java/no/nordicsemi/android/kotlin/ble/ui/scanner/repository/DevicesScanFilter.kt deleted file mode 100644 index 946a45dcd..000000000 --- a/scanner/src/main/java/no/nordicsemi/android/kotlin/ble/ui/scanner/repository/DevicesScanFilter.kt +++ /dev/null @@ -1,38 +0,0 @@ -/* - * Copyright (c) 2022, Nordic Semiconductor - * All rights reserved. - * - * Redistribution and use in source and binary forms, with or without modification, are - * permitted provided that the following conditions are met: - * - * 1. Redistributions of source code must retain the above copyright notice, this list of - * conditions and the following disclaimer. - * - * 2. Redistributions in binary form must reproduce the above copyright notice, this list - * of conditions and the following disclaimer in the documentation and/or other materials - * provided with the distribution. - * - * 3. Neither the name of the copyright holder nor the names of its contributors may be - * used to endorse or promote products derived from this software without specific prior - * written permission. - * - * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS - * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED - * TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A - * PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT - * HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, - * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT - * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, - * OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY - * OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING - * NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, - * EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. - */ - -package no.nordicsemi.android.kotlin.ble.ui.scanner.repository - -internal data class DevicesScanFilter( - val filterUuidRequired: Boolean?, - val filterNearbyOnly: Boolean, - val filterWithNames: Boolean -) diff --git a/scanner/src/main/java/no/nordicsemi/android/kotlin/ble/ui/scanner/repository/ScannerRepository.kt b/scanner/src/main/java/no/nordicsemi/android/kotlin/ble/ui/scanner/repository/ScannerRepository.kt deleted file mode 100644 index cfbc573ae..000000000 --- a/scanner/src/main/java/no/nordicsemi/android/kotlin/ble/ui/scanner/repository/ScannerRepository.kt +++ /dev/null @@ -1,46 +0,0 @@ -/* - * Copyright (c) 2022, Nordic Semiconductor - * All rights reserved. - * - * Redistribution and use in source and binary forms, with or without modification, are - * permitted provided that the following conditions are met: - * - * 1. Redistributions of source code must retain the above copyright notice, this list of - * conditions and the following disclaimer. - * - * 2. Redistributions in binary form must reproduce the above copyright notice, this list - * of conditions and the following disclaimer in the documentation and/or other materials - * provided with the distribution. - * - * 3. Neither the name of the copyright holder nor the names of its contributors may be - * used to endorse or promote products derived from this software without specific prior - * written permission. - * - * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS - * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED - * TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A - * PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT - * HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, - * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT - * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, - * OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY - * OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING - * NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, - * EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. - */ - -package no.nordicsemi.android.kotlin.ble.ui.scanner.repository - -import android.annotation.SuppressLint -import dagger.hilt.android.scopes.ViewModelScoped -import no.nordicsemi.android.kotlin.ble.scanner.BleScanner -import javax.inject.Inject - -@ViewModelScoped -class ScannerRepository @Inject internal constructor( - private val nordicScanner: BleScanner -) { - - @SuppressLint("MissingPermission") - fun getScannerState() = nordicScanner.scan() -} diff --git a/scanner/src/main/java/no/nordicsemi/android/kotlin/ble/ui/scanner/view/DeviceConnectingView.kt b/scanner/src/main/java/no/nordicsemi/android/kotlin/ble/ui/scanner/view/DeviceConnectingView.kt deleted file mode 100644 index a4d78fff6..000000000 --- a/scanner/src/main/java/no/nordicsemi/android/kotlin/ble/ui/scanner/view/DeviceConnectingView.kt +++ /dev/null @@ -1,116 +0,0 @@ -/* - * Copyright (c) 2024, Nordic Semiconductor - * All rights reserved. - * - * Redistribution and use in source and binary forms, with or without modification, are - * permitted provided that the following conditions are met: - * - * 1. Redistributions of source code must retain the above copyright notice, this list of - * conditions and the following disclaimer. - * - * 2. Redistributions in binary form must reproduce the above copyright notice, this list - * of conditions and the following disclaimer in the documentation and/or other materials - * provided with the distribution. - * - * 3. Neither the name of the copyright holder nor the names of its contributors may be - * used to endorse or promote products derived from this software without specific prior - * written permission. - * - * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS - * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED - * TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A - * PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT - * HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, - * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT - * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, - * OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY - * OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING - * NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, - * EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. - */ - -package no.nordicsemi.android.kotlin.ble.ui.scanner.view - -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.ColumnScope -import androidx.compose.foundation.layout.PaddingValues -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.widthIn -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.HourglassTop -import androidx.compose.material3.Button -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.res.stringResource -import androidx.compose.ui.text.style.TextAlign -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.ui.view.CircularIcon -import no.nordicsemi.android.kotlin.ble.ui.scanner.R - -@Composable -fun DeviceConnectingView( - modifier: Modifier = Modifier, - content: @Composable ColumnScope.(PaddingValues) -> Unit = {} -) { - Column( - modifier = modifier, - horizontalAlignment = Alignment.CenterHorizontally - ) { - OutlinedCard( - modifier = Modifier - .widthIn(max = 460.dp), - ) { - Column( - modifier = Modifier - .fillMaxWidth() - .padding(16.dp), - verticalArrangement = Arrangement.spacedBy(16.dp), - horizontalAlignment = Alignment.CenterHorizontally, - ) { - CircularIcon(imageVector = Icons.Default.HourglassTop) - - Text( - text = stringResource(id = R.string.device_connecting), - style = MaterialTheme.typography.titleMedium - ) - - Text( - text = stringResource(id = R.string.device_explanation), - textAlign = TextAlign.Center, - style = MaterialTheme.typography.bodyMedium - ) - - Text( - text = stringResource(id = R.string.device_please_wait), - textAlign = TextAlign.Center, - style = MaterialTheme.typography.titleLarge - ) - } - } - - content(PaddingValues(top = 16.dp)) - } -} - -@Preview -@Composable -private fun DeviceConnectingView_Preview() { - NordicTheme { - DeviceConnectingView { padding -> - Button( - onClick = {}, - modifier = Modifier.padding(padding) - ) { - Text(text = "Cancel") - } - } - } -} diff --git a/scanner/src/main/java/no/nordicsemi/android/kotlin/ble/ui/scanner/view/DeviceDisconnectedView.kt b/scanner/src/main/java/no/nordicsemi/android/kotlin/ble/ui/scanner/view/DeviceDisconnectedView.kt deleted file mode 100644 index e1bbc3966..000000000 --- a/scanner/src/main/java/no/nordicsemi/android/kotlin/ble/ui/scanner/view/DeviceDisconnectedView.kt +++ /dev/null @@ -1,155 +0,0 @@ -/* - * Copyright (c) 2024, Nordic Semiconductor - * All rights reserved. - * - * Redistribution and use in source and binary forms, with or without modification, are - * permitted provided that the following conditions are met: - * - * 1. Redistributions of source code must retain the above copyright notice, this list of - * conditions and the following disclaimer. - * - * 2. Redistributions in binary form must reproduce the above copyright notice, this list - * of conditions and the following disclaimer in the documentation and/or other materials - * provided with the distribution. - * - * 3. Neither the name of the copyright holder nor the names of its contributors may be - * used to endorse or promote products derived from this software without specific prior - * written permission. - * - * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS - * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED - * TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A - * PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT - * HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, - * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT - * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, - * OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY - * OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING - * NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, - * EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. - */ - -package no.nordicsemi.android.kotlin.ble.ui.scanner.view - -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.ColumnScope -import androidx.compose.foundation.layout.PaddingValues -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.widthIn -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.HighlightOff -import androidx.compose.material3.Button -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.res.stringResource -import androidx.compose.ui.text.style.TextAlign -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.ui.view.CircularIcon -import no.nordicsemi.android.kotlin.ble.core.data.BleGattConnectionStatus -import no.nordicsemi.android.kotlin.ble.ui.scanner.R - -enum class Reason { - USER, UNKNOWN, LINK_LOSS, MISSING_SERVICE -} - -@Composable -fun DeviceDisconnectedView( - status: BleGattConnectionStatus, - modifier: Modifier = Modifier, - content: @Composable ColumnScope.(PaddingValues) -> Unit = {}, -) { - val disconnectedReason = when (status) { - BleGattConnectionStatus.UNKNOWN -> stringResource(id = R.string.device_reason_unknown) - BleGattConnectionStatus.SUCCESS -> stringResource(id = R.string.device_reason_user) - BleGattConnectionStatus.TERMINATE_LOCAL_HOST -> stringResource(id = R.string.device_reason_terminate_local_host) - BleGattConnectionStatus.TERMINATE_PEER_USER -> stringResource(id = R.string.device_reason_terminate_peer_user) - BleGattConnectionStatus.LINK_LOSS -> stringResource(id = R.string.device_reason_link_loss) - BleGattConnectionStatus.NOT_SUPPORTED -> stringResource(id = R.string.device_reason_missing_service) - BleGattConnectionStatus.CANCELLED -> stringResource(id = R.string.device_reason_cancelled) - BleGattConnectionStatus.TIMEOUT -> stringResource(id = R.string.device_reason_timeout) - } - - DeviceDisconnectedView(disconnectedReason = disconnectedReason, modifier, content) -} - -@Composable -fun DeviceDisconnectedView( - reason: Reason, - modifier: Modifier = Modifier, - content: @Composable ColumnScope.(PaddingValues) -> Unit = {}, -) { - val disconnectedReason = when (reason) { - Reason.USER -> stringResource(id = R.string.device_reason_user) - Reason.LINK_LOSS -> stringResource(id = R.string.device_reason_link_loss) - Reason.MISSING_SERVICE -> stringResource(id = R.string.device_reason_missing_service) - Reason.UNKNOWN -> stringResource(id = R.string.device_reason_unknown) - } - - DeviceDisconnectedView(disconnectedReason = disconnectedReason, modifier, content) -} - -@Composable -fun DeviceDisconnectedView( - disconnectedReason: String, - modifier: Modifier = Modifier, - content: @Composable ColumnScope.(PaddingValues) -> Unit = {}, -) { - Column( - modifier = modifier, - horizontalAlignment = Alignment.CenterHorizontally - ) { - OutlinedCard( - modifier = Modifier - .widthIn(max = 460.dp), - ) { - Column( - modifier = Modifier - .fillMaxWidth() - .padding(16.dp), - verticalArrangement = Arrangement.spacedBy(16.dp), - horizontalAlignment = Alignment.CenterHorizontally, - ) { - CircularIcon(imageVector = Icons.Default.HighlightOff) - - Text( - text = stringResource(id = R.string.device_disconnected), - style = MaterialTheme.typography.titleMedium - ) - - Text( - text = disconnectedReason, - textAlign = TextAlign.Center, - style = MaterialTheme.typography.bodyMedium - ) - } - } - - content(PaddingValues(top = 16.dp)) - } -} - -@Preview -@Composable -private fun DeviceDisconnectedView_Preview() { - NordicTheme { - DeviceDisconnectedView( - reason = Reason.MISSING_SERVICE, - content = { padding -> - Button( - onClick = {}, - modifier = Modifier.padding(padding) - ) { - Text(text = "Retry") - } - } - ) - } -} diff --git a/scanner/src/main/java/no/nordicsemi/android/kotlin/ble/ui/scanner/view/internal/FilterView.kt b/scanner/src/main/java/no/nordicsemi/android/kotlin/ble/ui/scanner/view/internal/FilterView.kt deleted file mode 100644 index e05cf5817..000000000 --- a/scanner/src/main/java/no/nordicsemi/android/kotlin/ble/ui/scanner/view/internal/FilterView.kt +++ /dev/null @@ -1,142 +0,0 @@ -/* - * Copyright (c) 2024, Nordic Semiconductor - * All rights reserved. - * - * Redistribution and use in source and binary forms, with or without modification, are - * permitted provided that the following conditions are met: - * - * 1. Redistributions of source code must retain the above copyright notice, this list of - * conditions and the following disclaimer. - * - * 2. Redistributions in binary form must reproduce the above copyright notice, this list - * of conditions and the following disclaimer in the documentation and/or other materials - * provided with the distribution. - * - * 3. Neither the name of the copyright holder nor the names of its contributors may be - * used to endorse or promote products derived from this software without specific prior - * written permission. - * - * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS - * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED - * TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A - * PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT - * HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, - * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT - * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, - * OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY - * OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING - * NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, - * EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. - */ - -package no.nordicsemi.android.kotlin.ble.ui.scanner.view.internal - -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.foundation.layout.padding -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.automirrored.filled.Label -import androidx.compose.material.icons.filled.Done -import androidx.compose.material.icons.filled.Widgets -import androidx.compose.material.icons.filled.Wifi -import androidx.compose.material3.ElevatedFilterChip -import androidx.compose.material3.Icon -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -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.kotlin.ble.ui.scanner.R -import no.nordicsemi.android.kotlin.ble.ui.scanner.repository.DevicesScanFilter - -@Composable -internal fun FilterView( - config: DevicesScanFilter, - onChanged: (DevicesScanFilter) -> Unit, - modifier: Modifier = Modifier, -) { - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.Start, - modifier = modifier, - ) { - config.filterUuidRequired?.let { - ElevatedFilterChip( - selected = !it, - onClick = { onChanged(config.copy(filterUuidRequired = !it)) }, - label = { Text(text = stringResource(id = R.string.filter_uuid),) }, - modifier = Modifier.padding(end = 8.dp), - leadingIcon = { - if (!it) { - Icon(Icons.Default.Done, contentDescription = "") - } else { - Icon(Icons.Default.Widgets, contentDescription = "") - } - }, - ) - } - config.filterNearbyOnly.let { - ElevatedFilterChip( - selected = it, - onClick = { onChanged(config.copy(filterNearbyOnly = !it)) }, - label = { Text(text = stringResource(id = R.string.filter_nearby),) }, - modifier = Modifier.padding(end = 8.dp), - leadingIcon = { - if (it) { - Icon(Icons.Default.Done, contentDescription = "") - } else { - Icon(Icons.Default.Wifi, contentDescription = "") - } - }, - ) - } - config.filterWithNames.let { - ElevatedFilterChip( - selected = it, - onClick = { onChanged(config.copy(filterWithNames = !it)) }, - label = { Text(text = stringResource(id = R.string.filter_name),) }, - modifier = Modifier.padding(end = 8.dp), - leadingIcon = { - if (it) { - Icon(Icons.Default.Done, contentDescription = "") - } else { - Icon(Icons.AutoMirrored.Filled.Label, contentDescription = "") - } - }, - ) - } - } -} - -@Preview -@Composable -private fun FilterViewPreview() { - NordicTheme { - Column { - FilterView( - config = DevicesScanFilter( - filterUuidRequired = true, - filterNearbyOnly = true, - filterWithNames = true, - ), - onChanged = {}, - modifier = Modifier.fillMaxWidth(), - ) - - FilterView( - config = DevicesScanFilter( - filterUuidRequired = false, - filterNearbyOnly = false, - filterWithNames = false, - ), - onChanged = {}, - modifier = Modifier.fillMaxWidth(), - ) - } - } -} \ No newline at end of file diff --git a/scanner/src/main/res/values/filterStrings.xml b/scanner/src/main/res/values/filterStrings.xml new file mode 100644 index 000000000..c0ddd38ef --- /dev/null +++ b/scanner/src/main/res/values/filterStrings.xml @@ -0,0 +1,10 @@ + + + RSSI + Alphabetical + Names + Group by name + Bonded + Nearby + With service UUID + \ No newline at end of file diff --git a/scanner/src/main/res/values/strings.xml b/scanner/src/main/res/values/strings.xml index fc13b0cac..a9f6e3c02 100644 --- a/scanner/src/main/res/values/strings.xml +++ b/scanner/src/main/res/values/strings.xml @@ -1,5 +1,4 @@ - - - Bluetooth - Location - - Filters - All - Nearby - Name - - No name - Bonded devices - Discovered devices - - Scanning failed with error %d. Scanner - Scanning failed - Location settings - Back - - CAN\'T SEE YOUR DEVICE? + "CAN\'T SEE YOUR DEVICE?" 1. Make sure the device is turned on and is connected to a power source.\n\n2. Make sure the appropriate firmware and SoftDevice are flashed. 3. Location is turned off. Most Android phones @@ -57,23 +39,15 @@ device is advertising and it doesn\'t show up here, click the button below to enable Location. - App bar navigation back icon. + SCANNING FAILED + Scanning failed with %s + Enable location - Bonding in progress. Please follow instruction on the screen. - Bonding success. Please wait for the redirect to chosen profile screen. - We cannot get data from the peripheral without bonding. Please bond the device. + Filter results + Clear all - Disconnected - Device disconnected successfully. - Device disconnected with unknown reason. - Device signal has been lost. - Device was disconnected, because required services are missing. - The local device initiated disconnection. - The remote device initiated graceful disconnection. - Connection attempt was cancelled. - The connection timed out. + Group by name + Sort by - Connecting… - The mobile is trying to connect to peripheral device. - Please wait… + Select item diff --git a/settings.gradle.kts b/settings.gradle.kts index 64e96de72..05371c21c 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -61,3 +61,4 @@ include(":permissions-internet") include(":permissions-nfc") include(":permissions-wifi") include(":permissions-notification") +include(":scanner")