Skip to content

Store settings in DataStore - #1192

Closed
r0adkll wants to merge 3 commits into
refactor/split-campfire-settingsfrom
feat/settings-datastore
Closed

r0adkll wants to merge 3 commits into
refactor/split-campfire-settingsfrom
feat/settings-datastore

Conversation

@r0adkll

@r0adkll r0adkll commented Oct 3, 2026

Copy link
Copy Markdown
Owner

Second step of #607, stacked on #1189. Settings move from platform preferences (SharedPreferences / java.util.prefs / NSUserDefaults) to a Preferences DataStore. Nothing above the storage layer changes: the AppSettings delegates, the var + observeX() API and every call site are untouched.

How it works

  • DataStoreSettings is an ObservableSettings backed by DataStore, so the existing delegates keep working. The settings implementations only add a @SettingsStore qualifier to the store they inject.

    • Reads come from the file loaded into memory, so they stay synchronous.
    • A write updates memory and that key's listeners right away, then saves in the background. This class is the only writer, so a save writes the latest full snapshot: writes that arrive while one is saving collapse into the next, and they can't land out of order.
    • multiplatform-settings-datastore only offers a suspend FlowSettings, which can't back synchronous getters, so this wraps DataStore directly.
  • Loading: SettingsLoader loads the file once at startup.

    • StartupInitializer starts the read before it creates the user initializer, whose dependencies are most of the main thread's startup work.
    • It then waits for the read right before the session restore. Reading a setting before that throws.
  • Migration: LegacySettingsMigration copies every settings key out of the old preferences on first launch.

    • It works from a table of key names grouped by type, because DataStore keys are typed and desktop's java.util.prefs stores everything as text.
    • The old values stay in place for a release, so a downgrade or an older backup still works.
  • Files: a single settings.preferences_pb.

    Platform Location
    Android files/datastore/
    Desktop ~/.config/Campfire/datastore/
    iOS Application Support
  • Android backup: the new file is in data_extraction_rules.xml, and the old prefs entry stays so restoring an older backup still migrates. After a restore, SettingsBackupAgent also clears the device-bound keys from the new file.

  • Scope: other modules on SharedPreferences (discover scan, bookinfo, library reconciler, account restore snapshot) are unchanged; their per-user keys move with the per-user stores in the next step.

Benchmark tooling

  • :features:settings:benchmark: a Jetpack Microbenchmark of loading the store with and without migrating. Run it with ./gradlew :features:settings:benchmark:connectedReleaseAndroidTest.
  • app.campfire.android.benchmark convention: sets up such modules. It adds the androidx.benchmark Gradle plugin and benchmark-junit4, both 1.5.0, matching the existing macrobenchmark version.
  • Startup trace section: startup:loadSettings (in StartupTraceSections) is recorded by DiStartupBenchmarks.

Verification

  • Unit tests:
    • DataStoreSettingsTest (10) and DataStoreSettingsFileTest (2, real files on the JVM).
    • LegacySettingsMigrationTest (6).
    • All existing settings tests pass. ./gradlew jvmTest test passes except infra/audioplayer/engine-tests, which fails locally because the FFmpeg native library doesn't load (no jniavutil); this PR doesn't touch anything those tests use.
  • Builds: :app:desktop:compileKotlin and :app:android:compileAlphaDebugKotlin compile, and the touched modules compile for the iOS simulator.
  • Upgrade on an emulator (testbed server):

Performance

Galaxy A51 (SM-A515U, Android 13), signed-in cold starts to Home, fully AOT-compiled, 20 launches per run. Numbers come from all traces (Perfetto android_startups), because Macrobenchmark dropped iterations from its own startup metric on this device.

Settings load on the main thread:

Median Load blocks the main thread (run 1) Load starts early (run 1) Load blocks the main thread (run 2) Load starts early (run 2)
startup:loadSettings (main-thread wait) 12.8 ms 0.50 ms 13.3 ms 0.53 ms
CampfireApplication.onCreate 324 ms 322 ms 343 ms 318 ms
  • When the load blocked, the main thread slept for about 11 of the 12.6 ms, waiting on the background read.
  • Starting the read early hides it behind the creation of the session-restore dependencies.
  • Whole cold start varies ±40 ms between runs on this phone, more than this change, so it can't show the difference.

Migration (runs once, on the first launch after updating):

  • Phone, microbenchmark (warm): load 4.2 ms median, load + migration 10.1 ms.
  • Emulator, cold start: the migration added +2.9 ms median to the settings load, with nothing else changing beyond noise.

Follow-ups

  • Move Android Keystore work off the main thread at startup #1190: the same traces show 115–132 ms of main-thread Keystore calls before the session restore, with or without this PR. That's about 10× the settings load.
  • Baseline profile: regenerate it so the DataStore, okio and protobuf classes are precompiled for real installs. The benchmark compiles everything ahead of time; real installs don't.
  • F-Droid scan: this adds a dependency (androidx.datastore:datastore-preferences-core) and a Gradle plugin (androidx.benchmark). Both are Apache-licensed from Google Maven, but I haven't run the F-Droid scan check.
  • Next step of Break-up the CampfireSettings monolith into appScope/userScope layers #607: per-user settings stores, and removing the old values and their migration one release after this ships.

No changelog entry: nothing should change for users.

🤖 Generated with Claude Code

The settings implementations now read and write a Preferences DataStore
through DataStoreSettings, an ObservableSettings that serves reads from
the loaded file and saves writes in the background. Writes that arrive
while one is saving are folded into the next, so drags no longer write
on every frame. StartupInitializer loads the file before anything reads
a setting, inside a startup:loadSettings trace section the startup
benchmark records.

On first launch LegacySettingsMigration copies every settings key out of
the old platform preferences, typed from a key table because desktop's
java.util.prefs keeps everything as text. The old values stay for a
release so a downgrade or an older backup still works.

On Android the new file is backed up, and the backup agent also clears
the device-bound keys from it after a restore (#607).
Adds :features:settings:benchmark, a Jetpack Microbenchmark module that
times opening and reading the settings store the way startup does, with
and without migrating the old SharedPreferences settings. The new
app.campfire.android.benchmark convention sets up such modules: a
release, non-debuggable test build run by AndroidBenchmarkRunner.

  ./gradlew :features:settings:benchmark:connectedReleaseAndroidTest
StartupInitializer now starts the settings read before it creates the
user initializer, and only waits for it right before the session
restore. Creating the user initializer's dependencies is most of the
main thread's startup work, so the read finishes behind it instead of
blocking: on a Galaxy A51 the main thread sat idle for ~11ms of the
~12.6ms load.

SettingsLoader gains startLoading(); load() awaits the same read. The
startup:loadSettings trace section now covers only the wait.
@github-actions

github-actions Bot commented Oct 3, 2026

Copy link
Copy Markdown
Warnings
⚠️

Any changes to library code should be reflected in the Changelog.

Please consider adding a note there and adhere to the Changelog Guidelines.

⚠️ Big PR, try to keep changes smaller if you can
⚠️ This PR is original Xbox Huge! Consider breaking into smaller PRs
Messages
📖 This PR has been checked by Danger

Generated by 🚫 Danger Kotlin against 9af0dd2

@Rotom-Bot

Copy link
Copy Markdown
Collaborator

Code Coverage

File Coverage [47.01%] ❌
app/common/src/commonMain/kotlin/app/campfire/common/initializer/StartupInitializer.kt 0.00% ❌
features/settings/impl/src/androidMain/kotlin/app/campfire/settings/PreferencesPlatformComponent.kt 0.00% ❌
features/settings/impl/src/commonMain/kotlin/app/campfire/settings/AndroidAutoSettingsImpl.kt 0.00% ❌
features/settings/impl/src/commonMain/kotlin/app/campfire/settings/AppStateSettingsImpl.kt 0.00% ❌
features/settings/impl/src/commonMain/kotlin/app/campfire/settings/AudioOutputSettingsImpl.kt 0.00% ❌
features/settings/impl/src/commonMain/kotlin/app/campfire/settings/ConnectionSettingsImpl.kt 0.00% ❌
features/settings/impl/src/commonMain/kotlin/app/campfire/settings/DataStoreSettings.kt 81.33% ✅
features/settings/impl/src/commonMain/kotlin/app/campfire/settings/DevSettingsImpl.kt 0.00% ❌
features/settings/impl/src/commonMain/kotlin/app/campfire/settings/DeviceSettingsImpl.kt 0.00% ❌
features/settings/impl/src/commonMain/kotlin/app/campfire/settings/EqualizerSettingsImpl.kt 91.67% ✅
features/settings/impl/src/commonMain/kotlin/app/campfire/settings/LayoutSettingsImpl.kt 0.00% ❌
features/settings/impl/src/commonMain/kotlin/app/campfire/settings/LegacySettingsMigration.kt 100.00% ✅
features/settings/impl/src/commonMain/kotlin/app/campfire/settings/LibraryViewSettingsImpl.kt 0.00% ❌
features/settings/impl/src/commonMain/kotlin/app/campfire/settings/MobileDataSettingsImpl.kt 90.91% ✅
features/settings/impl/src/commonMain/kotlin/app/campfire/settings/PlaybackSettingsImpl.kt 77.12% ❌
features/settings/impl/src/commonMain/kotlin/app/campfire/settings/PrivacySettingsImpl.kt 0.00% ❌
features/settings/impl/src/commonMain/kotlin/app/campfire/settings/SettingsDataStoreComponent.kt 0.00% ❌
features/settings/impl/src/commonMain/kotlin/app/campfire/settings/SleepSettingsImpl.kt 0.00% ❌
features/settings/impl/src/commonMain/kotlin/app/campfire/settings/ThemeSettingsImpl.kt 76.47% ❌
features/settings/impl/src/iosMain/kotlin/app/campfire/settings/PreferencesPlatformComponent.kt 0.00% ❌
features/settings/impl/src/jvmMain/kotlin/app/campfire/settings/PreferencesPlatformComponent.kt 0.00% ❌
Total Project Coverage 23.57% ❌

@github-actions

github-actions Bot commented Oct 3, 2026

Copy link
Copy Markdown

Android APK @ e5122f9

@r0adkll

r0adkll commented Oct 4, 2026

Copy link
Copy Markdown
Owner Author

Superseded by #1199. The settings API became flow-only first (#1195, #1196, #1197), so DataStore now sits directly under the settings without the synchronous snapshot this PR needed. The migration key table, backup handling and file locations carried over.

@r0adkll r0adkll closed this Oct 4, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants