diff --git a/app/src/test/snapshots/RssiThresholdDialogKt.RssiThresholdDialogPreview.png b/app/src/test/snapshots/RssiThresholdDialogKt.RssiThresholdDialogPreview.png new file mode 100644 index 0000000..09f38fc Binary files /dev/null and b/app/src/test/snapshots/RssiThresholdDialogKt.RssiThresholdDialogPreview.png differ diff --git a/app/src/test/snapshots/SettingsContentKt.SettingsContentPreviewBluetoothUnavailable.png b/app/src/test/snapshots/SettingsContentKt.SettingsContentPreviewBluetoothUnavailable.png index d1c9898..213f3c2 100644 Binary files a/app/src/test/snapshots/SettingsContentKt.SettingsContentPreviewBluetoothUnavailable.png and b/app/src/test/snapshots/SettingsContentKt.SettingsContentPreviewBluetoothUnavailable.png differ diff --git a/app/src/test/snapshots/SettingsContentKt.SettingsContentPreviewNoWarning.png b/app/src/test/snapshots/SettingsContentKt.SettingsContentPreviewNoWarning.png index 2b0ad86..0cf0acb 100644 Binary files a/app/src/test/snapshots/SettingsContentKt.SettingsContentPreviewNoWarning.png and b/app/src/test/snapshots/SettingsContentKt.SettingsContentPreviewNoWarning.png differ diff --git a/app/src/test/snapshots/SettingsContentKt.SettingsContentPreviewServiceRestarting.png b/app/src/test/snapshots/SettingsContentKt.SettingsContentPreviewServiceRestarting.png index ff54740..69bb779 100644 Binary files a/app/src/test/snapshots/SettingsContentKt.SettingsContentPreviewServiceRestarting.png and b/app/src/test/snapshots/SettingsContentKt.SettingsContentPreviewServiceRestarting.png differ diff --git a/app/src/test/snapshots/SettingsContentKt.SettingsContentPreviewThreeColumns.png b/app/src/test/snapshots/SettingsContentKt.SettingsContentPreviewThreeColumns.png index cf05dc2..c736f43 100644 Binary files a/app/src/test/snapshots/SettingsContentKt.SettingsContentPreviewThreeColumns.png and b/app/src/test/snapshots/SettingsContentKt.SettingsContentPreviewThreeColumns.png differ diff --git a/core/data/src/main/java/kurou/androidpods/core/data/DataModule.kt b/core/data/src/main/java/kurou/androidpods/core/data/DataModule.kt index 1ab8aa2..f074b12 100644 --- a/core/data/src/main/java/kurou/androidpods/core/data/DataModule.kt +++ b/core/data/src/main/java/kurou/androidpods/core/data/DataModule.kt @@ -18,6 +18,7 @@ import kurou.androidpods.core.domain.CompatibleDeviceRepository import kurou.androidpods.core.domain.FirstLaunchRepository import kurou.androidpods.core.domain.OverlayPositionRepository import kurou.androidpods.core.domain.OverlaySettingsRepository +import kurou.androidpods.core.domain.RssiThresholdRepository import kurou.androidpods.core.domain.ThemeSettingsRepository import kurou.androidpods.core.domain.UnknownDeviceRepository import kurou.androidpods.core.domain.UpdateRepository @@ -53,6 +54,13 @@ abstract class DataModule { fun provideWidgetBatteryDataStore( @ApplicationContext context: Context, ): DataStore = context.widgetBatteryDataStore + + @Provides + @Singleton + @Named("rssi_threshold") + fun provideRssiThresholdDataStore( + @ApplicationContext context: Context, + ): DataStore = context.rssiThresholdDataStore } @Binds @@ -88,4 +96,7 @@ abstract class DataModule { @Binds internal abstract fun bindWidgetBatteryRepository(impl: WidgetBatteryRepositoryImpl): WidgetBatteryRepository + + @Binds + internal abstract fun bindRssiThresholdRepository(impl: RssiThresholdRepositoryImpl): RssiThresholdRepository } diff --git a/core/data/src/main/java/kurou/androidpods/core/data/RssiThresholdRepositoryImpl.kt b/core/data/src/main/java/kurou/androidpods/core/data/RssiThresholdRepositoryImpl.kt new file mode 100644 index 0000000..45cf356 --- /dev/null +++ b/core/data/src/main/java/kurou/androidpods/core/data/RssiThresholdRepositoryImpl.kt @@ -0,0 +1,40 @@ +package kurou.androidpods.core.data + +import android.content.Context +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.core.edit +import androidx.datastore.preferences.core.emptyPreferences +import androidx.datastore.preferences.core.stringPreferencesKey +import androidx.datastore.preferences.preferencesDataStore +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.catch +import kotlinx.coroutines.flow.map +import kurou.androidpods.core.domain.RssiThreshold +import kurou.androidpods.core.domain.RssiThresholdRepository +import java.io.IOException +import javax.inject.Inject +import javax.inject.Named +import javax.inject.Singleton + +internal val Context.rssiThresholdDataStore by preferencesDataStore(name = "rssi_threshold") + +@Singleton +internal class RssiThresholdRepositoryImpl @Inject constructor( + @param:Named("rssi_threshold") private val dataStore: DataStore, +) : RssiThresholdRepository { + private val thresholdKey = stringPreferencesKey("rssi_threshold") + + override fun observe(): Flow = + dataStore.data + .catch { if (it is IOException) emit(emptyPreferences()) else throw it } + .map { preferences -> + preferences[thresholdKey] + ?.let { try { RssiThreshold.valueOf(it) } catch (_: IllegalArgumentException) { null } } + ?: RssiThreshold.VERY_NEAR + } + + override suspend fun update(threshold: RssiThreshold) { + dataStore.edit { it[thresholdKey] = threshold.name } + } +} diff --git a/core/data/src/test/java/kurou/androidpods/core/data/RssiThresholdRepositoryImplTest.kt b/core/data/src/test/java/kurou/androidpods/core/data/RssiThresholdRepositoryImplTest.kt new file mode 100644 index 0000000..9ca20fe --- /dev/null +++ b/core/data/src/test/java/kurou/androidpods/core/data/RssiThresholdRepositoryImplTest.kt @@ -0,0 +1,91 @@ +package kurou.androidpods.core.data + +import android.app.Application +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.PreferenceDataStoreFactory +import androidx.datastore.preferences.core.Preferences +import androidx.test.core.app.ApplicationProvider +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.test.runTest +import kurou.androidpods.core.domain.RssiThreshold +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import java.io.File +import java.io.IOException +import java.util.UUID + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [35]) +class RssiThresholdRepositoryImplTest { + private lateinit var repository: RssiThresholdRepositoryImpl + + @Before + fun setUp() { + val context = ApplicationProvider.getApplicationContext() + val dataStore = PreferenceDataStoreFactory.create { + File(context.filesDir, "datastore/test_rssi_${UUID.randomUUID()}.preferences_pb") + } + repository = RssiThresholdRepositoryImpl(dataStore) + } + + @Test + fun `デフォルト値はVERY_NEAR`() = + runTest { + val result = repository.observe().first() + + assertEquals(RssiThreshold.VERY_NEAR, result) + } + + @Test + fun `updateで保存した値が取得できる`() = + runTest { + repository.update(RssiThreshold.NEAR) + + val result = repository.observe().first() + + assertEquals(RssiThreshold.NEAR, result) + } + + @Test + fun `IOExceptionが発生した場合はデフォルト値VERY_NEARを返す`() = + runTest { + val ioExceptionDataStore = object : DataStore { + override val data: Flow = flow { throw IOException("Test") } + override suspend fun updateData(transform: suspend (Preferences) -> Preferences): Preferences = + throw IOException("Test") + } + val repo = RssiThresholdRepositoryImpl(ioExceptionDataStore) + + val result = repo.observe().first() + + assertEquals(RssiThreshold.VERY_NEAR, result) + } + + @Test + fun `IOException以外の例外は伝播する`() = + runTest { + val runtimeExceptionDataStore = object : DataStore { + override val data: Flow = flow { throw RuntimeException("Test") } + override suspend fun updateData(transform: suspend (Preferences) -> Preferences): Preferences = + throw RuntimeException("Test") + } + val repo = RssiThresholdRepositoryImpl(runtimeExceptionDataStore) + + var thrownException: Throwable? = null + try { + repo.observe().first() + } catch (e: RuntimeException) { + thrownException = e + } + assertNotNull(thrownException) + assertTrue(thrownException is RuntimeException) + } +} diff --git a/core/domain/src/main/java/kurou/androidpods/core/domain/GetAppleDevicesUseCase.kt b/core/domain/src/main/java/kurou/androidpods/core/domain/GetAppleDevicesUseCase.kt index fb8da7d..9436e0c 100644 --- a/core/domain/src/main/java/kurou/androidpods/core/domain/GetAppleDevicesUseCase.kt +++ b/core/domain/src/main/java/kurou/androidpods/core/domain/GetAppleDevicesUseCase.kt @@ -1,12 +1,17 @@ package kurou.androidpods.core.domain import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.combine import javax.inject.Inject class GetAppleDevicesUseCase @Inject constructor( private val repository: AppleDeviceRepository, + private val rssiThresholdRepository: RssiThresholdRepository, ) { - fun observe(): Flow> = repository.observeDevices() + fun observe(): Flow> = + combine(repository.observeDevices(), rssiThresholdRepository.observe()) { devices, threshold -> + devices.filter { (_, device) -> device.rssi >= threshold.minRssi } + } fun startScan() = repository.startScan() diff --git a/core/domain/src/main/java/kurou/androidpods/core/domain/RssiThreshold.kt b/core/domain/src/main/java/kurou/androidpods/core/domain/RssiThreshold.kt new file mode 100644 index 0000000..30889b4 --- /dev/null +++ b/core/domain/src/main/java/kurou/androidpods/core/domain/RssiThreshold.kt @@ -0,0 +1,8 @@ +package kurou.androidpods.core.domain + +enum class RssiThreshold(val minRssi: Int) { + ALL(Int.MIN_VALUE), + MEDIUM(-75), + NEAR(-65), + VERY_NEAR(-55), +} diff --git a/core/domain/src/main/java/kurou/androidpods/core/domain/RssiThresholdRepository.kt b/core/domain/src/main/java/kurou/androidpods/core/domain/RssiThresholdRepository.kt new file mode 100644 index 0000000..d87526a --- /dev/null +++ b/core/domain/src/main/java/kurou/androidpods/core/domain/RssiThresholdRepository.kt @@ -0,0 +1,9 @@ +package kurou.androidpods.core.domain + +import kotlinx.coroutines.flow.Flow + +interface RssiThresholdRepository { + fun observe(): Flow + + suspend fun update(threshold: RssiThreshold) +} diff --git a/core/domain/src/main/java/kurou/androidpods/core/domain/RssiThresholdUseCase.kt b/core/domain/src/main/java/kurou/androidpods/core/domain/RssiThresholdUseCase.kt new file mode 100644 index 0000000..ebf80f7 --- /dev/null +++ b/core/domain/src/main/java/kurou/androidpods/core/domain/RssiThresholdUseCase.kt @@ -0,0 +1,12 @@ +package kurou.androidpods.core.domain + +import kotlinx.coroutines.flow.Flow +import javax.inject.Inject + +class RssiThresholdUseCase @Inject constructor( + private val repository: RssiThresholdRepository, +) { + fun observe(): Flow = repository.observe() + + suspend fun update(threshold: RssiThreshold) = repository.update(threshold) +} diff --git a/core/domain/src/test/java/kurou/androidpods/core/domain/GetAppleDevicesUseCaseTest.kt b/core/domain/src/test/java/kurou/androidpods/core/domain/GetAppleDevicesUseCaseTest.kt index 4313ae6..dfec53e 100644 --- a/core/domain/src/test/java/kurou/androidpods/core/domain/GetAppleDevicesUseCaseTest.kt +++ b/core/domain/src/test/java/kurou/androidpods/core/domain/GetAppleDevicesUseCaseTest.kt @@ -16,10 +16,21 @@ import org.junit.Test class GetAppleDevicesUseCaseTest { private lateinit var useCase: GetAppleDevicesUseCase private val repository = mockk(relaxUnitFun = true) + private val rssiThresholdRepository = mockk() + + private fun device(rssi: Int) = AppleDevice( + address = "AA:BB:CC:DD:EE:FF", + modelName = "AirPods Pro", + modelCode = 0x2002, + rssi = rssi, + leftBattery = 80, + rightBattery = 75, + caseBattery = 90, + ) @Before fun setUp() { - useCase = GetAppleDevicesUseCase(repository) + useCase = GetAppleDevicesUseCase(repository, rssiThresholdRepository) } @After @@ -28,26 +39,50 @@ class GetAppleDevicesUseCaseTest { } @Test - fun `observeがrepositoryのobserveDevicesのFlowを返す`() = + fun `ALLの場合はRSSIに関わらず全デバイスを返す`() = + runTest { + val devices = mapOf("key" to device(rssi = -90)) + every { repository.observeDevices() } returns MutableStateFlow(devices) + every { rssiThresholdRepository.observe() } returns MutableStateFlow(RssiThreshold.ALL) + + val result = useCase.observe().first() + + assertEquals(devices, result) + verify(exactly = 1) { repository.observeDevices() } + verify(exactly = 1) { rssiThresholdRepository.observe() } + confirmVerified(repository, rssiThresholdRepository) + } + + @Test + fun `閾値以上のRSSIを持つデバイスのみ返す`() = + runTest { + val nearDevice = device(rssi = -60) + val farDevice = device(rssi = -80).copy(modelCode = 0x2003) + val devices = mapOf("near" to nearDevice, "far" to farDevice) + every { repository.observeDevices() } returns MutableStateFlow(devices) + every { rssiThresholdRepository.observe() } returns MutableStateFlow(RssiThreshold.MEDIUM) + + val result = useCase.observe().first() + + assertEquals(mapOf("near" to nearDevice), result) + verify(exactly = 1) { repository.observeDevices() } + verify(exactly = 1) { rssiThresholdRepository.observe() } + confirmVerified(repository, rssiThresholdRepository) + } + + @Test + fun `閾値と等しいRSSIを持つデバイスは含まれる`() = runTest { - val device = - AppleDevice( - address = "AA:BB:CC:DD:EE:FF", - modelName = "AirPods Pro", - modelCode = 0x2002, - rssi = -60, - leftBattery = 80, - rightBattery = 75, - caseBattery = 90, - ) - val fakeFlow = MutableStateFlow(mapOf("AA:BB:CC:DD:EE:FF" to device)) - every { repository.observeDevices() } returns fakeFlow + val boundaryDevice = device(rssi = -75) + every { repository.observeDevices() } returns MutableStateFlow(mapOf("boundary" to boundaryDevice)) + every { rssiThresholdRepository.observe() } returns MutableStateFlow(RssiThreshold.MEDIUM) val result = useCase.observe().first() - assertEquals(mapOf("AA:BB:CC:DD:EE:FF" to device), result) + assertEquals(mapOf("boundary" to boundaryDevice), result) verify(exactly = 1) { repository.observeDevices() } - confirmVerified(repository) + verify(exactly = 1) { rssiThresholdRepository.observe() } + confirmVerified(repository, rssiThresholdRepository) } @Test diff --git a/feature/settings/src/main/java/kurou/androidpods/feature/settings/OverlayPositionDialog.kt b/feature/settings/src/main/java/kurou/androidpods/feature/settings/OverlayPositionDialog.kt index 23223c9..14f62af 100644 --- a/feature/settings/src/main/java/kurou/androidpods/feature/settings/OverlayPositionDialog.kt +++ b/feature/settings/src/main/java/kurou/androidpods/feature/settings/OverlayPositionDialog.kt @@ -1,18 +1,20 @@ package kurou.androidpods.feature.settings -import androidx.compose.foundation.clickable 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.foundation.selection.selectable import androidx.compose.material3.AlertDialog import androidx.compose.material3.RadioButton import androidx.compose.material3.Text import androidx.compose.material3.TextButton +import androidx.compose.material3.minimumInteractiveComponentSize 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.semantics.Role import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import kurou.androidpods.core.domain.OverlayPosition @@ -34,12 +36,17 @@ internal fun OverlayPositionDialog( modifier = Modifier .fillMaxWidth() - .clickable { onPositionSelected(position) } + .selectable( + selected = position == currentPosition, + onClick = { onPositionSelected(position) }, + role = Role.RadioButton, + ) .padding(vertical = 4.dp), ) { RadioButton( selected = position == currentPosition, - onClick = { onPositionSelected(position) }, + onClick = null, + modifier = Modifier.minimumInteractiveComponentSize(), ) Text(stringResource(position.toStringRes())) } diff --git a/feature/settings/src/main/java/kurou/androidpods/feature/settings/RssiThresholdDialog.kt b/feature/settings/src/main/java/kurou/androidpods/feature/settings/RssiThresholdDialog.kt new file mode 100644 index 0000000..2ca12be --- /dev/null +++ b/feature/settings/src/main/java/kurou/androidpods/feature/settings/RssiThresholdDialog.kt @@ -0,0 +1,73 @@ +package kurou.androidpods.feature.settings + +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.foundation.selection.selectable +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.RadioButton +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.minimumInteractiveComponentSize +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.semantics.Role +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import kurou.androidpods.core.domain.RssiThreshold + +@Composable +internal fun RssiThresholdDialog( + currentThreshold: RssiThreshold, + onDismiss: () -> Unit, + onThresholdSelected: (RssiThreshold) -> Unit, +) { + AlertDialog( + onDismissRequest = onDismiss, + title = { Text(stringResource(R.string.rssi_threshold_label)) }, + text = { + Column { + RssiThreshold.entries.forEach { threshold -> + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = + Modifier + .fillMaxWidth() + .selectable( + selected = threshold == currentThreshold, + onClick = { onThresholdSelected(threshold) }, + role = Role.RadioButton, + ) + .padding(vertical = 4.dp), + ) { + RadioButton( + selected = threshold == currentThreshold, + onClick = null, + modifier = Modifier.minimumInteractiveComponentSize(), + ) + Text(stringResource(threshold.toStringRes())) + } + } + } + }, + confirmButton = {}, + dismissButton = { + TextButton(onClick = onDismiss) { + Text(stringResource(android.R.string.cancel)) + } + }, + ) +} + +@Preview(showBackground = true, widthDp = 400, heightDp = 700) +@Composable +private fun RssiThresholdDialogPreview() { + RssiThresholdDialog( + currentThreshold = RssiThreshold.VERY_NEAR, + onDismiss = {}, + onThresholdSelected = {}, + ) +} diff --git a/feature/settings/src/main/java/kurou/androidpods/feature/settings/SettingsContent.kt b/feature/settings/src/main/java/kurou/androidpods/feature/settings/SettingsContent.kt index 44dcab9..b5d35a0 100644 --- a/feature/settings/src/main/java/kurou/androidpods/feature/settings/SettingsContent.kt +++ b/feature/settings/src/main/java/kurou/androidpods/feature/settings/SettingsContent.kt @@ -47,6 +47,7 @@ import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import kurou.androidpods.core.domain.OverlayPosition +import kurou.androidpods.core.domain.RssiThreshold import kurou.androidpods.core.domain.ThemeMode import kurou.androidpods.core.domain.ThemeSettings @@ -64,6 +65,7 @@ internal fun SettingsContent( hasUnknownDevices: Boolean, columns: Int, themeSettings: ThemeSettings, + rssiThreshold: RssiThreshold, onPermissionWarningClick: () -> Unit, onBluetoothWarningClick: () -> Unit, onNotificationWarningClick: () -> Unit, @@ -72,6 +74,7 @@ internal fun SettingsContent( onOverlayPositionClick: () -> Unit, onRestartServiceClick: () -> Unit, onBatteryOptimizationClick: () -> Unit, + onRssiThresholdClick: () -> Unit, onThemeModeClick: () -> Unit, onDynamicColorToggle: (Boolean) -> Unit, onUpdateClick: () -> Unit, @@ -128,8 +131,10 @@ internal fun SettingsContent( scanServiceSectionItems( isServiceRestarting = isServiceRestarting, isBatteryOptimizationExempt = isBatteryOptimizationExempt, + rssiThreshold = rssiThreshold, onRestartServiceClick = onRestartServiceClick, onBatteryOptimizationClick = onBatteryOptimizationClick, + onRssiThresholdClick = onRssiThresholdClick, ) appearanceSectionItems( themeSettings = themeSettings, @@ -267,8 +272,10 @@ private fun LazyGridScope.overlaySectionItems( private fun LazyGridScope.scanServiceSectionItems( isServiceRestarting: Boolean, isBatteryOptimizationExempt: Boolean, + rssiThreshold: RssiThreshold, onRestartServiceClick: () -> Unit, onBatteryOptimizationClick: () -> Unit, + onRssiThresholdClick: () -> Unit, ) { sectionLabel(R.string.scan_service_section_label) item(key = R.string.restart_service, span = { GridItemSpan(1) }) { @@ -302,6 +309,20 @@ private fun LazyGridScope.scanServiceSectionItems( ) } } + item(key = R.string.rssi_threshold_label, span = { GridItemSpan(1) }) { + SettingsItem( + label = stringResource(R.string.rssi_threshold_label), + icon = painterResource(R.drawable.ic_rssi_threshold), + onClick = onRssiThresholdClick, + modifier = Modifier.animateItem(), + ) { + Text( + text = stringResource(rssiThreshold.toStringRes()), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } } private fun LazyGridScope.appearanceSectionItems( @@ -569,6 +590,14 @@ internal fun OverlayPosition.toStringRes(): Int = OverlayPosition.BOTTOM -> R.string.overlay_position_bottom } +internal fun RssiThreshold.toStringRes(): Int = + when (this) { + RssiThreshold.ALL -> R.string.rssi_threshold_all + RssiThreshold.MEDIUM -> R.string.rssi_threshold_medium + RssiThreshold.NEAR -> R.string.rssi_threshold_near + RssiThreshold.VERY_NEAR -> R.string.rssi_threshold_very_near + } + @Preview(showBackground = true, widthDp = 400, heightDp = 700) @Composable private fun SettingsContentPreviewNoWarning() { @@ -595,8 +624,10 @@ private fun SettingsContentPreviewNoWarning() { onOverlayPositionClick = {}, onRestartServiceClick = {}, onBatteryOptimizationClick = {}, + onRssiThresholdClick = {}, isBatteryOptimizationExempt = false, themeSettings = ThemeSettings(), + rssiThreshold = RssiThreshold.VERY_NEAR, onThemeModeClick = {}, onDynamicColorToggle = {}, onUpdateClick = {}, @@ -629,8 +660,10 @@ private fun SettingsContentPreviewBluetoothUnavailable() { onOverlayPositionClick = {}, onRestartServiceClick = {}, onBatteryOptimizationClick = {}, + onRssiThresholdClick = {}, isBatteryOptimizationExempt = false, themeSettings = ThemeSettings(), + rssiThreshold = RssiThreshold.VERY_NEAR, onThemeModeClick = {}, onDynamicColorToggle = {}, onUpdateClick = {}, @@ -667,8 +700,10 @@ private fun SettingsContentPreviewAllWarnings() { onOverlayPositionClick = {}, onRestartServiceClick = {}, onBatteryOptimizationClick = {}, + onRssiThresholdClick = {}, isBatteryOptimizationExempt = false, themeSettings = ThemeSettings(), + rssiThreshold = RssiThreshold.VERY_NEAR, onThemeModeClick = {}, onDynamicColorToggle = {}, onUpdateClick = {}, @@ -705,8 +740,10 @@ private fun SettingsContentPreviewServiceRestarting() { onOverlayPositionClick = {}, onRestartServiceClick = {}, onBatteryOptimizationClick = {}, + onRssiThresholdClick = {}, isBatteryOptimizationExempt = false, themeSettings = ThemeSettings(), + rssiThreshold = RssiThreshold.VERY_NEAR, onThemeModeClick = {}, onDynamicColorToggle = {}, onUpdateClick = {}, @@ -743,8 +780,10 @@ private fun SettingsContentPreviewTwoColumns() { onOverlayPositionClick = {}, onRestartServiceClick = {}, onBatteryOptimizationClick = {}, + onRssiThresholdClick = {}, isBatteryOptimizationExempt = false, themeSettings = ThemeSettings(), + rssiThreshold = RssiThreshold.VERY_NEAR, onThemeModeClick = {}, onDynamicColorToggle = {}, onUpdateClick = {}, @@ -781,8 +820,10 @@ private fun SettingsContentPreviewThreeColumns() { onOverlayPositionClick = {}, onRestartServiceClick = {}, onBatteryOptimizationClick = {}, + onRssiThresholdClick = {}, isBatteryOptimizationExempt = false, themeSettings = ThemeSettings(), + rssiThreshold = RssiThreshold.VERY_NEAR, onThemeModeClick = {}, onDynamicColorToggle = {}, onUpdateClick = {}, diff --git a/feature/settings/src/main/java/kurou/androidpods/feature/settings/SettingsScreen.kt b/feature/settings/src/main/java/kurou/androidpods/feature/settings/SettingsScreen.kt index 16e7209..0e5eb83 100644 --- a/feature/settings/src/main/java/kurou/androidpods/feature/settings/SettingsScreen.kt +++ b/feature/settings/src/main/java/kurou/androidpods/feature/settings/SettingsScreen.kt @@ -147,6 +147,17 @@ fun SettingsScreen( ) } + if (uiState.showRssiThresholdDialog) { + RssiThresholdDialog( + currentThreshold = uiState.rssiThreshold, + onDismiss = viewModel::dismissRssiThresholdDialog, + onThresholdSelected = { threshold -> + viewModel.updateRssiThreshold(threshold) + viewModel.dismissRssiThresholdDialog() + }, + ) + } + if (uiState.showUnknownDeviceSheet) { val modelCode = uiState.unknownModelCodes.firstOrNull() if (modelCode != null) { @@ -221,6 +232,7 @@ fun SettingsScreen( viewModel.updateThemeSettings(uiState.themeSettings.copy(useDynamicColor = enabled)) }, onOverlayPositionClick = viewModel::showOverlayPositionDialog, + onRssiThresholdClick = viewModel::showRssiThresholdDialog, ) } @@ -306,6 +318,7 @@ private fun SettingsScaffold( onOverlayPositionClick: () -> Unit, onRestartServiceClick: () -> Unit, onBatteryOptimizationClick: () -> Unit, + onRssiThresholdClick: () -> Unit, onThemeModeClick: () -> Unit, onDynamicColorToggle: (Boolean) -> Unit, modifier: Modifier = Modifier, @@ -333,6 +346,7 @@ private fun SettingsScaffold( hasUnknownDevices = uiState.hasUnknownDevices, columns = columns, themeSettings = uiState.themeSettings, + rssiThreshold = uiState.rssiThreshold, onPermissionWarningClick = onPermissionWarningClick, onBluetoothWarningClick = onBluetoothWarningClick, onNotificationWarningClick = onNotificationWarningClick, @@ -346,6 +360,7 @@ private fun SettingsScaffold( onOverlayPositionClick = onOverlayPositionClick, onRestartServiceClick = onRestartServiceClick, onBatteryOptimizationClick = onBatteryOptimizationClick, + onRssiThresholdClick = onRssiThresholdClick, onThemeModeClick = onThemeModeClick, onDynamicColorToggle = onDynamicColorToggle, modifier = Modifier.padding(innerPadding), diff --git a/feature/settings/src/main/java/kurou/androidpods/feature/settings/SettingsViewModel.kt b/feature/settings/src/main/java/kurou/androidpods/feature/settings/SettingsViewModel.kt index ef81d1a..c6a83d2 100644 --- a/feature/settings/src/main/java/kurou/androidpods/feature/settings/SettingsViewModel.kt +++ b/feature/settings/src/main/java/kurou/androidpods/feature/settings/SettingsViewModel.kt @@ -22,6 +22,8 @@ import kurou.androidpods.core.domain.GetBluetoothAdapterStateUseCase import kurou.androidpods.core.domain.GetOverlaySettingsUseCase import kurou.androidpods.core.domain.OverlayPosition import kurou.androidpods.core.domain.OverlayPositionUseCase +import kurou.androidpods.core.domain.RssiThreshold +import kurou.androidpods.core.domain.RssiThresholdUseCase import kurou.androidpods.core.domain.ThemeSettings import kurou.androidpods.core.domain.ThemeSettingsUseCase import kurou.androidpods.core.domain.UnknownDeviceUseCase @@ -53,12 +55,18 @@ private data class WarningState( val permissionStates: Map, ) +private data class AppSettingsState( + val themeSettings: ThemeSettings, + val rssiThreshold: RssiThreshold, +) + private data class UiControlState( val isServiceRestarting: Boolean = false, val showPermissionRequiredDialog: Boolean = false, val showThemeModeDialog: Boolean = false, val showOverlayPositionDialog: Boolean = false, val showUnknownDeviceSheet: Boolean = false, + val showRssiThresholdDialog: Boolean = false, ) data class SettingsUiState( @@ -73,17 +81,19 @@ data class SettingsUiState( val isBatteryOptimizationExempt: Boolean = false, val unknownModelCodes: Set = emptySet(), val permissionStates: Map = emptyMap(), + val rssiThreshold: RssiThreshold = RssiThreshold.VERY_NEAR, val isServiceRestarting: Boolean = false, val showPermissionRequiredDialog: Boolean = false, val showThemeModeDialog: Boolean = false, val showOverlayPositionDialog: Boolean = false, val showUnknownDeviceSheet: Boolean = false, + val showRssiThresholdDialog: Boolean = false, ) { val hasUnknownDevices: Boolean get() = unknownModelCodes.isNotEmpty() } @HiltViewModel -@Suppress("TooManyFunctions") +@Suppress("TooManyFunctions", "LongParameterList") class SettingsViewModel @Inject constructor( private val getBluetoothAdapterStateUseCase: GetBluetoothAdapterStateUseCase, private val getAppleDevicesUseCase: GetAppleDevicesUseCase, @@ -92,6 +102,7 @@ class SettingsViewModel @Inject constructor( private val themeSettingsUseCase: ThemeSettingsUseCase, private val overlayPositionUseCase: OverlayPositionUseCase, private val unknownDeviceUseCase: UnknownDeviceUseCase, + private val rssiThresholdUseCase: RssiThresholdUseCase, ) : ViewModel() { private val _updateAvailable = MutableStateFlow(false) private val _isNotificationsDisabled = MutableStateFlow(false) @@ -131,16 +142,22 @@ class SettingsViewModel @Inject constructor( isBatteryOptimizationExempt, permissionStates, ) }, - themeSettingsUseCase.observe(), + combine( + themeSettingsUseCase.observe(), + rssiThresholdUseCase.observe(), + ) { themeSettings, rssiThreshold -> + AppSettingsState(themeSettings, rssiThreshold) + }, _uiControlState, - ) { scan, overlay, warning, themeSettings, uiControl -> + ) { scan, overlay, warning, appSettings, uiControl -> SettingsUiState( bluetoothAdapterState = scan.bluetoothAdapterState, appleDevices = scan.appleDevices, unknownModelCodes = scan.unknownModelCodes, overlayEnabled = overlay.overlayEnabled, overlayPosition = overlay.overlayPosition, - themeSettings = themeSettings, + themeSettings = appSettings.themeSettings, + rssiThreshold = appSettings.rssiThreshold, updateAvailable = warning.updateAvailable, isNotificationsDisabled = warning.isNotificationsDisabled, isDeviceScanChannelDisabled = warning.isDeviceScanChannelDisabled, @@ -151,6 +168,7 @@ class SettingsViewModel @Inject constructor( showThemeModeDialog = uiControl.showThemeModeDialog, showOverlayPositionDialog = uiControl.showOverlayPositionDialog, showUnknownDeviceSheet = uiControl.showUnknownDeviceSheet, + showRssiThresholdDialog = uiControl.showRssiThresholdDialog, ) }.stateIn( scope = viewModelScope, @@ -244,6 +262,18 @@ class SettingsViewModel @Inject constructor( } } + fun showRssiThresholdDialog() = + _uiControlState.update { it.copy(showRssiThresholdDialog = true) } + + fun dismissRssiThresholdDialog() = + _uiControlState.update { it.copy(showRssiThresholdDialog = false) } + + fun updateRssiThreshold(threshold: RssiThreshold) { + viewModelScope.launch { + rssiThresholdUseCase.update(threshold) + } + } + fun updateThemeSettings(settings: ThemeSettings) { viewModelScope.launch { themeSettingsUseCase.update(settings) diff --git a/feature/settings/src/main/java/kurou/androidpods/feature/settings/ThemeModeDialog.kt b/feature/settings/src/main/java/kurou/androidpods/feature/settings/ThemeModeDialog.kt index 3d2b562..1da914d 100644 --- a/feature/settings/src/main/java/kurou/androidpods/feature/settings/ThemeModeDialog.kt +++ b/feature/settings/src/main/java/kurou/androidpods/feature/settings/ThemeModeDialog.kt @@ -1,18 +1,20 @@ package kurou.androidpods.feature.settings -import androidx.compose.foundation.clickable 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.foundation.selection.selectable import androidx.compose.material3.AlertDialog import androidx.compose.material3.RadioButton import androidx.compose.material3.Text import androidx.compose.material3.TextButton +import androidx.compose.material3.minimumInteractiveComponentSize 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.semantics.Role import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import kurou.androidpods.core.domain.ThemeMode @@ -34,12 +36,17 @@ internal fun ThemeModeDialog( modifier = Modifier .fillMaxWidth() - .clickable { onModeSelected(mode) } + .selectable( + selected = mode == currentMode, + onClick = { onModeSelected(mode) }, + role = Role.RadioButton, + ) .padding(vertical = 4.dp), ) { RadioButton( selected = mode == currentMode, - onClick = { onModeSelected(mode) }, + onClick = null, + modifier = Modifier.minimumInteractiveComponentSize(), ) Text(stringResource(mode.toStringRes())) } diff --git a/feature/settings/src/main/res/drawable/ic_rssi_threshold.xml b/feature/settings/src/main/res/drawable/ic_rssi_threshold.xml new file mode 100644 index 0000000..b30a5f1 --- /dev/null +++ b/feature/settings/src/main/res/drawable/ic_rssi_threshold.xml @@ -0,0 +1,11 @@ + + + + + diff --git a/feature/settings/src/main/res/values-ja/strings.xml b/feature/settings/src/main/res/values-ja/strings.xml index c3ac123..32f5f2b 100644 --- a/feature/settings/src/main/res/values-ja/strings.xml +++ b/feature/settings/src/main/res/values-ja/strings.xml @@ -45,4 +45,9 @@ モデルコード %1$s のデバイスを検出しました。何のデバイスを接続しようとしていましたか? デバイス名 報告する + 電波強度フィルター + すべて検出 + 中距離まで(〜5m) + 近い距離のみ(〜2m) + 手元のみ(〜1m) diff --git a/feature/settings/src/main/res/values/strings.xml b/feature/settings/src/main/res/values/strings.xml index 9cd9e3e..74653b4 100644 --- a/feature/settings/src/main/res/values/strings.xml +++ b/feature/settings/src/main/res/values/strings.xml @@ -45,4 +45,9 @@ Model code %1$s was detected. What device were you trying to connect? Device name Report + Signal Strength Filter + All devices + Medium range (~5m) + Near range (~2m) + Very near (~1m) diff --git a/feature/settings/src/test/java/kurou/androidpods/feature/settings/RssiThresholdDialogTest.kt b/feature/settings/src/test/java/kurou/androidpods/feature/settings/RssiThresholdDialogTest.kt new file mode 100644 index 0000000..18d4f72 --- /dev/null +++ b/feature/settings/src/test/java/kurou/androidpods/feature/settings/RssiThresholdDialogTest.kt @@ -0,0 +1,52 @@ +package kurou.androidpods.feature.settings + +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.junit4.v2.createComposeRule +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import kurou.androidpods.core.domain.RssiThreshold +import org.junit.Assert.assertEquals +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [35]) +class RssiThresholdDialogTest { + @get:Rule + val composeTestRule = createComposeRule() + + @Test + fun `タイトルと選択肢が表示される`() { + composeTestRule.setContent { + RssiThresholdDialog( + currentThreshold = RssiThreshold.VERY_NEAR, + onDismiss = {}, + onThresholdSelected = {}, + ) + } + + composeTestRule.onNodeWithText("Signal Strength Filter").assertIsDisplayed() + composeTestRule.onNodeWithText("All devices").assertIsDisplayed() + composeTestRule.onNodeWithText("Medium range (~5m)").assertIsDisplayed() + composeTestRule.onNodeWithText("Near range (~2m)").assertIsDisplayed() + composeTestRule.onNodeWithText("Very near (~1m)").assertIsDisplayed() + } + + @Test + fun `選択肢をタップするとonThresholdSelectedが呼ばれる`() { + var selected: RssiThreshold? = null + composeTestRule.setContent { + RssiThresholdDialog( + currentThreshold = RssiThreshold.VERY_NEAR, + onDismiss = {}, + onThresholdSelected = { selected = it }, + ) + } + + composeTestRule.onNodeWithText("All devices").performClick() + assertEquals(RssiThreshold.ALL, selected) + } +} diff --git a/feature/settings/src/test/java/kurou/androidpods/feature/settings/SettingsContentTest.kt b/feature/settings/src/test/java/kurou/androidpods/feature/settings/SettingsContentTest.kt index 51273a1..1ed2ae5 100644 --- a/feature/settings/src/test/java/kurou/androidpods/feature/settings/SettingsContentTest.kt +++ b/feature/settings/src/test/java/kurou/androidpods/feature/settings/SettingsContentTest.kt @@ -10,6 +10,7 @@ import androidx.compose.ui.test.onNodeWithText import androidx.compose.ui.test.performClick import androidx.compose.ui.test.performScrollToNode import kurou.androidpods.core.domain.OverlayPosition +import kurou.androidpods.core.domain.RssiThreshold import kurou.androidpods.core.domain.ThemeSettings import org.junit.Assert.assertEquals import org.junit.Assert.assertTrue @@ -39,6 +40,7 @@ class SettingsContentTest { hasUnknownDevices: Boolean = false, columns: Int = 1, themeSettings: ThemeSettings = ThemeSettings(), + rssiThreshold: RssiThreshold = RssiThreshold.VERY_NEAR, onPermissionWarningClick: () -> Unit = {}, onBluetoothWarningClick: () -> Unit = {}, onNotificationWarningClick: () -> Unit = {}, @@ -47,6 +49,7 @@ class SettingsContentTest { onOverlayPositionClick: () -> Unit = {}, onRestartServiceClick: () -> Unit = {}, onBatteryOptimizationClick: () -> Unit = {}, + onRssiThresholdClick: () -> Unit = {}, onThemeModeClick: () -> Unit = {}, onDynamicColorToggle: (Boolean) -> Unit = {}, onUpdateClick: () -> Unit = {}, @@ -69,6 +72,7 @@ class SettingsContentTest { hasUnknownDevices = hasUnknownDevices, columns = columns, themeSettings = themeSettings, + rssiThreshold = rssiThreshold, onPermissionWarningClick = onPermissionWarningClick, onBluetoothWarningClick = onBluetoothWarningClick, onNotificationWarningClick = onNotificationWarningClick, @@ -77,6 +81,7 @@ class SettingsContentTest { onOverlayPositionClick = onOverlayPositionClick, onRestartServiceClick = onRestartServiceClick, onBatteryOptimizationClick = onBatteryOptimizationClick, + onRssiThresholdClick = onRssiThresholdClick, onThemeModeClick = onThemeModeClick, onDynamicColorToggle = onDynamicColorToggle, onUpdateClick = onUpdateClick, @@ -259,6 +264,18 @@ class SettingsContentTest { assertTrue(clicked) } + @Test + fun `RSSI閾値アイテムをタップするとonRssiThresholdClickが呼ばれる`() { + var clicked = false + setSettingsContent(onRssiThresholdClick = { clicked = true }) + + composeTestRule.onAllNodes(hasScrollAction()).onFirst() + .performScrollToNode(hasText("Signal Strength Filter")) + composeTestRule.onNodeWithText("Signal Strength Filter").performClick() + + assertTrue(clicked) + } + @Test fun `テーマアイテムをタップするとonThemeModeClickが呼ばれる`() { var clicked = false diff --git a/feature/settings/src/test/java/kurou/androidpods/feature/settings/SettingsScreenTest.kt b/feature/settings/src/test/java/kurou/androidpods/feature/settings/SettingsScreenTest.kt index e467f69..33000bc 100644 --- a/feature/settings/src/test/java/kurou/androidpods/feature/settings/SettingsScreenTest.kt +++ b/feature/settings/src/test/java/kurou/androidpods/feature/settings/SettingsScreenTest.kt @@ -41,6 +41,8 @@ import kurou.androidpods.core.domain.GetOverlaySettingsUseCase import kurou.androidpods.core.domain.NotificationChannels import kurou.androidpods.core.domain.OverlayPosition import kurou.androidpods.core.domain.OverlayPositionUseCase +import kurou.androidpods.core.domain.RssiThreshold +import kurou.androidpods.core.domain.RssiThresholdUseCase import kurou.androidpods.core.domain.ThemeSettings import kurou.androidpods.core.domain.ThemeSettingsUseCase import kurou.androidpods.core.domain.UnknownDeviceUseCase @@ -69,6 +71,7 @@ class SettingsScreenTest { private val themeSettingsUseCase = mockk() private val overlayPositionUseCase = mockk(relaxUnitFun = true) private val unknownDeviceUseCase = mockk() + private val rssiThresholdUseCase = mockk() @After fun tearDown() { @@ -97,6 +100,8 @@ class SettingsScreenTest { every { themeSettingsUseCase.observe() } returns MutableStateFlow(ThemeSettings()) every { overlayPositionUseCase.observe() } returns MutableStateFlow(OverlayPosition.BOTTOM) every { unknownDeviceUseCase.observe() } returns MutableStateFlow(unknownModelCodes) + every { rssiThresholdUseCase.observe() } returns MutableStateFlow(RssiThreshold.VERY_NEAR) + coEvery { rssiThresholdUseCase.update(any()) } just Runs coEvery { themeSettingsUseCase.update(any()) } just Runs return SettingsViewModel( btUseCase, @@ -106,6 +111,7 @@ class SettingsScreenTest { themeSettingsUseCase, overlayPositionUseCase, unknownDeviceUseCase, + rssiThresholdUseCase, ) } @@ -580,6 +586,36 @@ class SettingsScreenTest { assertFalse(viewModel.uiState.value.showUnknownDeviceSheet) } + @Test + fun `RSSI閾値アイテムをタップするとRssiThresholdDialogが表示される`() { + val context = ApplicationProvider.getApplicationContext() + grantRequiredPermissions(context) + + composeTestRule.setContent { + SettingsScreen( + windowSizeClass = windowSizeClassOf(400f), + onStartScanService = {}, + onStopScanService = {}, + onLicensesClick = {}, + onDevicesClick = {}, + viewModel = createViewModel(BluetoothAdapter.STATE_ON), + ) + } + composeTestRule.waitForIdle() + + composeTestRule.onAllNodes(hasScrollAction()).onFirst() + .performScrollToNode(hasText("Signal Strength Filter")) + composeTestRule.onNodeWithText("Signal Strength Filter").performClick() + composeTestRule.waitForIdle() + + composeTestRule.onNodeWithText("All devices").assertExists() + + composeTestRule.onNodeWithText("All devices").performClick() + composeTestRule.waitForIdle() + + composeTestRule.onNodeWithText("All devices").assertDoesNotExist() + } + @Test fun `GitHubリポジトリをタップするとACTION_VIEWのインテントが発行される`() { val context = ApplicationProvider.getApplicationContext() diff --git a/feature/settings/src/test/java/kurou/androidpods/feature/settings/SettingsViewModelTest.kt b/feature/settings/src/test/java/kurou/androidpods/feature/settings/SettingsViewModelTest.kt index f9d8e8e..55a5283 100644 --- a/feature/settings/src/test/java/kurou/androidpods/feature/settings/SettingsViewModelTest.kt +++ b/feature/settings/src/test/java/kurou/androidpods/feature/settings/SettingsViewModelTest.kt @@ -26,6 +26,8 @@ import kurou.androidpods.core.domain.GetBluetoothAdapterStateUseCase import kurou.androidpods.core.domain.GetOverlaySettingsUseCase import kurou.androidpods.core.domain.OverlayPosition import kurou.androidpods.core.domain.OverlayPositionUseCase +import kurou.androidpods.core.domain.RssiThreshold +import kurou.androidpods.core.domain.RssiThresholdUseCase import kurou.androidpods.core.domain.ThemeMode import kurou.androidpods.core.domain.ThemeSettings import kurou.androidpods.core.domain.ThemeSettingsUseCase @@ -48,6 +50,7 @@ class SettingsViewModelTest { private val fakeOverlayPositionFlow = MutableStateFlow(OverlayPosition.BOTTOM) private val fakeOverlayEnabledFlow = MutableStateFlow(false) private val fakeUnknownModelCodesFlow = MutableStateFlow>(emptySet()) + private val fakeRssiThresholdFlow = MutableStateFlow(RssiThreshold.VERY_NEAR) private val getBluetoothAdapterStateUseCase = mockk() private val getAppleDevicesUseCase = mockk(relaxUnitFun = true) private val getOverlaySettingsUseCase = mockk(relaxUnitFun = true) @@ -55,6 +58,7 @@ class SettingsViewModelTest { private val themeSettingsUseCase = mockk() private val overlayPositionUseCase = mockk(relaxUnitFun = true) private val unknownDeviceUseCase = mockk() + private val rssiThresholdUseCase = mockk() @Before fun setUp() { @@ -65,6 +69,7 @@ class SettingsViewModelTest { every { themeSettingsUseCase.observe() } returns fakeThemeSettingsFlow every { overlayPositionUseCase.observe() } returns fakeOverlayPositionFlow every { unknownDeviceUseCase.observe() } returns fakeUnknownModelCodesFlow + every { rssiThresholdUseCase.observe() } returns fakeRssiThresholdFlow viewModel = SettingsViewModel( getBluetoothAdapterStateUseCase, @@ -74,6 +79,7 @@ class SettingsViewModelTest { themeSettingsUseCase, overlayPositionUseCase, unknownDeviceUseCase, + rssiThresholdUseCase, ) } @@ -99,6 +105,7 @@ class SettingsViewModelTest { verify(exactly = 1) { themeSettingsUseCase.observe() } verify(exactly = 1) { overlayPositionUseCase.observe() } verify(exactly = 1) { unknownDeviceUseCase.observe() } + verify(exactly = 1) { rssiThresholdUseCase.observe() } coVerify(exactly = 1) { checkUpdateUseCase(version) } confirmVerified( getBluetoothAdapterStateUseCase, @@ -108,6 +115,7 @@ class SettingsViewModelTest { themeSettingsUseCase, overlayPositionUseCase, unknownDeviceUseCase, + rssiThresholdUseCase, ) } @@ -127,6 +135,7 @@ class SettingsViewModelTest { verify(exactly = 1) { themeSettingsUseCase.observe() } verify(exactly = 1) { overlayPositionUseCase.observe() } verify(exactly = 1) { unknownDeviceUseCase.observe() } + verify(exactly = 1) { rssiThresholdUseCase.observe() } coVerify(exactly = 1) { checkUpdateUseCase(version) } confirmVerified( getBluetoothAdapterStateUseCase, @@ -136,6 +145,7 @@ class SettingsViewModelTest { themeSettingsUseCase, overlayPositionUseCase, unknownDeviceUseCase, + rssiThresholdUseCase, ) } @@ -155,6 +165,7 @@ class SettingsViewModelTest { verify(exactly = 1) { themeSettingsUseCase.observe() } verify(exactly = 1) { overlayPositionUseCase.observe() } verify(exactly = 1) { unknownDeviceUseCase.observe() } + verify(exactly = 1) { rssiThresholdUseCase.observe() } coVerify(exactly = 1) { checkUpdateUseCase(version) } confirmVerified( getBluetoothAdapterStateUseCase, @@ -164,6 +175,7 @@ class SettingsViewModelTest { themeSettingsUseCase, overlayPositionUseCase, unknownDeviceUseCase, + rssiThresholdUseCase, ) } @@ -184,6 +196,7 @@ class SettingsViewModelTest { verify(exactly = 1) { themeSettingsUseCase.observe() } verify(exactly = 1) { overlayPositionUseCase.observe() } verify(exactly = 1) { unknownDeviceUseCase.observe() } + verify(exactly = 1) { rssiThresholdUseCase.observe() } confirmVerified( getBluetoothAdapterStateUseCase, getAppleDevicesUseCase, @@ -192,6 +205,7 @@ class SettingsViewModelTest { themeSettingsUseCase, overlayPositionUseCase, unknownDeviceUseCase, + rssiThresholdUseCase, ) } @@ -210,6 +224,7 @@ class SettingsViewModelTest { verify(exactly = 1) { themeSettingsUseCase.observe() } verify(exactly = 1) { overlayPositionUseCase.observe() } verify(exactly = 1) { unknownDeviceUseCase.observe() } + verify(exactly = 1) { rssiThresholdUseCase.observe() } confirmVerified( getBluetoothAdapterStateUseCase, getAppleDevicesUseCase, @@ -218,6 +233,7 @@ class SettingsViewModelTest { themeSettingsUseCase, overlayPositionUseCase, unknownDeviceUseCase, + rssiThresholdUseCase, ) } @@ -236,6 +252,7 @@ class SettingsViewModelTest { verify(exactly = 1) { themeSettingsUseCase.observe() } verify(exactly = 1) { overlayPositionUseCase.observe() } verify(exactly = 1) { unknownDeviceUseCase.observe() } + verify(exactly = 1) { rssiThresholdUseCase.observe() } confirmVerified( getBluetoothAdapterStateUseCase, getAppleDevicesUseCase, @@ -244,6 +261,7 @@ class SettingsViewModelTest { themeSettingsUseCase, overlayPositionUseCase, unknownDeviceUseCase, + rssiThresholdUseCase, ) } @@ -262,6 +280,7 @@ class SettingsViewModelTest { verify(exactly = 1) { themeSettingsUseCase.observe() } verify(exactly = 1) { overlayPositionUseCase.observe() } verify(exactly = 1) { unknownDeviceUseCase.observe() } + verify(exactly = 1) { rssiThresholdUseCase.observe() } confirmVerified( getBluetoothAdapterStateUseCase, getAppleDevicesUseCase, @@ -270,6 +289,7 @@ class SettingsViewModelTest { themeSettingsUseCase, overlayPositionUseCase, unknownDeviceUseCase, + rssiThresholdUseCase, ) } @@ -287,6 +307,7 @@ class SettingsViewModelTest { verify(exactly = 1) { themeSettingsUseCase.observe() } verify(exactly = 1) { overlayPositionUseCase.observe() } verify(exactly = 1) { unknownDeviceUseCase.observe() } + verify(exactly = 1) { rssiThresholdUseCase.observe() } coVerify(exactly = 1) { themeSettingsUseCase.update(settings) } confirmVerified( getBluetoothAdapterStateUseCase, @@ -296,6 +317,7 @@ class SettingsViewModelTest { themeSettingsUseCase, overlayPositionUseCase, unknownDeviceUseCase, + rssiThresholdUseCase, ) } @@ -310,6 +332,7 @@ class SettingsViewModelTest { verify(exactly = 1) { themeSettingsUseCase.observe() } verify(exactly = 1) { overlayPositionUseCase.observe() } verify(exactly = 1) { unknownDeviceUseCase.observe() } + verify(exactly = 1) { rssiThresholdUseCase.observe() } coVerify(exactly = 1) { overlayPositionUseCase.update(OverlayPosition.TOP) } confirmVerified( getBluetoothAdapterStateUseCase, @@ -319,6 +342,7 @@ class SettingsViewModelTest { themeSettingsUseCase, overlayPositionUseCase, unknownDeviceUseCase, + rssiThresholdUseCase, ) } @@ -335,6 +359,7 @@ class SettingsViewModelTest { verify(exactly = 1) { themeSettingsUseCase.observe() } verify(exactly = 1) { overlayPositionUseCase.observe() } verify(exactly = 1) { unknownDeviceUseCase.observe() } + verify(exactly = 1) { rssiThresholdUseCase.observe() } confirmVerified( getBluetoothAdapterStateUseCase, getAppleDevicesUseCase, @@ -343,6 +368,7 @@ class SettingsViewModelTest { themeSettingsUseCase, overlayPositionUseCase, unknownDeviceUseCase, + rssiThresholdUseCase, ) } @@ -440,6 +466,7 @@ class SettingsViewModelTest { verify(exactly = 1) { themeSettingsUseCase.observe() } verify(exactly = 1) { overlayPositionUseCase.observe() } verify(exactly = 1) { unknownDeviceUseCase.observe() } + verify(exactly = 1) { rssiThresholdUseCase.observe() } confirmVerified( getBluetoothAdapterStateUseCase, getAppleDevicesUseCase, @@ -448,6 +475,7 @@ class SettingsViewModelTest { themeSettingsUseCase, overlayPositionUseCase, unknownDeviceUseCase, + rssiThresholdUseCase, ) } @@ -469,6 +497,7 @@ class SettingsViewModelTest { verify(exactly = 1) { themeSettingsUseCase.observe() } verify(exactly = 1) { overlayPositionUseCase.observe() } verify(exactly = 1) { unknownDeviceUseCase.observe() } + verify(exactly = 1) { rssiThresholdUseCase.observe() } confirmVerified( getBluetoothAdapterStateUseCase, getAppleDevicesUseCase, @@ -477,6 +506,35 @@ class SettingsViewModelTest { themeSettingsUseCase, overlayPositionUseCase, unknownDeviceUseCase, + rssiThresholdUseCase, + ) + } + + @Test + fun `updateRssiThresholdを呼び出すとUseCaseのupdateが呼ばれる`() = + runTest { + fakeBluetoothFlow.emit(BluetoothAdapter.STATE_ON) + coEvery { rssiThresholdUseCase.update(RssiThreshold.NEAR) } just Runs + + viewModel.updateRssiThreshold(RssiThreshold.NEAR) + + verify(exactly = 1) { getBluetoothAdapterStateUseCase.observe() } + verify(exactly = 1) { getAppleDevicesUseCase.observe() } + verify(exactly = 1) { getOverlaySettingsUseCase.observe() } + verify(exactly = 1) { themeSettingsUseCase.observe() } + verify(exactly = 1) { overlayPositionUseCase.observe() } + verify(exactly = 1) { unknownDeviceUseCase.observe() } + verify(exactly = 1) { rssiThresholdUseCase.observe() } + coVerify(exactly = 1) { rssiThresholdUseCase.update(RssiThreshold.NEAR) } + confirmVerified( + getBluetoothAdapterStateUseCase, + getAppleDevicesUseCase, + getOverlaySettingsUseCase, + checkUpdateUseCase, + themeSettingsUseCase, + overlayPositionUseCase, + unknownDeviceUseCase, + rssiThresholdUseCase, ) } diff --git a/navigation/src/test/java/kurou/androidpods/navigation/FakeRepositoryModule.kt b/navigation/src/test/java/kurou/androidpods/navigation/FakeRepositoryModule.kt index 8e75e68..338e4c4 100644 --- a/navigation/src/test/java/kurou/androidpods/navigation/FakeRepositoryModule.kt +++ b/navigation/src/test/java/kurou/androidpods/navigation/FakeRepositoryModule.kt @@ -15,6 +15,8 @@ import kurou.androidpods.core.domain.CompatibleDeviceRepository import kurou.androidpods.core.domain.OverlayPosition import kurou.androidpods.core.domain.OverlayPositionRepository import kurou.androidpods.core.domain.OverlaySettingsRepository +import kurou.androidpods.core.domain.RssiThreshold +import kurou.androidpods.core.domain.RssiThresholdRepository import kurou.androidpods.core.domain.ThemeSettings import kurou.androidpods.core.domain.ThemeSettingsRepository import kurou.androidpods.core.domain.UnknownDeviceRepository @@ -108,4 +110,13 @@ object FakeRepositoryModule { override suspend fun save(device: AppleDevice) {} } + + @Provides + @Singleton + fun provideRssiThresholdRepository(): RssiThresholdRepository = + object : RssiThresholdRepository { + override fun observe(): Flow = flowOf(RssiThreshold.VERY_NEAR) + + override suspend fun update(threshold: RssiThreshold) {} + } }