mirror of
https://github.com/d4rken-org/capod.git
synced 2026-09-15 02:36:12 -04:00
feat(aap): Persist learned ANC settings across reconnects
This commit is contained in:
+84
-5
@@ -16,12 +16,10 @@ import eu.darken.capod.reaction.core.stem.StemAction
|
||||
import eu.darken.capod.reaction.core.stem.StemActionSettings
|
||||
import io.kotest.matchers.shouldBe
|
||||
import io.kotest.matchers.types.shouldBeInstanceOf
|
||||
import io.mockk.Called
|
||||
import io.mockk.coEvery
|
||||
import io.mockk.coVerify
|
||||
import io.mockk.every
|
||||
import io.mockk.mockk
|
||||
import io.mockk.verify
|
||||
import kotlinx.coroutines.CompletableDeferred
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.awaitCancellation
|
||||
@@ -70,6 +68,7 @@ class DeviceSettingsViewModelTest : BaseTest() {
|
||||
private lateinit var devicesFlow: MutableStateFlow<List<PodDevice>>
|
||||
private lateinit var upgradeInfoFlow: MutableStateFlow<UpgradeRepo.Info>
|
||||
private lateinit var connectedDevicesFlow: MutableStateFlow<List<BluetoothDevice2>>
|
||||
private lateinit var offRejectedFlow: kotlinx.coroutines.flow.MutableSharedFlow<BluetoothAddress>
|
||||
|
||||
private fun mockBondedDevice(address: BluetoothAddress): BluetoothDevice2 = mockk {
|
||||
every { this@mockk.address } returns address
|
||||
@@ -84,7 +83,7 @@ class DeviceSettingsViewModelTest : BaseTest() {
|
||||
every { it.isPro } returns false
|
||||
})
|
||||
|
||||
val syntheticDevice = mockk<PodDevice>().also {
|
||||
val syntheticDevice = mockk<PodDevice>(relaxed = true).also {
|
||||
every { it.profileId } returns testAddress
|
||||
every { it.address } returns testAddress
|
||||
}
|
||||
@@ -92,7 +91,10 @@ class DeviceSettingsViewModelTest : BaseTest() {
|
||||
every { it.devices } returns devicesFlow
|
||||
coEvery { it.getDeviceForProfile(testAddress) } returns syntheticDevice
|
||||
}
|
||||
aapManager = mockk(relaxed = true)
|
||||
offRejectedFlow = kotlinx.coroutines.flow.MutableSharedFlow(extraBufferCapacity = 16)
|
||||
aapManager = mockk(relaxed = true) {
|
||||
every { offRejectedEvents } returns offRejectedFlow
|
||||
}
|
||||
upgradeRepo = mockk<UpgradeRepo>().also {
|
||||
every { it.upgradeInfo } returns upgradeInfoFlow
|
||||
}
|
||||
@@ -282,7 +284,7 @@ class DeviceSettingsViewModelTest : BaseTest() {
|
||||
|
||||
vm.setDeviceName("NewName")
|
||||
|
||||
verify { aapManager wasNot Called }
|
||||
coVerify(exactly = 0) { aapManager.sendCommand(any(), AapCommand.SetDeviceName("NewName")) }
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -391,4 +393,81 @@ class DeviceSettingsViewModelTest : BaseTest() {
|
||||
sendFailed.command shouldBe AapCommand.SetNcWithOneAirPod(true)
|
||||
sendFailed.message shouldBe "socket closed"
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `offRejectedEvents for current address emits OffModeRejectedByDevice`() = runVmTest {
|
||||
val vm = createViewModel()
|
||||
vm.initialize(testAddress)
|
||||
vm.state.first()
|
||||
|
||||
offRejectedFlow.emit(testAddress)
|
||||
|
||||
val event = vm.events.first()
|
||||
event shouldBe DeviceSettingsViewModel.Event.OffModeRejectedByDevice
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `offRejectedEvents for other address is ignored`() = runVmTest {
|
||||
val vm = createViewModel()
|
||||
vm.initialize(testAddress)
|
||||
vm.state.first()
|
||||
|
||||
offRejectedFlow.emit("11:22:33:44:55:66")
|
||||
// No event should have been emitted — send another recognized event afterward
|
||||
// so we can assert that the first emission from vm.events is the later one.
|
||||
coEvery {
|
||||
aapManager.sendCommand(testAddress, AapCommand.SetNcWithOneAirPod(true))
|
||||
} throws IllegalStateException("socket closed")
|
||||
vm.setNcWithOneAirPod(true)
|
||||
|
||||
val event = vm.events.first()
|
||||
event.shouldBeInstanceOf<DeviceSettingsViewModel.Event.SendFailed>()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `setAllowOffOption(true) as Pro sends only SetAllowOffOption`() = runVmTest {
|
||||
every { upgradeInfoFlow.value.isPro } returns true
|
||||
|
||||
val vm = createViewModel()
|
||||
vm.initialize(testAddress)
|
||||
vm.state.first()
|
||||
|
||||
vm.setAllowOffOption(true)
|
||||
|
||||
coVerify(exactly = 1) { aapManager.sendCommand(testAddress, AapCommand.SetAllowOffOption(true)) }
|
||||
coVerify(exactly = 0) { aapManager.sendCommand(any(), AapCommand.SetListeningModeCycle(0x0E)) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `setAllowOffOption(false) as Pro always sends SetListeningModeCycle + SetAllowOffOption(false) in order`() = runVmTest {
|
||||
every { upgradeInfoFlow.value.isPro } returns true
|
||||
|
||||
val vm = createViewModel()
|
||||
vm.initialize(testAddress)
|
||||
vm.state.first()
|
||||
|
||||
vm.setAllowOffOption(false)
|
||||
|
||||
// Fallback cycle mask (0x0F) with OFF bit stripped = 0x0E.
|
||||
coVerify(ordering = io.mockk.Ordering.ORDERED) {
|
||||
aapManager.sendCommand(testAddress, AapCommand.SetListeningModeCycle(0x0E))
|
||||
aapManager.sendCommand(testAddress, AapCommand.SetAllowOffOption(false))
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `setAllowOffOption as non-Pro sends no commands`() = runVmTest {
|
||||
every { upgradeInfoFlow.value.isPro } returns false
|
||||
|
||||
val vm = createViewModel()
|
||||
vm.initialize(testAddress)
|
||||
vm.state.first()
|
||||
|
||||
vm.setAllowOffOption(true)
|
||||
vm.setAllowOffOption(false)
|
||||
|
||||
coVerify(exactly = 0) { aapManager.sendCommand(any(), AapCommand.SetAllowOffOption(true)) }
|
||||
coVerify(exactly = 0) { aapManager.sendCommand(any(), AapCommand.SetAllowOffOption(false)) }
|
||||
coVerify(exactly = 0) { aapManager.sendCommand(any(), AapCommand.SetListeningModeCycle(0x0E)) }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
package eu.darken.capod.monitor.core
|
||||
|
||||
import eu.darken.capod.pods.core.apple.PodModel
|
||||
import eu.darken.capod.pods.core.apple.aap.AapPodState
|
||||
import eu.darken.capod.pods.core.apple.aap.protocol.AapSetting
|
||||
import io.kotest.matchers.collections.shouldContainExactly
|
||||
import io.kotest.matchers.shouldBe
|
||||
@@ -92,4 +94,96 @@ class PodDeviceAncModeTest : BaseTest() {
|
||||
reportedCycleMask = 0x0A,
|
||||
) shouldBe 0x0A
|
||||
}
|
||||
|
||||
// -- PodDevice.visibleAncModes extension: the null-coalesce lives here --
|
||||
|
||||
private fun deviceWith(
|
||||
allowOffSetting: AapSetting.AllowOffOption? = null,
|
||||
learnedAllowOffEnabled: Boolean? = null,
|
||||
currentMode: AapSetting.AncMode.Value = AapSetting.AncMode.Value.ON,
|
||||
): PodDevice {
|
||||
val ancSetting = AapSetting.AncMode(current = currentMode, supported = allModes)
|
||||
val settings: Map<kotlin.reflect.KClass<out AapSetting>, AapSetting> = buildMap {
|
||||
put(AapSetting.AncMode::class, ancSetting)
|
||||
if (allowOffSetting != null) put(AapSetting.AllowOffOption::class, allowOffSetting)
|
||||
}
|
||||
return PodDevice(
|
||||
profileId = null,
|
||||
ble = null,
|
||||
aap = AapPodState(settings = settings),
|
||||
profileModel = PodModel.AIRPODS_PRO,
|
||||
profileLearnedAllowOffEnabled = learnedAllowOffEnabled,
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `unknown AllowOffOption is treated as allowed — OFF visible by default`() {
|
||||
val device = deviceWith(allowOffSetting = null, learnedAllowOffEnabled = null)
|
||||
device.visibleAncModes shouldContainExactly allModes
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `confirmed AllowOffOption=false hides OFF`() {
|
||||
val device = deviceWith(
|
||||
allowOffSetting = AapSetting.AllowOffOption(enabled = false),
|
||||
learnedAllowOffEnabled = null,
|
||||
)
|
||||
device.visibleAncModes shouldContainExactly listOf(
|
||||
AapSetting.AncMode.Value.ON,
|
||||
AapSetting.AncMode.Value.TRANSPARENCY,
|
||||
AapSetting.AncMode.Value.ADAPTIVE,
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `profile-learned AllowOffEnabled=false acts as fallback and hides OFF`() {
|
||||
val device = deviceWith(allowOffSetting = null, learnedAllowOffEnabled = false)
|
||||
device.visibleAncModes shouldContainExactly listOf(
|
||||
AapSetting.AncMode.Value.ON,
|
||||
AapSetting.AncMode.Value.TRANSPARENCY,
|
||||
AapSetting.AncMode.Value.ADAPTIVE,
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `live AAP AllowOffOption overrides profile fallback`() {
|
||||
val device = deviceWith(
|
||||
allowOffSetting = AapSetting.AllowOffOption(enabled = true),
|
||||
learnedAllowOffEnabled = false,
|
||||
)
|
||||
device.visibleAncModes shouldContainExactly allModes
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `profile-learned ListeningModeCycle mask is used when AAP state has none`() {
|
||||
val ancSetting = AapSetting.AncMode(current = AapSetting.AncMode.Value.ON, supported = allModes)
|
||||
val device = PodDevice(
|
||||
profileId = null,
|
||||
ble = null,
|
||||
aap = AapPodState(settings = mapOf(AapSetting.AncMode::class to ancSetting)),
|
||||
profileModel = PodModel.AIRPODS_PRO,
|
||||
profileLearnedAllowOffEnabled = true,
|
||||
profileLastRequestedListeningModeCycleMask = 0x0F,
|
||||
)
|
||||
device.listeningModeCycle?.modeMask shouldBe 0x0F
|
||||
device.resolvedAncCycleMask shouldBe 0x0F
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `live AAP ListeningModeCycle overrides profile fallback`() {
|
||||
val ancSetting = AapSetting.AncMode(current = AapSetting.AncMode.Value.ON, supported = allModes)
|
||||
val device = PodDevice(
|
||||
profileId = null,
|
||||
ble = null,
|
||||
aap = AapPodState(
|
||||
settings = mapOf(
|
||||
AapSetting.AncMode::class to ancSetting,
|
||||
AapSetting.ListeningModeCycle::class to AapSetting.ListeningModeCycle(modeMask = 0x0A),
|
||||
),
|
||||
),
|
||||
profileModel = PodModel.AIRPODS_PRO,
|
||||
profileLastRequestedListeningModeCycleMask = 0x0F,
|
||||
)
|
||||
device.listeningModeCycle?.modeMask shouldBe 0x0A
|
||||
}
|
||||
}
|
||||
|
||||
+114
@@ -0,0 +1,114 @@
|
||||
package eu.darken.capod.monitor.core.aap
|
||||
|
||||
import eu.darken.capod.pods.core.apple.PodModel
|
||||
import eu.darken.capod.pods.core.apple.aap.AapConnectionManager
|
||||
import eu.darken.capod.pods.core.apple.aap.AapPodState
|
||||
import eu.darken.capod.pods.core.apple.aap.protocol.AapSetting
|
||||
import eu.darken.capod.profiles.core.AppleDeviceProfile
|
||||
import eu.darken.capod.profiles.core.DeviceProfilesRepo
|
||||
import io.mockk.coVerify
|
||||
import io.mockk.every
|
||||
import io.mockk.mockk
|
||||
import io.mockk.slot
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.flowOf
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.test.UnconfinedTestDispatcher
|
||||
import kotlinx.coroutines.test.advanceUntilIdle
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.jupiter.api.Test
|
||||
import testhelpers.BaseTest
|
||||
|
||||
class AapLearnedSettingsPersisterTest : BaseTest() {
|
||||
|
||||
private val testAddress = "AA:BB:CC:DD:EE:FF"
|
||||
private val testProfile = AppleDeviceProfile(
|
||||
label = "Test AirPods",
|
||||
model = PodModel.AIRPODS_PRO,
|
||||
address = testAddress,
|
||||
)
|
||||
|
||||
private fun stateWithSettings(
|
||||
allowOffEnabled: Boolean? = null,
|
||||
cycleMask: Int? = null,
|
||||
): AapPodState {
|
||||
val settings = buildMap<kotlin.reflect.KClass<out AapSetting>, AapSetting> {
|
||||
allowOffEnabled?.let { put(AapSetting.AllowOffOption::class, AapSetting.AllowOffOption(it)) }
|
||||
cycleMask?.let { put(AapSetting.ListeningModeCycle::class, AapSetting.ListeningModeCycle(it)) }
|
||||
}
|
||||
return AapPodState(settings = settings)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `persists AllowOffOption value to matching profile`() = runTest(UnconfinedTestDispatcher()) {
|
||||
val allStates = MutableStateFlow<Map<String, AapPodState>>(emptyMap())
|
||||
val aapManager = mockk<AapConnectionManager>(relaxed = true) {
|
||||
every { this@mockk.allStates } returns allStates
|
||||
}
|
||||
val profilesRepo = mockk<DeviceProfilesRepo>(relaxUnitFun = true) {
|
||||
every { profiles } returns flowOf(listOf<eu.darken.capod.profiles.core.DeviceProfile>(testProfile))
|
||||
}
|
||||
val persister = AapLearnedSettingsPersister(aapManager, profilesRepo)
|
||||
|
||||
val job = launch { persister.monitor().collect {} }
|
||||
|
||||
allStates.value = mapOf(testAddress to stateWithSettings(allowOffEnabled = false))
|
||||
advanceUntilIdle()
|
||||
|
||||
val transform = slot<(AppleDeviceProfile) -> AppleDeviceProfile>()
|
||||
coVerify { profilesRepo.updateAppleProfile(eq(testProfile.id), capture(transform)) }
|
||||
val updated = transform.captured(testProfile)
|
||||
assert(updated.learnedAllowOffEnabled == false) { "Expected learnedAllowOffEnabled=false" }
|
||||
|
||||
job.cancel()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `persists ListeningModeCycle mask to matching profile`() = runTest(UnconfinedTestDispatcher()) {
|
||||
val allStates = MutableStateFlow<Map<String, AapPodState>>(emptyMap())
|
||||
val aapManager = mockk<AapConnectionManager>(relaxed = true) {
|
||||
every { this@mockk.allStates } returns allStates
|
||||
}
|
||||
val profilesRepo = mockk<DeviceProfilesRepo>(relaxUnitFun = true) {
|
||||
every { profiles } returns flowOf(listOf<eu.darken.capod.profiles.core.DeviceProfile>(testProfile))
|
||||
}
|
||||
val persister = AapLearnedSettingsPersister(aapManager, profilesRepo)
|
||||
|
||||
val job = launch { persister.monitor().collect {} }
|
||||
|
||||
allStates.value = mapOf(testAddress to stateWithSettings(cycleMask = 0x0F))
|
||||
advanceUntilIdle()
|
||||
|
||||
val transform = slot<(AppleDeviceProfile) -> AppleDeviceProfile>()
|
||||
coVerify { profilesRepo.updateAppleProfile(eq(testProfile.id), capture(transform)) }
|
||||
val updated = transform.captured(testProfile)
|
||||
assert(updated.lastRequestedListeningModeCycleMask == 0x0F) { "Expected lastRequestedListeningModeCycleMask=0x0F" }
|
||||
|
||||
job.cancel()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `does not write when settings match already-persisted values`() = runTest(UnconfinedTestDispatcher()) {
|
||||
val allStates = MutableStateFlow<Map<String, AapPodState>>(emptyMap())
|
||||
val aapManager = mockk<AapConnectionManager>(relaxed = true) {
|
||||
every { this@mockk.allStates } returns allStates
|
||||
}
|
||||
val profileWithStored = testProfile.copy(
|
||||
learnedAllowOffEnabled = true,
|
||||
lastRequestedListeningModeCycleMask = 0x0F,
|
||||
)
|
||||
val profilesRepo = mockk<DeviceProfilesRepo>(relaxUnitFun = true) {
|
||||
every { profiles } returns flowOf(listOf<eu.darken.capod.profiles.core.DeviceProfile>(profileWithStored))
|
||||
}
|
||||
val persister = AapLearnedSettingsPersister(aapManager, profilesRepo)
|
||||
|
||||
val job = launch { persister.monitor().collect {} }
|
||||
|
||||
allStates.value = mapOf(testAddress to stateWithSettings(allowOffEnabled = true, cycleMask = 0x0F))
|
||||
advanceUntilIdle()
|
||||
|
||||
coVerify(exactly = 0) { profilesRepo.updateAppleProfile(any(), any()) }
|
||||
|
||||
job.cancel()
|
||||
}
|
||||
}
|
||||
@@ -588,6 +588,86 @@ class AapSessionEngineTest : BaseTest() {
|
||||
AapCommand.SetAncMode(AapSetting.AncMode.Value.OFF),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `rejected OFF command emits offRejected event`() = runTest(UnconfinedTestDispatcher()) {
|
||||
val supportedModes = listOf(
|
||||
AapSetting.AncMode.Value.OFF,
|
||||
AapSetting.AncMode.Value.ON,
|
||||
AapSetting.AncMode.Value.ADAPTIVE,
|
||||
)
|
||||
var nextSetting: Pair<KClass<out AapSetting>, AapSetting>? = null
|
||||
val profile = mockProfile {
|
||||
every { decodeSetting(any()) } answers { nextSetting }
|
||||
}
|
||||
val engine = AapSessionEngine(profile, timeSource)
|
||||
engine.startReady(this as TestScope)
|
||||
|
||||
val rejected = mutableListOf<Unit>()
|
||||
val collectJob = launch { engine.offRejected.collect { rejected += it } }
|
||||
|
||||
nextSetting = settingPair(
|
||||
AapSetting.AncMode(
|
||||
current = AapSetting.AncMode.Value.ADAPTIVE,
|
||||
supported = supportedModes,
|
||||
)
|
||||
)
|
||||
engine.processMessage(dummyMessage())
|
||||
|
||||
engine.send(AapCommand.SetAncMode(AapSetting.AncMode.Value.OFF)) { }
|
||||
|
||||
nextSetting = settingPair(
|
||||
AapSetting.AncMode(
|
||||
current = AapSetting.AncMode.Value.ADAPTIVE,
|
||||
supported = supportedModes,
|
||||
)
|
||||
)
|
||||
engine.processMessage(dummyMessage())
|
||||
|
||||
advanceTimeBy(2100L)
|
||||
rejected.size shouldBe 1
|
||||
collectJob.cancel()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `rejected non-OFF command does not emit offRejected`() = runTest(UnconfinedTestDispatcher()) {
|
||||
val supportedModes = listOf(
|
||||
AapSetting.AncMode.Value.OFF,
|
||||
AapSetting.AncMode.Value.ON,
|
||||
AapSetting.AncMode.Value.ADAPTIVE,
|
||||
)
|
||||
var nextSetting: Pair<KClass<out AapSetting>, AapSetting>? = null
|
||||
val profile = mockProfile {
|
||||
every { decodeSetting(any()) } answers { nextSetting }
|
||||
}
|
||||
val engine = AapSessionEngine(profile, timeSource)
|
||||
engine.startReady(this as TestScope)
|
||||
|
||||
val rejected = mutableListOf<Unit>()
|
||||
val collectJob = launch { engine.offRejected.collect { rejected += it } }
|
||||
|
||||
nextSetting = settingPair(
|
||||
AapSetting.AncMode(
|
||||
current = AapSetting.AncMode.Value.ADAPTIVE,
|
||||
supported = supportedModes,
|
||||
)
|
||||
)
|
||||
engine.processMessage(dummyMessage())
|
||||
|
||||
engine.send(AapCommand.SetAncMode(AapSetting.AncMode.Value.ON)) { }
|
||||
|
||||
nextSetting = settingPair(
|
||||
AapSetting.AncMode(
|
||||
current = AapSetting.AncMode.Value.ADAPTIVE,
|
||||
supported = supportedModes,
|
||||
)
|
||||
)
|
||||
engine.processMessage(dummyMessage())
|
||||
|
||||
advanceTimeBy(2100L)
|
||||
rejected shouldBe emptyList()
|
||||
collectJob.cancel()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
+8
-6
@@ -147,7 +147,7 @@ class AapSettingsCoordinatorTest : BaseTest() {
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `flush sorts AllowOffOption before AncMode before others`() {
|
||||
fun `flush sorts ListeningModeCycle before AllowOffOption before AncMode before others`() {
|
||||
val coord = createCoordinator()
|
||||
val state = stateWithSetting(
|
||||
AapSetting.ToneVolume::class to AapSetting.ToneVolume(level = 50),
|
||||
@@ -157,12 +157,14 @@ class AapSettingsCoordinatorTest : BaseTest() {
|
||||
val second =
|
||||
coord.enqueue(first.pendingCommands, AapCommand.SetAncMode(AapSetting.AncMode.Value.OFF), state)
|
||||
val third = coord.enqueue(second.pendingCommands, AapCommand.SetAllowOffOption(true), state)
|
||||
val result = coord.flush(third.pendingCommands)
|
||||
val fourth = coord.enqueue(third.pendingCommands, AapCommand.SetListeningModeCycle(0x0F), state)
|
||||
val result = coord.flush(fourth.pendingCommands)
|
||||
|
||||
result.commands shouldHaveSize 3
|
||||
result.commands[0].shouldBeInstanceOf<AapCommand.SetAllowOffOption>()
|
||||
result.commands[1].shouldBeInstanceOf<AapCommand.SetAncMode>()
|
||||
result.commands[2].shouldBeInstanceOf<AapCommand.SetToneVolume>()
|
||||
result.commands shouldHaveSize 4
|
||||
result.commands[0].shouldBeInstanceOf<AapCommand.SetListeningModeCycle>()
|
||||
result.commands[1].shouldBeInstanceOf<AapCommand.SetAllowOffOption>()
|
||||
result.commands[2].shouldBeInstanceOf<AapCommand.SetAncMode>()
|
||||
result.commands[3].shouldBeInstanceOf<AapCommand.SetToneVolume>()
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
Reference in New Issue
Block a user