mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-08-11 00:37:41 +00:00
feat(calendars): reminder settings (enable + lead time) and store tests
#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
This commit is contained in:
+65
@@ -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"
|
||||
}
|
||||
}
|
||||
+7
-7
@@ -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
|
||||
|
||||
@@ -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<Route.Pictures> { PicturesScreen(accountViewModel, nav) }
|
||||
composableFromEnd<Route.Calendars> { CalendarsScreen(accountViewModel, nav) }
|
||||
composableFromEnd<Route.CalendarCollections> { CalendarCollectionsScreen(accountViewModel, nav) }
|
||||
composableFromEnd<Route.CalendarReminderSettings> { CalendarReminderSettingsScreen(nav) }
|
||||
composableFromEndArgs<Route.CalendarEventDetail> {
|
||||
CalendarEventDetailScreen(it.kind, it.pubKeyHex, it.dTag, accountViewModel, nav)
|
||||
}
|
||||
|
||||
@@ -282,6 +282,8 @@ sealed class Route {
|
||||
|
||||
@Serializable object NotificationSettings : Route()
|
||||
|
||||
@Serializable object CalendarReminderSettings : Route()
|
||||
|
||||
@Serializable object Lists : Route()
|
||||
|
||||
@Serializable data class MyPeopleListView(
|
||||
|
||||
+147
@@ -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))
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+6
@@ -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,
|
||||
|
||||
@@ -1999,6 +1999,12 @@
|
||||
<string name="calendar_event_participant_input">npub or hex pubkey</string>
|
||||
<string name="calendar_event_participant_invalid">Enter a valid npub… or 64-character hex pubkey.</string>
|
||||
<string name="calendar_event_participant_remove">Remove participant</string>
|
||||
<string name="calendar_reminder_settings_title">Calendar reminders</string>
|
||||
<string name="calendar_reminder_settings_enabled_title">Send reminders</string>
|
||||
<string name="calendar_reminder_settings_enabled_subtitle">A notification fires when an event you\'re attending is about to start.</string>
|
||||
<string name="calendar_reminder_settings_lead_title">Reminder lead time</string>
|
||||
<string name="calendar_reminder_settings_lead_subtitle">How many minutes before the event you want to be notified.</string>
|
||||
<string name="calendar_reminder_settings_lead_choice">%1$d min</string>
|
||||
<string name="calendar_open_in_maps">Open in maps</string>
|
||||
<string name="route_calendar_event_detail">Event details</string>
|
||||
<string name="new_short_video">New Short Video</string>
|
||||
|
||||
+237
@@ -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<String, Any?>()
|
||||
|
||||
override fun getAll(): MutableMap<String, *> = data
|
||||
|
||||
override fun getString(
|
||||
key: String,
|
||||
defValue: String?,
|
||||
): String? = data[key] as? String ?: defValue
|
||||
|
||||
override fun getStringSet(
|
||||
key: String,
|
||||
defValues: MutableSet<String>?,
|
||||
): MutableSet<String>? {
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
return data[key] as? MutableSet<String> ?: 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<String, Any?>,
|
||||
) : SharedPreferences.Editor {
|
||||
private val pending = mutableMapOf<String, Any?>()
|
||||
private val removed = mutableSetOf<String>()
|
||||
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<String>?,
|
||||
): 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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user