Skip to content

Restore the session without decrypting its token - #1210

Merged
r0adkll merged 1 commit into
mainfrom
perf/restore-without-keystore
Oct 6, 2026
Merged

r0adkll merged 1 commit into
mainfrom
perf/restore-without-keystore

Conversation

@r0adkll

@r0adkll r0adkll commented Oct 6, 2026

Copy link
Copy Markdown
Owner

On a signed-in cold start, the session restore no longer reads (and decrypts) the stored token, so CampfireApplication.onCreate doesn't wait on the Android Keystore at all. It only checks that a token is stored.

Closes #1190

Where the issue stands after #1185

The issue's measurements (54 main-thread keystore2 calls, 115–132 ms on a Galaxy A51) were taken on builds based on eb7b2075, before #1185 landed. They came from the eager EncryptedSharedPreferences providers, which no longer exist. #1185 already covers most of the issue's suggestions:

  • the stores are built lazily, so creating them in DI makes no Keystore calls;
  • Hardcover's store is only opened when Hardcover is used;
  • every read goes through toSuspendSettings(io);
  • security-crypto is only used to read the legacy files.

On current main the main thread makes no Keystore calls during startup. It does still wait on them: StartupInitializer runs the restore under runBlocking, and DatabaseUserSessionRestorer called getToken(). That one call loads the Keystore key and decrypts both the access and refresh tokens on the IO dispatcher while the main thread blocks. The value was thrown away: the restore only checked that it wasn't null. The Ktor auth provider then decrypts the token again for the first request.

What changed

  • TokenStorage.has(userId) (SecureTokenStorage: hasKey on the access-token key). It reads nothing, so it costs no decryption.
  • DatabaseUserSessionRestorer takes TokenStorage instead of AccountManager and uses has() to choose between LoggedIn and NeedsAuthentication.

Behavior change in one edge case: a token that is stored but can no longer be decrypted (its Keystore key is gone) used to send the restore straight to re-authentication. Now the restore reports LoggedIn. The first request then goes out without a token and gets a 401. The refresh is also rejected with a 401 (ABS answers every refresh failure with 401), and invalidateAccount moves the session to re-authentication. On device, the app shows cached Home briefly, then the Reauthenticate screen with "'demo' was logged out and requires reauthentication". While the server is unreachable, the user keeps their cached and downloaded content until it comes back.

The first launch after upgrading from a pre-#1185 version still migrates the legacy store before answering has(). That's a one-time cost, and it has to finish before the restore can tell whether a token exists.

Verification

  • :data:account:impl:jvmTest: new DatabaseUserSessionRestorerTest (in-memory DB: signed out without a current user; signed out and currentUserId cleared when the account row is gone; LoggedIn with a stored token; NeedsAuthentication without one) and SecureTokenStorageTest (has / round trip). The restorer tests use a TokenStorage whose get throws. Changing the restorer back to get() != null fails both token tests.

  • :data:account:impl:compileTestKotlinIosSimulatorArm64, ./scripts/ktlint --check, and :app:desktop:compileKotlin :app:android:compileAlphaDebugKotlin :app:ios:compileKotlinIosSimulatorArm64 jvmTest test: all pass. I excluded :infra:audioplayer:engine-tests:test: it needs the native ffmpeg/libvlc libraries and a login to the personal test server, which was down, and this change doesn't touch it.

  • Perfetto, fossBenchmarkRelease on the testbed emulator (android-36, signed in to the throwaway server, fully AOT-compiled, 8 cold starts each). keystore2 binder transactions from the app process:

    main this PR
    on the main thread 0 0
    during the restore (UserComponent slice), on an IO thread 11 0
    during onCreate 11 0
    per launch, first 10 s 41 31

    The emulator's Keystore is software-backed, so those 11 calls cost only ~2.4 ms there and its timings were within noise.

  • DiStartupBenchmarks on the Galaxy A51 (SM-A515U, Android 13, the issue's device; fossBenchmarkRelease, fully AOT-compiled, 20 cold starts per run). The prefilled test server was down (Cloudflare 523), so the benchmark signed in to the testbed server through adb reverse, using -P overrides of the test credentials. Medians from the traces, IQR in brackets. Both columns ran on the charger, back to back:

    main this PR Δ
    process start → onCreate 409 ms (387–425) 407 ms (392–419) −2
    onCreate 173.7 ms (168–191) 141.7 ms (132–150) −32
    restore (UserComponent) 148.7 ms (141–163) 118.9 ms (111–128) −30
    onCreate → first frame 579 ms (575–614) 583 ms (570–608) +4
    cold start (android_startups) 1174.5 ms (1153–1208) 1139.5 ms (1108–1173) −35
    Macrobenchmark timeToInitialDisplayMs 1166.6 ms (n=15) 1140.3 ms (n=14) −26
    keystore2 calls during the restore 11 (28.9 ms) 0

    An earlier main run on battery agrees on onCreate (182.2 ms, 168–202), so the onCreate saving is clear. The phase after onCreate doesn't grow, so the decrypt that moved to the first request (on a background thread) doesn't stall the main thread later. The cold-start gain looks real but is within this device's run-to-run variation: the two main runs differed by 23 ms.

  • Device, edge case: on the testbed with root, the stored ciphertexts were overwritten with Base64 that doesn't authenticate, then the app was cold-started. This build: Home, then Reauthenticate (above). Same experiment on main: straight to re-authentication. Both builds then crash with IllegalArgumentException: Required value was null in KtorAudioBookShelfApi.getCurrentUser → requireServerUrl, with identical stacks. That crash is pre-existing and already in Crashlytics for 1.2.0 (issue 4318e55f), after a rejected refresh token. It needs its own issue; this PR neither causes nor fixes it.

Changelog

[Android] Slightly faster startup when signed in under Changed.

🤖 Generated with Claude Code

The startup session restore only needs to know whether a token is stored,
but it read and decrypted it, so the main thread (blocked in
StartupInitializer's runBlocking) waited on the Android Keystore. Check
for the token with TokenStorage.has instead.

Closes #1190
@github-actions

github-actions Bot commented Oct 6, 2026

Copy link
Copy Markdown
Messages
📖 This PR has been checked by Danger

Generated by 🚫 Danger Kotlin against 7f2dc46

@Rotom-Bot

Copy link
Copy Markdown
Collaborator

@github-actions

github-actions Bot commented Oct 6, 2026

Copy link
Copy Markdown

Android APK @ 1aa3b42

@r0adkll
r0adkll merged commit e52e0de into main Oct 6, 2026
7 checks passed
@r0adkll
r0adkll deleted the perf/restore-without-keystore branch October 6, 2026 20:27
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.

Move Android Keystore work off the main thread at startup

2 participants