From 3315ccccec4d6237281f63bf5097e206d0471d08 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 19 May 2026 19:00:34 +0000 Subject: [PATCH] feat(calendars): reminder settings (enable + lead time) and store tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #5 Configurable reminder lead time + #6 enable/disable toggle: - CalendarReminderPrefs is a device-level SharedPreferences wrapper with isEnabled / leadMinutes accessors. Device scope (rather than per-account) because the worker that consults it runs globally — per-account preferences would require account-context plumbing into WorkManager that the rest of the app doesn't have today. - CalendarReminderWorker now reads enabled + lead-minutes on each cycle. When disabled the worker short-circuits to Result.success() rather than cancelling itself — flipping the toggle back on takes effect immediately without a relaunch. - New CalendarReminderSettingsScreen: a Switch for enabled, a row of FilterChips for the 5/15/30/60-minute lead-time choices. The lead- time row disables when reminders are off. - New Route.CalendarReminderSettings wired through AppNavigation and surfaced as an entry in the existing AllSettingsScreen list under the notification settings divider. #7 Tests: - CalendarReminderPrefsTest exercises the prefs round-trip (defaults, set/get of enabled and lead-minutes) and the store contract (wasNotified false-by-default, true after markNotified, false again when start changes, and forgetBefore pruning). - Backed by an in-memory FakeSharedPreferences so the tests run on the JVM without Robolectric. mockk stubs the Context to hand back the fake prefs. https://claude.ai/code/session_01CbyrA2GdM4EQh8T6nsst6U --- .../service/calendar/CalendarReminderPrefs.kt | 65 +++++ .../calendar/CalendarReminderWorker.kt | 14 +- .../amethyst/ui/navigation/AppNavigation.kt | 2 + .../amethyst/ui/navigation/routes/Routes.kt | 2 + .../CalendarReminderSettingsScreen.kt | 147 +++++++++++ .../loggedIn/settings/AllSettingsScreen.kt | 6 + amethyst/src/main/res/values/strings.xml | 6 + .../calendar/CalendarReminderPrefsTest.kt | 237 ++++++++++++++++++ 8 files changed, 472 insertions(+), 7 deletions(-) create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/service/calendar/CalendarReminderPrefs.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/calendars/CalendarReminderSettingsScreen.kt create mode 100644 amethyst/src/test/java/com/vitorpamplona/amethyst/calendar/CalendarReminderPrefsTest.kt diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/calendar/CalendarReminderPrefs.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/calendar/CalendarReminderPrefs.kt new file mode 100644 index 0000000000..f3dda3056f --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/calendar/CalendarReminderPrefs.kt @@ -0,0 +1,65 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.service.calendar + +import android.content.Context +import android.content.SharedPreferences + +/** + * Device-wide preferences for the calendar reminder worker. + * + * Stored at device scope (rather than per-account) because the worker that consults them runs + * globally — multiplexing per-account preferences would require account-context plumbing into + * WorkManager that the rest of the app doesn't have. A user who flips between two accounts on + * the same device shares the same lead-time and enabled-state. Per-account preferences could be + * a follow-up if anyone asks. + */ +class CalendarReminderPrefs( + context: Context, +) { + private val prefs: SharedPreferences = context.getSharedPreferences(PREF_NAME, Context.MODE_PRIVATE) + + fun isEnabled(): Boolean = prefs.getBoolean(KEY_ENABLED, DEFAULT_ENABLED) + + fun setEnabled(enabled: Boolean) { + prefs.edit().putBoolean(KEY_ENABLED, enabled).apply() + } + + fun leadMinutes(): Int = prefs.getInt(KEY_LEAD_MINUTES, DEFAULT_LEAD_MINUTES) + + fun setLeadMinutes(minutes: Int) { + prefs.edit().putInt(KEY_LEAD_MINUTES, minutes).apply() + } + + companion object { + const val DEFAULT_LEAD_MINUTES = 15 + const val DEFAULT_ENABLED = true + + // Choices presented in the settings UI. Anchored to the worker cadence — lead times + // smaller than the cadence (15 min) can't be honoured reliably; 60 is the largest the + // UX shape supports without an extra "hours" picker. + val LEAD_TIME_CHOICES = listOf(5, 15, 30, 60) + + private const val PREF_NAME = "amethyst_calendar_reminder_prefs" + private const val KEY_ENABLED = "enabled" + private const val KEY_LEAD_MINUTES = "lead_minutes" + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/calendar/CalendarReminderWorker.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/calendar/CalendarReminderWorker.kt index 0ef9a6866c..39a75428a5 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/calendar/CalendarReminderWorker.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/calendar/CalendarReminderWorker.kt @@ -52,8 +52,13 @@ class CalendarReminderWorker( params: WorkerParameters, ) : CoroutineWorker(appContext, params) { override suspend fun doWork(): Result { + val prefs = CalendarReminderPrefs(applicationContext) + if (!prefs.isEnabled()) { + Log.d(TAG) { "Reminders disabled; skipping scan." } + return Result.success() + } val now = TimeUtils.now() - val windowEnd = now + LEAD_TIME_SECONDS + val windowEnd = now + prefs.leadMinutes() * 60L val store = CalendarReminderStore(applicationContext) // Walk every kind-31925 RSVP authored by an account on this device. We don't have a @@ -67,7 +72,7 @@ class CalendarReminderWorker( e is CalendarRSVPEvent && e.status() == RSVPStatusTag.STATUS.ACCEPTED }.mapNotNull { it.event as? CalendarRSVPEvent } - Log.d(TAG) { "Worker scanning ${acceptedRsvps.size} accepted RSVPs (now=$now, lead=${LEAD_TIME_SECONDS}s)" } + Log.d(TAG) { "Worker scanning ${acceptedRsvps.size} accepted RSVPs (now=$now, lead=${prefs.leadMinutes()}m)" } acceptedRsvps.forEach { rsvp -> val targetAddress = rsvp.calendarEventAddress() ?: return@forEach @@ -110,11 +115,6 @@ class CalendarReminderWorker( private const val TAG = "CalendarReminderWorker" private const val WORK_NAME = "calendar_reminder_worker" - // 15 minutes — the WorkManager periodic minimum is also 15 min, so the worst-case - // latency is one full cycle. Calendar apps typically use 5/10/15 min lead options; - // we hard-code 15 to match the worker cadence. - private const val LEAD_TIME_SECONDS = 15L * 60L - // Don't bother remembering "I notified for this" entries for events whose start was // more than a day ago; they can't fire again so the entry is pure overhead. private const val PRUNE_AGE_SECONDS = 24L * 60L * 60L diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt index 4d2adba44d..b83c525a6c 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt @@ -74,6 +74,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.bookmarkgroups.membershipMa import com.vitorpamplona.amethyst.ui.screen.loggedIn.bookmarkgroups.membershipManagement.PostBookmarkListManagementScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.bookmarkgroups.old.OldBookmarkListScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.calendars.CalendarCollectionsScreen +import com.vitorpamplona.amethyst.ui.screen.loggedIn.calendars.CalendarReminderSettingsScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.calendars.CalendarsScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.calendars.create.NewCalendarCollectionScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.calendars.create.NewCalendarEventScreen @@ -259,6 +260,7 @@ fun BuildNavigation( composableFromEnd { PicturesScreen(accountViewModel, nav) } composableFromEnd { CalendarsScreen(accountViewModel, nav) } composableFromEnd { CalendarCollectionsScreen(accountViewModel, nav) } + composableFromEnd { CalendarReminderSettingsScreen(nav) } composableFromEndArgs { CalendarEventDetailScreen(it.kind, it.pubKeyHex, it.dTag, accountViewModel, nav) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt index 752ad062bd..75358b6e63 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt @@ -282,6 +282,8 @@ sealed class Route { @Serializable object NotificationSettings : Route() + @Serializable object CalendarReminderSettings : Route() + @Serializable object Lists : Route() @Serializable data class MyPeopleListView( diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/calendars/CalendarReminderSettingsScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/calendars/CalendarReminderSettingsScreen.kt new file mode 100644 index 0000000000..543fc987d5 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/calendars/CalendarReminderSettingsScreen.kt @@ -0,0 +1,147 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.screen.loggedIn.calendars + +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.foundation.layout.size +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.FilterChip +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Switch +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.unit.dp +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.commons.icons.symbols.Icon +import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols +import com.vitorpamplona.amethyst.service.calendar.CalendarReminderPrefs +import com.vitorpamplona.amethyst.service.calendar.CalendarReminderWorker +import com.vitorpamplona.amethyst.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.ui.stringRes + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun CalendarReminderSettingsScreen(nav: INav) { + val context = LocalContext.current + val prefs = remember { CalendarReminderPrefs(context) } + var enabled by remember { mutableStateOf(prefs.isEnabled()) } + var leadMinutes by remember { mutableIntStateOf(prefs.leadMinutes()) } + + Scaffold( + topBar = { + TopAppBar( + title = { Text(stringRes(R.string.calendar_reminder_settings_title)) }, + navigationIcon = { + IconButton(onClick = { nav.popBack() }) { + Icon( + symbol = MaterialSymbols.AutoMirrored.ArrowBack, + contentDescription = stringRes(R.string.back), + modifier = Modifier.size(20.dp), + tint = MaterialTheme.colorScheme.onSurface, + ) + } + }, + ) + }, + ) { pad -> + Column( + modifier = + Modifier + .padding(pad) + .padding(horizontal = 16.dp, vertical = 12.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + ) { + Column(modifier = Modifier.weight(1f)) { + Text( + text = stringRes(R.string.calendar_reminder_settings_enabled_title), + style = MaterialTheme.typography.titleSmall, + ) + Text( + text = stringRes(R.string.calendar_reminder_settings_enabled_subtitle), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + Switch( + checked = enabled, + onCheckedChange = { + enabled = it + prefs.setEnabled(it) + // Toggling off doesn't cancel the worker — the worker itself short- + // circuits when isEnabled() returns false. Keeping the schedule alive + // means flipping it back on takes effect immediately without needing a + // re-launch via AppModules. + if (it) CalendarReminderWorker.schedule(context) + }, + ) + } + + Text( + text = stringRes(R.string.calendar_reminder_settings_lead_title), + style = MaterialTheme.typography.titleSmall, + modifier = Modifier.padding(top = 8.dp), + ) + Text( + text = stringRes(R.string.calendar_reminder_settings_lead_subtitle), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + CalendarReminderPrefs.LEAD_TIME_CHOICES.forEach { choice -> + FilterChip( + selected = choice == leadMinutes, + onClick = { + leadMinutes = choice + prefs.setLeadMinutes(choice) + }, + enabled = enabled, + label = { + Text(stringRes(R.string.calendar_reminder_settings_lead_choice, choice)) + }, + ) + } + } + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/AllSettingsScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/AllSettingsScreen.kt index 7fb17f1bee..4988276dde 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/AllSettingsScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/AllSettingsScreen.kt @@ -224,6 +224,12 @@ fun AllSettingsScreen( onClick = { nav.nav(Route.NotificationSettings) }, ) SettingsDivider() + SettingsItem( + title = R.string.calendar_reminder_settings_title, + icon = MaterialSymbols.CalendarMonth, + onClick = { nav.nav(Route.CalendarReminderSettings) }, + ) + SettingsDivider() SettingsItem( title = R.string.compose_settings, icon = MaterialSymbols.Edit, diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index adb9bf30e7..6629065fd6 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -1999,6 +1999,12 @@ npub or hex pubkey Enter a valid npub… or 64-character hex pubkey. Remove participant + Calendar reminders + Send reminders + A notification fires when an event you\'re attending is about to start. + Reminder lead time + How many minutes before the event you want to be notified. + %1$d min Open in maps Event details New Short Video diff --git a/amethyst/src/test/java/com/vitorpamplona/amethyst/calendar/CalendarReminderPrefsTest.kt b/amethyst/src/test/java/com/vitorpamplona/amethyst/calendar/CalendarReminderPrefsTest.kt new file mode 100644 index 0000000000..56006280bd --- /dev/null +++ b/amethyst/src/test/java/com/vitorpamplona/amethyst/calendar/CalendarReminderPrefsTest.kt @@ -0,0 +1,237 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.calendar + +import android.content.Context +import android.content.SharedPreferences +import com.vitorpamplona.amethyst.service.calendar.CalendarReminderPrefs +import com.vitorpamplona.amethyst.service.calendar.CalendarReminderStore +import io.mockk.every +import io.mockk.mockk +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test + +/** + * Unit tests for the device-level reminder preferences and the per-event "already notified" + * store. Backed by an in-memory fake [SharedPreferences] so the test runs on the JVM without + * needing Robolectric. + */ +class CalendarReminderPrefsTest { + private lateinit var fakePrefs: FakeSharedPreferences + private lateinit var ctx: Context + + @Before + fun setUp() { + fakePrefs = FakeSharedPreferences() + ctx = mockk() + every { ctx.getSharedPreferences(any(), any()) } returns fakePrefs + } + + @Test + fun prefs_defaultsMatchPublicConstants() { + val prefs = CalendarReminderPrefs(ctx) + // Defaults are the contract callers in AppModules rely on — flipping these without an + // explicit migration would silently re-enable reminders for users who had turned them + // off (or vice versa). + assertEquals(CalendarReminderPrefs.DEFAULT_ENABLED, prefs.isEnabled()) + assertEquals(CalendarReminderPrefs.DEFAULT_LEAD_MINUTES, prefs.leadMinutes()) + } + + @Test + fun prefs_setEnabled_roundTrips() { + val prefs = CalendarReminderPrefs(ctx) + prefs.setEnabled(false) + assertFalse(prefs.isEnabled()) + prefs.setEnabled(true) + assertTrue(prefs.isEnabled()) + } + + @Test + fun prefs_setLeadMinutes_roundTrips() { + val prefs = CalendarReminderPrefs(ctx) + prefs.setLeadMinutes(30) + assertEquals(30, prefs.leadMinutes()) + } + + @Test + fun store_wasNotified_isFalseByDefault() { + val store = CalendarReminderStore(ctx) + assertFalse(store.wasNotified("event-a", 1_000_000L)) + } + + @Test + fun store_markNotified_makesWasNotifiedTrueForSameStart() { + val store = CalendarReminderStore(ctx) + store.markNotified("event-a", 1_000_000L) + assertTrue(store.wasNotified("event-a", 1_000_000L)) + } + + @Test + fun store_wasNotified_isFalseWhenStartChanges() { + // Regression test for the "moved meeting" case: if the author updates the appointment + // with a new start, the store should not silently swallow the new reminder. + val store = CalendarReminderStore(ctx) + store.markNotified("event-a", 1_000_000L) + assertFalse(store.wasNotified("event-a", 2_000_000L)) + } + + @Test + fun store_forgetBefore_dropsOldEntries() { + val store = CalendarReminderStore(ctx) + store.markNotified("old", 1_000_000L) + store.markNotified("recent", 5_000_000L) + store.forgetBefore(3_000_000L) + assertFalse(store.wasNotified("old", 1_000_000L)) + assertTrue(store.wasNotified("recent", 5_000_000L)) + } +} + +/** + * Bare-bones in-memory implementation of [SharedPreferences] sufficient for the prefs/store + * round-trip tests. apply() is synchronous here — fine because the production code never relies + * on apply()'s async semantics. + */ +private class FakeSharedPreferences : SharedPreferences { + private val data = mutableMapOf() + + override fun getAll(): MutableMap = data + + override fun getString( + key: String, + defValue: String?, + ): String? = data[key] as? String ?: defValue + + override fun getStringSet( + key: String, + defValues: MutableSet?, + ): MutableSet? { + @Suppress("UNCHECKED_CAST") + return data[key] as? MutableSet ?: defValues + } + + override fun getInt( + key: String, + defValue: Int, + ): Int = (data[key] as? Int) ?: defValue + + override fun getLong( + key: String, + defValue: Long, + ): Long = (data[key] as? Long) ?: defValue + + override fun getFloat( + key: String, + defValue: Float, + ): Float = (data[key] as? Float) ?: defValue + + override fun getBoolean( + key: String, + defValue: Boolean, + ): Boolean = (data[key] as? Boolean) ?: defValue + + override fun contains(key: String): Boolean = data.containsKey(key) + + override fun edit(): SharedPreferences.Editor = FakeEditor(data) + + override fun registerOnSharedPreferenceChangeListener(listener: SharedPreferences.OnSharedPreferenceChangeListener?) = Unit + + override fun unregisterOnSharedPreferenceChangeListener(listener: SharedPreferences.OnSharedPreferenceChangeListener?) = Unit +} + +private class FakeEditor( + private val data: MutableMap, +) : SharedPreferences.Editor { + private val pending = mutableMapOf() + private val removed = mutableSetOf() + private var clearAll = false + + override fun putString( + key: String, + value: String?, + ): SharedPreferences.Editor { + pending[key] = value + return this + } + + override fun putStringSet( + key: String, + values: MutableSet?, + ): SharedPreferences.Editor { + pending[key] = values + return this + } + + override fun putInt( + key: String, + value: Int, + ): SharedPreferences.Editor { + pending[key] = value + return this + } + + override fun putLong( + key: String, + value: Long, + ): SharedPreferences.Editor { + pending[key] = value + return this + } + + override fun putFloat( + key: String, + value: Float, + ): SharedPreferences.Editor { + pending[key] = value + return this + } + + override fun putBoolean( + key: String, + value: Boolean, + ): SharedPreferences.Editor { + pending[key] = value + return this + } + + override fun remove(key: String): SharedPreferences.Editor { + removed.add(key) + return this + } + + override fun clear(): SharedPreferences.Editor { + clearAll = true + return this + } + + override fun commit(): Boolean { + apply() + return true + } + + override fun apply() { + if (clearAll) data.clear() + removed.forEach { data.remove(it) } + data.putAll(pending) + } +}