feat(reactions): Move reaction settings to per-device profiles

Migrate reaction toggles (auto-play, auto-pause, auto-connect, popups) from global ReactionSettings singleton to per-profile fields on AppleDeviceProfile. Each paired device can now have independent reaction behavior.

Extract ReactionConfig snapshot to decouple PodDevice from AppleDeviceProfile — reaction consumers read device.reactions instead of device.profile. Remove auto-connect from upgrade benefits (now free). Add LegacyReactionSettingsReader for one-shot DataStore migration.
This commit is contained in:
darken
2026-04-14 11:28:41 +02:00
committed by Matthias Urhahn
parent 29df90628f
commit 067eb2e9e0
115 changed files with 834 additions and 1225 deletions
@@ -4,10 +4,12 @@ import eu.darken.capod.common.bluetooth.BluetoothAddress
import eu.darken.capod.common.bluetooth.BluetoothDevice2
import eu.darken.capod.common.bluetooth.BluetoothManager2
import eu.darken.capod.common.upgrade.UpgradeRepo
import eu.darken.capod.main.core.GeneralSettings
import eu.darken.capod.monitor.core.DeviceMonitor
import eu.darken.capod.monitor.core.PodDevice
import eu.darken.capod.pods.core.apple.aap.AapConnectionManager
import eu.darken.capod.pods.core.apple.aap.protocol.AapCommand
import eu.darken.capod.profiles.core.DeviceProfilesRepo
import io.kotest.matchers.shouldBe
import io.kotest.matchers.types.shouldBeInstanceOf
import io.mockk.Called
@@ -46,6 +48,8 @@ class DeviceSettingsViewModelTest : BaseTest() {
private lateinit var aapManager: AapConnectionManager
private lateinit var upgradeRepo: UpgradeRepo
private lateinit var bluetoothManager: BluetoothManager2
private lateinit var profilesRepo: DeviceProfilesRepo
private lateinit var generalSettings: GeneralSettings
private lateinit var devicesFlow: MutableStateFlow<List<PodDevice>>
private lateinit var upgradeInfoFlow: MutableStateFlow<UpgradeRepo.Info>
@@ -63,8 +67,14 @@ class DeviceSettingsViewModelTest : BaseTest() {
every { it.isPro } returns false
})
// Synthesize a PodDevice for forceConnect/sendInternal lookups (currentAddress()).
val syntheticDevice = mockk<PodDevice>().also {
every { it.profileId } returns testAddress
every { it.address } returns testAddress
}
deviceMonitor = mockk<DeviceMonitor>().also {
every { it.devices } returns devicesFlow
coEvery { it.getDeviceForProfile(testAddress) } returns syntheticDevice
}
aapManager = mockk(relaxed = true)
upgradeRepo = mockk<UpgradeRepo>().also {
@@ -74,6 +84,8 @@ class DeviceSettingsViewModelTest : BaseTest() {
every { isNudgeAvailable } returns true
every { bondedDevices() } returns flowOf(emptySet())
}
profilesRepo = mockk(relaxed = true)
generalSettings = mockk(relaxed = true)
}
@AfterEach
@@ -87,6 +99,8 @@ class DeviceSettingsViewModelTest : BaseTest() {
aapManager = aapManager,
upgradeRepo = upgradeRepo,
bluetoothManager = bluetoothManager,
profilesRepo = profilesRepo,
generalSettings = generalSettings,
)
@Test
@@ -312,7 +312,7 @@ class DeviceMonitorTest : BaseTest() {
}
@Test
fun `getDeviceForProfile no BLE no cache no AAP - returns null`() = runTest(testDispatcher) {
fun `getDeviceForProfile no BLE no cache no AAP - returns synthesized bare device`() = runTest(testDispatcher) {
val monitor = createMonitor(
ble = emptyList(),
aap = emptyMap(),
@@ -323,6 +323,27 @@ class DeviceMonitorTest : BaseTest() {
val device = monitor.getDeviceForProfile(testProfile.id)
// Pristine profile must still be reachable so the Reactions section in
// DeviceSettings can be edited even before the device has ever been observed.
device shouldNotBe null
device!!.profileId shouldBe testProfile.id
device.reactions shouldBe testProfile.reactionConfig
device.ble shouldBe null
device.isLive shouldBe false
}
@Test
fun `getDeviceForProfile profile not in repo - returns null`() = runTest(testDispatcher) {
val monitor = createMonitor(
ble = emptyList(),
aap = emptyMap(),
cache = emptyMap(),
profiles = emptyList(),
scope = backgroundScope,
)
val device = monitor.getDeviceForProfile("nonexistent-profile-id")
device shouldBe null
}
@@ -21,6 +21,7 @@ class DeviceProfilesRepoReorderTest : BaseTest() {
val settings = mockk<DeviceProfilesSettings> {
every { profiles } returns fakeProfiles.mock
every { defaultProfileCreated } returns FakeDataStoreValue(true).mock
every { reactionMigrationDone } returns FakeDataStoreValue(true).mock
}
val repo = DeviceProfilesRepo(
@@ -29,6 +30,7 @@ class DeviceProfilesRepoReorderTest : BaseTest() {
generalSettings = mockk(relaxed = true),
settings = settings,
deviceStateCache = mockk(relaxed = true),
json = kotlinx.serialization.json.Json { ignoreUnknownKeys = true },
)
return repo to fakeProfiles
@@ -0,0 +1,74 @@
package eu.darken.capod.profiles.core
import eu.darken.capod.reaction.core.autoconnect.AutoConnectCondition
import io.kotest.matchers.shouldBe
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
import org.junit.jupiter.api.Test
import testhelpers.BaseTest
/**
* Verifies the legacy reaction DataStore key names and JSON serialization format.
* A key-name mismatch would silently zero all reaction settings for upgrading users.
*/
class LegacyReactionSettingsReaderTest : BaseTest() {
/**
* The keys that were used by the now-deleted `ReactionSettings` class.
* If any of these change in `LegacyReactionSettingsReader`, upgrading users lose their settings.
* This test acts as a frozen snapshot — DO NOT update these values to "fix" the test.
*/
@Test
fun `legacy key names match the deleted ReactionSettings class`() {
// Boolean keys — frozen snapshot of the old ReactionSettings key names.
// If LegacyReactionSettingsReader keys change, upgrading users silently lose settings.
LegacyReactionSettingsReader.AUTO_PAUSE_KEY.name shouldBe "reaction.autopause.enabled"
LegacyReactionSettingsReader.AUTO_PLAY_KEY.name shouldBe "reaction.autoplay.enabled"
LegacyReactionSettingsReader.AUTO_CONNECT_KEY.name shouldBe "reaction.autoconnect.enabled"
LegacyReactionSettingsReader.POPUP_CASE_OPEN_KEY.name shouldBe "reaction.popup.caseopen"
LegacyReactionSettingsReader.POPUP_CONNECTED_KEY.name shouldBe "reaction.popup.connected"
LegacyReactionSettingsReader.ONE_POD_KEY.name shouldBe "reaction.onepod.enabled"
// String key for serialized enum
LegacyReactionSettingsReader.CONDITION_KEY.name shouldBe "reaction.autoconnect.condition"
}
/**
* Verifies the project Json can round-trip AutoConnectCondition enum values using their
* @SerialName annotations. The legacy DataStore stored these as JSON strings.
*/
@Test
fun `AutoConnectCondition round-trips through project Json`() {
val projectJson = Json {
ignoreUnknownKeys = true
encodeDefaults = true
explicitNulls = false
classDiscriminator = "type"
}
for (condition in AutoConnectCondition.entries) {
val encoded = projectJson.encodeToString(condition)
val decoded = projectJson.decodeFromString<AutoConnectCondition>(encoded)
decoded shouldBe condition
}
}
/**
* Verifies the serialized form matches what the deleted ReactionSettings class stored.
* The old class used `createValue(key, default, json, onErrorFallbackToDefault = true)`
* which serializes the enum to its @SerialName value as a JSON string.
*/
@Test
fun `AutoConnectCondition serialized format matches legacy storage`() {
val json = Json {
ignoreUnknownKeys = true
encodeDefaults = true
explicitNulls = false
classDiscriminator = "type"
}
json.encodeToString(AutoConnectCondition.WHEN_SEEN) shouldBe "\"autoconnect.condition.seen\""
json.encodeToString(AutoConnectCondition.CASE_OPEN) shouldBe "\"autoconnect.condition.case\""
json.encodeToString(AutoConnectCondition.IN_EAR) shouldBe "\"autoconnect.condition.inear\""
}
}
@@ -18,8 +18,6 @@ class AutoConnectLogicTest : BaseTest() {
bluetoothManager = mockk(relaxed = true),
deviceMonitor = mockk(relaxed = true),
generalSettings = mockk(relaxed = true),
reactionSettings = mockk(relaxed = true),
deviceProfilesRepo = mockk(relaxed = true),
)
}
@@ -18,7 +18,6 @@ class PlayPauseLogicTest : BaseTest() {
playPause = PlayPause(
deviceMonitor = mockk(relaxed = true),
bluetoothManager = mockk(relaxed = true),
reactionSettings = mockk(relaxed = true),
mediaControl = mockk(relaxed = true)
)
}
@@ -18,7 +18,6 @@ class PopUpReactionLogicTest : BaseTest() {
fun setup() {
popUpReaction = PopUpReaction(
deviceMonitor = mockk(relaxed = true),
reactionSettings = mockk(relaxed = true),
bluetoothManager = mockk(relaxed = true),
)
}
@@ -1,321 +0,0 @@
package eu.darken.capod.reaction.ui
import eu.darken.capod.common.navigation.Nav
import eu.darken.capod.common.navigation.NavEvent
import eu.darken.capod.common.upgrade.UpgradeRepo
import eu.darken.capod.main.core.GeneralSettings
import eu.darken.capod.main.core.MonitorMode
import eu.darken.capod.reaction.core.ReactionSettings
import eu.darken.capod.reaction.core.autoconnect.AutoConnectCondition
import io.kotest.matchers.shouldBe
import io.mockk.every
import io.mockk.mockk
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.test.UnconfinedTestDispatcher
import kotlinx.coroutines.test.resetMain
import kotlinx.coroutines.test.runTest
import kotlinx.coroutines.test.setMain
import org.junit.jupiter.api.AfterEach
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Nested
import org.junit.jupiter.api.Test
import testhelpers.BaseTest
import testhelpers.coroutine.TestDispatcherProvider
import testhelpers.datastore.FakeDataStoreValue
import testhelpers.livedata.InstantExecutorExtension
import org.junit.jupiter.api.extension.ExtendWith
@OptIn(ExperimentalCoroutinesApi::class)
@ExtendWith(InstantExecutorExtension::class)
class ReactionSettingsViewModelTest : BaseTest() {
private val testDispatcher = UnconfinedTestDispatcher()
private lateinit var upgradeInfoFlow: MutableStateFlow<UpgradeRepo.Info>
private lateinit var upgradeRepo: UpgradeRepo
private lateinit var reactionSettings: ReactionSettings
private lateinit var generalSettings: GeneralSettings
private lateinit var fakeOnePodMode: FakeDataStoreValue<Boolean>
private lateinit var fakeAutoPlay: FakeDataStoreValue<Boolean>
private lateinit var fakeAutoPause: FakeDataStoreValue<Boolean>
private lateinit var fakeAutoConnect: FakeDataStoreValue<Boolean>
private lateinit var fakeAutoConnectCondition: FakeDataStoreValue<AutoConnectCondition>
private lateinit var fakeShowPopUpOnCaseOpen: FakeDataStoreValue<Boolean>
private lateinit var fakeShowPopUpOnConnection: FakeDataStoreValue<Boolean>
private lateinit var fakeMonitorMode: FakeDataStoreValue<MonitorMode>
@BeforeEach
fun setup() {
Dispatchers.setMain(testDispatcher)
upgradeInfoFlow = MutableStateFlow(mockUpgradeInfo(isPro = false))
upgradeRepo = mockk<UpgradeRepo>().also {
every { it.upgradeInfo } returns upgradeInfoFlow
}
fakeOnePodMode = FakeDataStoreValue(false)
fakeAutoPlay = FakeDataStoreValue(false)
fakeAutoPause = FakeDataStoreValue(false)
fakeAutoConnect = FakeDataStoreValue(false)
fakeAutoConnectCondition = FakeDataStoreValue(AutoConnectCondition.WHEN_SEEN)
fakeShowPopUpOnCaseOpen = FakeDataStoreValue(false)
fakeShowPopUpOnConnection = FakeDataStoreValue(false)
reactionSettings = mockk<ReactionSettings>().also {
every { it.onePodMode } returns fakeOnePodMode.mock
every { it.autoPlay } returns fakeAutoPlay.mock
every { it.autoPause } returns fakeAutoPause.mock
every { it.autoConnect } returns fakeAutoConnect.mock
every { it.autoConnectCondition } returns fakeAutoConnectCondition.mock
every { it.showPopUpOnCaseOpen } returns fakeShowPopUpOnCaseOpen.mock
every { it.showPopUpOnConnection } returns fakeShowPopUpOnConnection.mock
}
fakeMonitorMode = FakeDataStoreValue(MonitorMode.AUTOMATIC)
generalSettings = mockk<GeneralSettings>().also {
every { it.monitorMode } returns fakeMonitorMode.mock
}
}
@AfterEach
fun teardown() {
Dispatchers.resetMain()
}
private fun createViewModel() = ReactionSettingsViewModel(
dispatcherProvider = TestDispatcherProvider(testDispatcher),
reactionSettings = reactionSettings,
generalSettings = generalSettings,
upgradeRepo = upgradeRepo,
)
private fun mockUpgradeInfo(isPro: Boolean): UpgradeRepo.Info = mockk<UpgradeRepo.Info>().also {
every { it.isPro } returns isPro
}
@Nested
inner class StateTests {
@Test
fun `state combines all flows correctly`() = runTest(testDispatcher) {
fakeAutoPlay.value = true
fakeAutoPause.value = true
fakeAutoConnect.value = true
fakeOnePodMode.value = true
fakeShowPopUpOnCaseOpen.value = true
fakeShowPopUpOnConnection.value = true
fakeAutoConnectCondition.value = AutoConnectCondition.CASE_OPEN
upgradeInfoFlow.value = mockUpgradeInfo(isPro = true)
val vm = createViewModel()
val state = vm.state.first()
state.isPro shouldBe true
state.autoPlay shouldBe true
state.autoPause shouldBe true
state.autoConnect shouldBe true
state.onePodMode shouldBe true
state.showPopUpOnCaseOpen shouldBe true
state.showPopUpOnConnection shouldBe true
state.autoConnectCondition shouldBe AutoConnectCondition.CASE_OPEN
}
}
@Nested
inner class AutoPlayTests {
@Test
fun `setAutoPlay true when pro - sets setting`() = runTest(testDispatcher) {
upgradeInfoFlow.value = mockUpgradeInfo(isPro = true)
val vm = createViewModel()
vm.state.first() // ensure state is initialized
vm.setAutoPlay(true)
fakeAutoPlay.value shouldBe true
}
@Test
fun `setAutoPlay true when not pro - navigates to upgrade, does NOT change setting`() = runTest(testDispatcher) {
upgradeInfoFlow.value = mockUpgradeInfo(isPro = false)
val vm = createViewModel()
vm.state.first()
vm.setAutoPlay(true)
fakeAutoPlay.value shouldBe false
val event = vm.navEvents.first()
(event as NavEvent.GoTo).destination shouldBe Nav.Main.Upgrade
}
@Test
fun `setAutoPlay false - always sets (no pro check)`() = runTest(testDispatcher) {
fakeAutoPlay.value = true
val vm = createViewModel()
vm.state.first()
vm.setAutoPlay(false)
fakeAutoPlay.value shouldBe false
}
}
@Nested
inner class AutoPauseTests {
@Test
fun `setAutoPause true when pro - sets setting`() = runTest(testDispatcher) {
upgradeInfoFlow.value = mockUpgradeInfo(isPro = true)
val vm = createViewModel()
vm.state.first()
vm.setAutoPause(true)
fakeAutoPause.value shouldBe true
}
@Test
fun `setAutoPause true when not pro - navigates to upgrade`() = runTest(testDispatcher) {
upgradeInfoFlow.value = mockUpgradeInfo(isPro = false)
val vm = createViewModel()
vm.state.first()
vm.setAutoPause(true)
fakeAutoPause.value shouldBe false
val event = vm.navEvents.first()
(event as NavEvent.GoTo).destination shouldBe Nav.Main.Upgrade
}
@Test
fun `setAutoPause false - always sets`() = runTest(testDispatcher) {
fakeAutoPause.value = true
val vm = createViewModel()
vm.state.first()
vm.setAutoPause(false)
fakeAutoPause.value shouldBe false
}
}
@Nested
inner class AutoConnectTests {
@Test
fun `setAutoConnect true when pro - sets setting AND sets monitorMode to ALWAYS`() = runTest(testDispatcher) {
upgradeInfoFlow.value = mockUpgradeInfo(isPro = true)
val vm = createViewModel()
vm.state.first()
vm.setAutoConnect(true)
fakeAutoConnect.value shouldBe true
fakeMonitorMode.value shouldBe MonitorMode.ALWAYS
}
@Test
fun `setAutoConnect true when not pro - navigates to upgrade`() = runTest(testDispatcher) {
upgradeInfoFlow.value = mockUpgradeInfo(isPro = false)
val vm = createViewModel()
vm.state.first()
vm.setAutoConnect(true)
fakeAutoConnect.value shouldBe false
val event = vm.navEvents.first()
(event as NavEvent.GoTo).destination shouldBe Nav.Main.Upgrade
}
@Test
fun `setAutoConnect false - sets setting, does NOT change monitorMode`() = runTest(testDispatcher) {
fakeAutoConnect.value = true
fakeMonitorMode.value = MonitorMode.ALWAYS
val vm = createViewModel()
vm.state.first()
vm.setAutoConnect(false)
fakeAutoConnect.value shouldBe false
fakeMonitorMode.value shouldBe MonitorMode.ALWAYS
}
}
@Nested
inner class PopUpTests {
@Test
fun `setShowPopUpOnCaseOpen true when pro - sets setting`() = runTest(testDispatcher) {
upgradeInfoFlow.value = mockUpgradeInfo(isPro = true)
val vm = createViewModel()
vm.state.first()
vm.setShowPopUpOnCaseOpen(true)
fakeShowPopUpOnCaseOpen.value shouldBe true
}
@Test
fun `setShowPopUpOnCaseOpen true when not pro - navigates to upgrade`() = runTest(testDispatcher) {
upgradeInfoFlow.value = mockUpgradeInfo(isPro = false)
val vm = createViewModel()
vm.state.first()
vm.setShowPopUpOnCaseOpen(true)
fakeShowPopUpOnCaseOpen.value shouldBe false
val event = vm.navEvents.first()
(event as NavEvent.GoTo).destination shouldBe Nav.Main.Upgrade
}
@Test
fun `setShowPopUpOnConnection true when pro - sets setting`() = runTest(testDispatcher) {
upgradeInfoFlow.value = mockUpgradeInfo(isPro = true)
val vm = createViewModel()
vm.state.first()
vm.setShowPopUpOnConnection(true)
fakeShowPopUpOnConnection.value shouldBe true
}
@Test
fun `setShowPopUpOnConnection true when not pro - navigates to upgrade`() = runTest(testDispatcher) {
upgradeInfoFlow.value = mockUpgradeInfo(isPro = false)
val vm = createViewModel()
vm.state.first()
vm.setShowPopUpOnConnection(true)
fakeShowPopUpOnConnection.value shouldBe false
val event = vm.navEvents.first()
(event as NavEvent.GoTo).destination shouldBe Nav.Main.Upgrade
}
}
@Nested
inner class DirectSettingTests {
@Test
fun `setOnePodMode - direct assignment (no pro check)`() = runTest(testDispatcher) {
val vm = createViewModel()
vm.setOnePodMode(true)
fakeOnePodMode.value shouldBe true
}
@Test
fun `setAutoConnectCondition - direct assignment (no pro check)`() = runTest(testDispatcher) {
val vm = createViewModel()
vm.setAutoConnectCondition(AutoConnectCondition.IN_EAR)
fakeAutoConnectCondition.value shouldBe AutoConnectCondition.IN_EAR
}
}
}