Wave 11 (M-authexpiry): refresh auth token every sync; recover rows lost to the expired-token sync

Committed by tasks/wave-chain.sh after its mechanical gate passed
(assembleDebug, testDebugUnitTest = 355 tests, verifyPaparazziDebug, no build
files touched, no "always 'false'"). ORCHESTRATOR REVIEW STILL PENDING.
Prompt: tasks/M-authexpiry.txt. Worker: $4.886013000000001, 123 turns.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Spriteandclaude committed 2026-09-17 02:54:41 +00:00
1 parent 81f63bffc0
commit 009cb9a7cc
30 files changed
+687 -17

No files matched your search

@@ -3,6 +3,7 @@ package org.modg.bookshelf.data.prefs
import android.content.Context import android.content.Context
import androidx.datastore.core.DataStore import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.Preferences import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.booleanPreferencesKey
import androidx.datastore.preferences.core.edit import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.longPreferencesKey import androidx.datastore.preferences.core.longPreferencesKey
import androidx.datastore.preferences.core.stringPreferencesKey import androidx.datastore.preferences.core.stringPreferencesKey
@@ -27,6 +28,10 @@ class SettingsStore(private val context: Context) {
val USER_EMAIL = stringPreferencesKey("user_email") val USER_EMAIL = stringPreferencesKey("user_email")
val LAST_SYNC_TIME = longPreferencesKey("last_sync_time") val LAST_SYNC_TIME = longPreferencesKey("last_sync_time")
val LAST_SHELF_ID = stringPreferencesKey("last_shelf_id") val LAST_SHELF_ID = stringPreferencesKey("last_shelf_id")
// Named for the incident, not the feature: a generic "did the full repull
// run" flag would invite reuse for some future, unrelated repull with
// different safety requirements. See SyncEngine.sync().
val FULL_REPULL_2026_09_17_DONE = booleanPreferencesKey("full_repull_2026_09_17_done")
fun cursor(collection: String) = stringPreferencesKey("cursor_$collection") fun cursor(collection: String) = stringPreferencesKey("cursor_$collection")
} }
@@ -36,6 +41,9 @@ class SettingsStore(private val context: Context) {
val userEmail: Flow<String?> = context.dataStore.data.map { it[Keys.USER_EMAIL] } val userEmail: Flow<String?> = context.dataStore.data.map { it[Keys.USER_EMAIL] }
val lastSyncTime: Flow<Long?> = context.dataStore.data.map { it[Keys.LAST_SYNC_TIME] } val lastSyncTime: Flow<Long?> = context.dataStore.data.map { it[Keys.LAST_SYNC_TIME] }
/** See [Keys.FULL_REPULL_2026_09_17_DONE] and [setFullRepullDone]. */
val fullRepullDone: Flow<Boolean> = context.dataStore.data.map { it[Keys.FULL_REPULL_2026_09_17_DONE] ?: false }
/** /**
* The shelf most recently assigned to a book — SPEC "remember the most recently * The shelf most recently assigned to a book — SPEC "remember the most recently
* used shelf" (shelving a box of books usually means one shelf, over and over). * used shelf" (shelving a box of books usually means one shelf, over and over).
@@ -66,10 +74,30 @@ class SettingsStore(private val context: Context) {
context.dataStore.edit { it[Keys.cursor(collection)] = cursor } context.dataStore.edit { it[Keys.cursor(collection)] = cursor }
} }
/** Forces the next sync's pull to fetch that collection from scratch. */
suspend fun clearCursor(collection: String) {
context.dataStore.edit { it.remove(Keys.cursor(collection)) }
}
suspend fun setLastSyncTime(epochMillis: Long) { suspend fun setLastSyncTime(epochMillis: Long) {
context.dataStore.edit { it[Keys.LAST_SYNC_TIME] = epochMillis } context.dataStore.edit { it[Keys.LAST_SYNC_TIME] = epochMillis }
} }
/** One-way: production code never needs to un-set this once a full re-pull has landed. */
suspend fun setFullRepullDone() {
context.dataStore.edit { it[Keys.FULL_REPULL_2026_09_17_DONE] = true }
}
/**
* Test-only seam (internal, not private) — DataStore's on-disk state
* otherwise leaks across Robolectric test methods within a class; see
* [org.modg.bookshelf.data.prefs.SettingsStoreTest]'s [clearLastShelfId]
* call in its own setUp for the same reason.
*/
internal suspend fun resetFullRepullDoneForTesting() {
context.dataStore.edit { it.remove(Keys.FULL_REPULL_2026_09_17_DONE) }
}
suspend fun setLastShelfId(shelfId: String) { suspend fun setLastShelfId(shelfId: String) {
context.dataStore.edit { it[Keys.LAST_SHELF_ID] = shelfId } context.dataStore.edit { it[Keys.LAST_SHELF_ID] = shelfId }
} }
@@ -92,6 +120,18 @@ class SettingsStore(private val context: Context) {
} }
} }
/**
* Auth EXPIRY, not sign-out: the 2026-09-17 incident (server logged
* `auth: ""` on every request — a 5-day-old token PocketBase silently
* treated as anonymous rather than rejecting) means the token can go bad
* on its own. Unlike [clearAuth], this keeps server URL, user id AND
* email so the setup screen can pre-fill everything but the password —
* re-signing-in after an expiry should be one field, not three.
*/
suspend fun clearAuthToken() {
context.dataStore.edit { it.remove(Keys.AUTH_TOKEN) }
}
companion object { companion object {
const val COLLECTION_BOOKS = "books" const val COLLECTION_BOOKS = "books"
const val COLLECTION_SHELVES = "shelves" const val COLLECTION_SHELVES = "shelves"
@@ -20,6 +20,19 @@ interface PocketBaseApi {
@POST("api/collections/users/auth-with-password") @POST("api/collections/users/auth-with-password")
suspend fun authWithPassword(@Body body: AuthWithPasswordRequest): AuthResponse suspend fun authWithPassword(@Body body: AuthWithPasswordRequest): AuthResponse
/**
* Re-issues a fresh token for the currently-attached one (see
* [PbAuthInterceptor]) — same response shape as [authWithPassword]. Called
* at the start of every [org.modg.bookshelf.data.repo.SyncEngine.sync]: a
* PocketBase user token that has quietly expired is NOT rejected by the
* server, it's just treated as anonymous, and an anonymous request 404s
* on records the rules hide instead of 401ing — see
* [org.modg.bookshelf.data.repo.SyncEngine]'s 404 hard-delete helpers for
* the 2026-09-17 incident that caused.
*/
@POST("api/collections/users/auth-refresh")
suspend fun authRefresh(): AuthResponse
// ---- books ---- // ---- books ----
@GET("api/collections/books/records") @GET("api/collections/books/records")
@@ -18,6 +18,9 @@ class AuthRepository(
val isLoggedIn: Flow<Boolean> = settingsStore.authToken.map { !it.isNullOrBlank() } val isLoggedIn: Flow<Boolean> = settingsStore.authToken.map { !it.isNullOrBlank() }
val serverUrl: Flow<String?> = settingsStore.serverUrl val serverUrl: Flow<String?> = settingsStore.serverUrl
/** Read by the setup screen to pre-fill re-sign-in after an auth expiry (SettingsStore.clearAuthToken keeps this). */
val userEmail: Flow<String?> = settingsStore.userEmail
/** Normalizes per SPEC: requires https, strips a trailing slash. */ /** Normalizes per SPEC: requires https, strips a trailing slash. */
suspend fun setServerUrl(rawUrl: String): Result<Unit> { suspend fun setServerUrl(rawUrl: String): Result<Unit> {
val trimmed = rawUrl.trim().trimEnd('/') val trimmed = rawUrl.trim().trimEnd('/')
@@ -36,6 +39,12 @@ class AuthRepository(
settingsStore.setAuthToken(response.token) settingsStore.setAuthToken(response.token)
settingsStore.setUserId(response.record.id) settingsStore.setUserId(response.record.id)
settingsStore.setUserEmail(email) settingsStore.setUserEmail(email)
// Every fresh sign-in reconciles fully, not just the one after the
// 2026-09-17 incident's stuck cursors — the same backstop for any
// future cause we haven't thought of yet.
settingsStore.clearCursor(SettingsStore.COLLECTION_BOOKCASES)
settingsStore.clearCursor(SettingsStore.COLLECTION_SHELVES)
settingsStore.clearCursor(SettingsStore.COLLECTION_BOOKS)
Result.success(Unit) Result.success(Unit)
} catch (e: Exception) { } catch (e: Exception) {
Result.failure(e) Result.failure(e)
@@ -1,5 +1,6 @@
package org.modg.bookshelf.data.repo package org.modg.bookshelf.data.repo
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.first
import okhttp3.MultipartBody import okhttp3.MultipartBody
import okhttp3.RequestBody.Companion.asRequestBody import okhttp3.RequestBody.Companion.asRequestBody
@@ -26,6 +27,13 @@ sealed class SyncResult {
object Success : SyncResult() object Success : SyncResult()
object Skipped : SyncResult() object Skipped : SyncResult()
data class Failure(val error: Throwable) : SyncResult() data class Failure(val error: Throwable) : SyncResult()
/**
* The session is gone (auth-refresh, or any push/pull mid-sync, came back
* 401/403). Distinct from [Failure]: the fix isn't "retry later", it's
* "sign in again" — see the 2026-09-17 incident in SyncEngine's KDoc.
*/
object AuthExpired : SyncResult()
} }
/** /**
@@ -35,6 +43,18 @@ sealed class SyncResult {
* is bookcases -> shelves -> books both ways, so relations resolve cleanly * is bookcases -> shelves -> books both ways, so relations resolve cleanly
* even though Room does not enforce foreign keys between them (sync would * even though Room does not enforce foreign keys between them (sync would
* otherwise have to special-case out-of-order pages). * otherwise have to special-case out-of-order pages).
*
* 2026-09-17 incident: user auth tokens lasted PocketBase's default 5 days,
* the app logged in once on the setup screen and never refreshed, and
* PocketBase does NOT reject an expired/invalid `Authorization` header — it
* silently treats the request as anonymous. The `@request.auth.id != ""`
* collection rules then turned every write anonymous-404/400, and this
* class's own 404-means-deleted handling (see the update* helpers below)
* hard-deleted five books that were still intact on the server. Server
* tokens now last 180 days (commit ab1d294) as a backstop, but the real fix
* is here: refresh the token at the top of every sync, before any push/pull,
* so a stale token surfaces as [SyncResult.AuthExpired] instead of silently
* degrading into anonymous requests that 404 on records the rules are hiding.
*/ */
class SyncEngine( class SyncEngine(
private val apiProvider: ApiProvider, private val apiProvider: ApiProvider,
@@ -46,6 +66,25 @@ class SyncEngine(
suspend fun sync(): SyncResult { suspend fun sync(): SyncResult {
val api = apiProvider.api() ?: return SyncResult.Skipped val api = apiProvider.api() ?: return SyncResult.Skipped
return try { return try {
val refreshed = api.authRefresh()
settingsStore.setAuthToken(refreshed.token)
settingsStore.setUserId(refreshed.record.id)
// One-time recovery for the 2026-09-17 incident: rows hard-deleted
// locally by an anonymous-404 during the outage are still on the
// server, but the pull cursor has already moved past their
// `updated`, so an ordinary incremental pull will never see them
// again. Re-pulling everything once is safe — see
// applyIncomingBook/shouldApplyRemote below: a row missing
// locally is always inserted, and a locally-dirty row only ever
// loses to a strictly newer remote copy.
val recovering = !settingsStore.fullRepullDone.first()
if (recovering) {
settingsStore.clearCursor(SettingsStore.COLLECTION_BOOKCASES)
settingsStore.clearCursor(SettingsStore.COLLECTION_SHELVES)
settingsStore.clearCursor(SettingsStore.COLLECTION_BOOKS)
}
pushBookcases(api) pushBookcases(api)
pushShelves(api) pushShelves(api)
pushBooks(api) pushBooks(api)
@@ -53,7 +92,24 @@ class SyncEngine(
pullShelves(api) pullShelves(api)
pullBooks(api) pullBooks(api)
settingsStore.setLastSyncTime(System.currentTimeMillis()) settingsStore.setLastSyncTime(System.currentTimeMillis())
// Only after the whole sync succeeds — a sync that fails partway
// through (e.g. pull throws) must retry the full re-pull next time.
if (recovering) settingsStore.setFullRepullDone()
SyncResult.Success SyncResult.Success
} catch (e: CancellationException) {
throw e
} catch (e: HttpException) {
if (e.code() == 401 || e.code() == 403) {
// Belt and braces: this catches both authRefresh's own 401/403
// and one from any push/pull call later in this same sync
// (e.g. the token was revoked mid-sync). The 404 handling in
// the update* helpers below only matches code 404, so it can
// never swallow this.
settingsStore.clearAuthToken()
SyncResult.AuthExpired
} else {
SyncResult.Failure(e)
}
} catch (e: IOException) { } catch (e: IOException) {
SyncResult.Failure(e) SyncResult.Failure(e)
} catch (e: Exception) { } catch (e: Exception) {
@@ -85,6 +141,15 @@ class SyncEngine(
} }
} }
/**
* A 404 here is trusted as "deleted on the server" ONLY because [sync]
* proved the attached token valid, moments earlier, by refreshing it. On
* 2026-09-17 a request that carried a quietly-expired token 404'd on a
* bookcase that still existed — PocketBase's create/update rules hide a
* record from an unauthenticated caller rather than 401ing it, so an
* anonymous PATCH to an existing id looks identical to one to a deleted
* id. Do not call this without a fresh refresh in the same [sync] run.
*/
private suspend fun updateBookcase(api: PocketBaseApi, entity: BookcaseEntity) { private suspend fun updateBookcase(api: PocketBaseApi, entity: BookcaseEntity) {
try { try {
val response = api.updateBookcase(entity.id, entity.toDto()) val response = api.updateBookcase(entity.id, entity.toDto())
@@ -115,6 +180,7 @@ class SyncEngine(
} }
} }
/** Same trust argument as [updateBookcase]'s KDoc — see it for the 2026-09-17 incident. */
private suspend fun updateShelf(api: PocketBaseApi, entity: ShelfEntity) { private suspend fun updateShelf(api: PocketBaseApi, entity: ShelfEntity) {
try { try {
val response = api.updateShelf(entity.id, entity.toDto()) val response = api.updateShelf(entity.id, entity.toDto())
@@ -145,6 +211,13 @@ class SyncEngine(
} }
} }
/**
* Same trust argument as [updateBookcase]'s KDoc — see it for the
* 2026-09-17 incident. This is the helper that actually lost the user's
* books that day: five records hard-deleted locally while fully intact
* on the server, because the request that 404'd on them carried no valid
* auth and PocketBase's rules hid rather than rejected them.
*/
private suspend fun updateBook(api: PocketBaseApi, entity: BookEntity) { private suspend fun updateBook(api: PocketBaseApi, entity: BookEntity) {
try { try {
val response = api.updateBook(entity.id, entity.toDto()) val response = api.updateBook(entity.id, entity.toDto())
@@ -7,6 +7,7 @@ import androidx.compose.animation.core.infiniteRepeatable
import androidx.compose.animation.core.rememberInfiniteTransition import androidx.compose.animation.core.rememberInfiniteTransition
import androidx.compose.animation.core.tween import androidx.compose.animation.core.tween
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
@@ -36,6 +37,9 @@ enum class SyncStatus {
Syncing, Syncing,
Offline, Offline,
Error, Error,
/** The session expired (see SyncEngine's 2026-09-17 incident KDoc) — distinct from [Error] so the bar is tappable to re-sign-in. */
AuthExpired,
} }
/** /**
@@ -52,11 +56,16 @@ fun SyncStatusBar(
status: SyncStatus, status: SyncStatus,
label: String, label: String,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
// Only [SyncStatus.AuthExpired] wires this up today (SPEC: sync failure is
// a quiet status line, never a dialog — but "sign in again" needs SOME
// affordance, and this bar is what the user is already looking at).
onClick: (() -> Unit)? = null,
) { ) {
Row( Row(
modifier = modifier modifier = modifier
.fillMaxWidth() .fillMaxWidth()
.background(MaterialTheme.colorScheme.surfaceContainerLow) .background(MaterialTheme.colorScheme.surfaceContainerLow)
.let { if (onClick != null) it.clickable(onClick = onClick) else it }
.navigationBarsPadding() .navigationBarsPadding()
.padding(horizontal = 28.dp, vertical = 8.dp), .padding(horizontal = 28.dp, vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
@@ -77,7 +86,7 @@ private fun StatusDot(status: SyncStatus) {
SyncStatus.Synced -> MaterialTheme.colorScheme.secondary SyncStatus.Synced -> MaterialTheme.colorScheme.secondary
SyncStatus.Syncing -> MaterialTheme.colorScheme.secondary SyncStatus.Syncing -> MaterialTheme.colorScheme.secondary
SyncStatus.Offline -> MaterialTheme.colorScheme.onSurfaceVariant SyncStatus.Offline -> MaterialTheme.colorScheme.onSurfaceVariant
SyncStatus.Error -> MaterialTheme.colorScheme.error SyncStatus.Error, SyncStatus.AuthExpired -> MaterialTheme.colorScheme.error
} }
if (status == SyncStatus.Syncing) { if (status == SyncStatus.Syncing) {
val transition = rememberInfiniteTransition(label = "sync-pulse") val transition = rememberInfiniteTransition(label = "sync-pulse")
@@ -105,6 +105,10 @@ fun LibraryScreen(
container: AppContainer, container: AppContainer,
onEnterByHandWithQuery: (String) -> Unit = { onAddByHandClick() }, onEnterByHandWithQuery: (String) -> Unit = { onAddByHandClick() },
onEditOnlineResult: (OnlineBook, BookMetadata?) -> Unit = { _, _ -> }, onEditOnlineResult: (OnlineBook, BookMetadata?) -> Unit = { _, _ -> },
// 2026-09-17 incident: the user is looking at THIS screen when the
// session expires, not the setup screen — MainActivity's start-destination
// check only runs on a fresh launch. Defaults to a no-op for previews/tests.
onAuthExpired: () -> Unit = {},
) { ) {
val viewModel: LibraryViewModel = viewModel( val viewModel: LibraryViewModel = viewModel(
// A fresh VM per distinct shelf filter, so tapping a different shelf // A fresh VM per distinct shelf filter, so tapping a different shelf
@@ -161,7 +165,11 @@ fun LibraryScreen(
} }
}, },
syncStatusBar = { syncStatusBar = {
SyncStatusBar(status = uiState.syncBar.status, label = uiState.syncBar.label) SyncStatusBar(
status = uiState.syncBar.status,
label = uiState.syncBar.label,
onClick = if (uiState.syncBar.status == SyncStatus.AuthExpired) onAuthExpired else null,
)
}, },
) { innerPadding -> ) { innerPadding ->
PullToRefreshBox( PullToRefreshBox(
@@ -8,6 +8,9 @@ enum class SyncOutcome {
SUCCESS, SUCCESS,
FAILURE, FAILURE,
NO_SERVER, NO_SERVER,
/** See [org.modg.bookshelf.data.repo.SyncResult.AuthExpired] — the fix is "sign in again", not "retry". */
AUTH_EXPIRED,
} }
data class SyncBarState(val status: SyncStatus, val label: String) data class SyncBarState(val status: SyncStatus, val label: String)
@@ -29,6 +32,7 @@ object LibrarySyncPresenter {
return when (lastOutcome) { return when (lastOutcome) {
SyncOutcome.FAILURE -> SyncBarState(SyncStatus.Error, "Sync failed — showing local library") SyncOutcome.FAILURE -> SyncBarState(SyncStatus.Error, "Sync failed — showing local library")
SyncOutcome.NO_SERVER -> SyncBarState(SyncStatus.Offline, "No server configured") SyncOutcome.NO_SERVER -> SyncBarState(SyncStatus.Offline, "No server configured")
SyncOutcome.AUTH_EXPIRED -> SyncBarState(SyncStatus.AuthExpired, "Signed out — sign in again to sync")
SyncOutcome.SUCCESS, SyncOutcome.NONE -> if (lastSyncMillis != null) { SyncOutcome.SUCCESS, SyncOutcome.NONE -> if (lastSyncMillis != null) {
SyncBarState(SyncStatus.Synced, "Synced ${elapsedPhrase(nowMillis - lastSyncMillis)}") SyncBarState(SyncStatus.Synced, "Synced ${elapsedPhrase(nowMillis - lastSyncMillis)}")
} else { } else {
@@ -137,7 +137,9 @@ class LibraryViewModel(
) )
private val _onlineSearchPhase = MutableStateFlow<OnlineSearchPhase>(OnlineSearchPhase.NotSearched) private val _onlineSearchPhase = MutableStateFlow<OnlineSearchPhase>(OnlineSearchPhase.NotSearched)
private var onlineSearchJob: Job? = null
/** Internal (not private) so tests can join it to deterministically wait out a cancelled search. */
internal var onlineSearchJob: Job? = null
/** /**
* Recomputed from [allBooks] on every change, not frozen at search time — so a * Recomputed from [allBooks] on every change, not frozen at search time — so a
@@ -425,6 +427,7 @@ class LibraryViewModel(
is SyncResult.Success -> _lastSyncOutcome.value = SyncOutcome.SUCCESS is SyncResult.Success -> _lastSyncOutcome.value = SyncOutcome.SUCCESS
is SyncResult.Failure -> _lastSyncOutcome.value = SyncOutcome.FAILURE is SyncResult.Failure -> _lastSyncOutcome.value = SyncOutcome.FAILURE
is SyncResult.Skipped -> _lastSyncOutcome.value = SyncOutcome.NO_SERVER is SyncResult.Skipped -> _lastSyncOutcome.value = SyncOutcome.NO_SERVER
is SyncResult.AuthExpired -> _lastSyncOutcome.value = SyncOutcome.AUTH_EXPIRED
} }
_isSyncing.value = false _isSyncing.value = false
} }
@@ -57,6 +57,11 @@ fun BookshelfNavHost(
onAddByHandClick = { navController.navigate(Routes.addBook()) }, onAddByHandClick = { navController.navigate(Routes.addBook()) },
onLocationsClick = { navController.navigate(Routes.LOCATIONS) }, onLocationsClick = { navController.navigate(Routes.LOCATIONS) },
onSettingsClick = { navController.navigate(Routes.SETTINGS) }, onSettingsClick = { navController.navigate(Routes.SETTINGS) },
onAuthExpired = {
navController.navigate(Routes.SETUP) {
popUpTo(navController.graph.id) { inclusive = true }
}
},
container = container, container = container,
// Wave 10: "Can't find it? Enter it by hand" under the online results — // Wave 10: "Can't find it? Enter it by hand" under the online results —
// pre-fills the add-by-hand form's title with the search query. // pre-fills the add-by-hand form's title with the search query.
@@ -104,12 +104,30 @@ fun SettingsScreen(
SectionHeading("Sync") SectionHeading("Sync")
InfoRow(label = "Last synced", value = formatLastSync(state.lastSyncTime)) InfoRow(label = "Last synced", value = formatLastSync(state.lastSyncTime))
PrimaryButton( if (state.syncAuthExpired) {
text = if (state.isSyncing) "Syncing…" else "Sync now", // Not a "Sync failed" dead end: the fix is signing in again, so the
onClick = viewModel::syncNow, // button goes straight to setup instead of retrying the same sync.
enabled = !state.isSyncing, // Goes through onSignedOut directly, NOT viewModel.signOut(onSignedOut)
modifier = Modifier.padding(top = 8.dp), // — that also clears the stored email/user id, and setup pre-fills those.
) Text(
text = "Your session expired. Sign in again.",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.error,
modifier = Modifier.padding(top = 8.dp),
)
PrimaryButton(
text = "Sign in",
onClick = onSignedOut,
modifier = Modifier.padding(top = 8.dp),
)
} else {
PrimaryButton(
text = if (state.isSyncing) "Syncing…" else "Sync now",
onClick = viewModel::syncNow,
enabled = !state.isSyncing,
modifier = Modifier.padding(top = 8.dp),
)
}
GoldDivider(modifier = Modifier.padding(vertical = 16.dp)) GoldDivider(modifier = Modifier.padding(vertical = 16.dp))
@@ -239,6 +257,7 @@ internal fun InfoRow(label: String, value: String) {
private fun syncLabel(state: SettingsUiState): String = when { private fun syncLabel(state: SettingsUiState): String = when {
state.isSyncing -> "Syncing…" state.isSyncing -> "Syncing…"
state.syncAuthExpired -> "Signed out — sign in again to sync"
state.syncError != null -> "Sync failed: ${state.syncError}" state.syncError != null -> "Sync failed: ${state.syncError}"
state.lastSyncTime != null -> "Synced • ${formatLastSync(state.lastSyncTime)}" state.lastSyncTime != null -> "Synced • ${formatLastSync(state.lastSyncTime)}"
else -> "Not synced yet" else -> "Not synced yet"
@@ -26,11 +26,13 @@ data class SettingsUiState(
val lastSyncTime: Long? = null, val lastSyncTime: Long? = null,
val isSyncing: Boolean = false, val isSyncing: Boolean = false,
val syncError: String? = null, val syncError: String? = null,
val syncAuthExpired: Boolean = false,
val latestCrash: CrashReport? = null, val latestCrash: CrashReport? = null,
) { ) {
val syncStatus: SyncStatus val syncStatus: SyncStatus
get() = when { get() = when {
isSyncing -> SyncStatus.Syncing isSyncing -> SyncStatus.Syncing
syncAuthExpired -> SyncStatus.AuthExpired
syncError != null -> SyncStatus.Error syncError != null -> SyncStatus.Error
lastSyncTime != null -> SyncStatus.Synced lastSyncTime != null -> SyncStatus.Synced
else -> SyncStatus.Offline else -> SyncStatus.Offline
@@ -56,6 +58,7 @@ class SettingsViewModel(
private val isSyncing = MutableStateFlow(false) private val isSyncing = MutableStateFlow(false)
private val syncError = MutableStateFlow<String?>(null) private val syncError = MutableStateFlow<String?>(null)
private val syncAuthExpired = MutableStateFlow(false)
// Read only when this screen is opened (never at app startup) and off the // Read only when this screen is opened (never at app startup) and off the
// main thread — CrashReporter.list() reads files. See its KDoc. // main thread — CrashReporter.list() reads files. See its KDoc.
@@ -83,7 +86,7 @@ class SettingsViewModel(
} }
val uiState: StateFlow<SettingsUiState> = val uiState: StateFlow<SettingsUiState> =
combine(baseInfo, isSyncing, syncError, latestCrash) { base, syncing, error, crash -> combine(baseInfo, isSyncing, syncError, syncAuthExpired, latestCrash) { base, syncing, error, authExpired, crash ->
SettingsUiState( SettingsUiState(
serverUrl = base.serverUrl, serverUrl = base.serverUrl,
userEmail = base.userEmail, userEmail = base.userEmail,
@@ -92,6 +95,7 @@ class SettingsViewModel(
lastSyncTime = base.lastSyncTime, lastSyncTime = base.lastSyncTime,
isSyncing = syncing, isSyncing = syncing,
syncError = error, syncError = error,
syncAuthExpired = authExpired,
latestCrash = crash, latestCrash = crash,
) )
}.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), SettingsUiState()) }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), SettingsUiState())
@@ -101,6 +105,7 @@ class SettingsViewModel(
viewModelScope.launch { viewModelScope.launch {
isSyncing.value = true isSyncing.value = true
syncError.value = null syncError.value = null
syncAuthExpired.value = false
// SyncEngine never throws (SPEC: sync failure is a quiet status // SyncEngine never throws (SPEC: sync failure is a quiet status
// line, never a crash or a blocking dialog) — surface its result // line, never a crash or a blocking dialog) — surface its result
// as that quiet line, nothing more. // as that quiet line, nothing more.
@@ -108,6 +113,9 @@ class SettingsViewModel(
is SyncResult.Success -> Unit is SyncResult.Success -> Unit
is SyncResult.Skipped -> syncError.value = "No server configured yet." is SyncResult.Skipped -> syncError.value = "No server configured yet."
is SyncResult.Failure -> syncError.value = result.error.message ?: "Sync failed." is SyncResult.Failure -> syncError.value = result.error.message ?: "Sync failed."
// Not a generic failure: "Sync failed: HTTP 401" would tell the user
// to retry, when what actually fixes this is signing in again.
is SyncResult.AuthExpired -> syncAuthExpired.value = true
} }
isSyncing.value = false isSyncing.value = false
} }
@@ -5,6 +5,7 @@ import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.update import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
@@ -46,6 +47,16 @@ class SetupViewModel(private val authRepository: AuthRepository) : ViewModel() {
.readTimeout(6, TimeUnit.SECONDS) .readTimeout(6, TimeUnit.SECONDS)
.build() .build()
init {
// Re-signing-in after an auth expiry (SettingsStore.clearAuthToken keeps
// server URL, user id and email) should be typing the password only.
viewModelScope.launch {
val url = authRepository.serverUrl.first()
val email = authRepository.userEmail.first()
_uiState.update { it.copy(serverUrl = url ?: it.serverUrl, email = email ?: it.email) }
}
}
fun onServerUrlChanged(value: String) { fun onServerUrlChanged(value: String) {
_uiState.update { it.copy(serverUrl = value, urlError = null) } _uiState.update { it.copy(serverUrl = value, urlError = null) }
} }
@@ -49,4 +49,22 @@ class AuthRepositoryTest {
assertNull(settingsStore.userEmail.first()) assertNull(settingsStore.userEmail.first())
} }
/**
* Backstop for causes of stuck cursors we haven't thought of yet (see the
* 2026-09-17 incident's one-time re-pull in SyncEngine.sync()): every fresh
* sign-in reconciles fully, not just the specific recovery path.
*/
@Test
fun `login success clears all three pull cursors so a fresh sign-in reconciles fully`() = runTest {
settingsStore.setCursor(SettingsStore.COLLECTION_BOOKS, "2024-01-01 00:00:00.000Z")
settingsStore.setCursor(SettingsStore.COLLECTION_SHELVES, "2024-01-01 00:00:00.000Z")
settingsStore.setCursor(SettingsStore.COLLECTION_BOOKCASES, "2024-01-01 00:00:00.000Z")
repository.login(email = "reader@example.com", password = "hunter2")
assertNull(settingsStore.cursorFor(SettingsStore.COLLECTION_BOOKS).first())
assertNull(settingsStore.cursorFor(SettingsStore.COLLECTION_SHELVES).first())
assertNull(settingsStore.cursorFor(SettingsStore.COLLECTION_BOOKCASES).first())
}
} }
@@ -1,5 +1,6 @@
package org.modg.bookshelf.data.repo package org.modg.bookshelf.data.repo
import kotlinx.coroutines.CancellationException
import okhttp3.MediaType.Companion.toMediaType import okhttp3.MediaType.Companion.toMediaType
import okhttp3.MultipartBody import okhttp3.MultipartBody
import okhttp3.ResponseBody.Companion.toResponseBody import okhttp3.ResponseBody.Companion.toResponseBody
@@ -10,8 +11,10 @@ import org.modg.bookshelf.data.remote.BookcaseDto
import org.modg.bookshelf.data.remote.PbListResponse import org.modg.bookshelf.data.remote.PbListResponse
import org.modg.bookshelf.data.remote.PocketBaseApi import org.modg.bookshelf.data.remote.PocketBaseApi
import org.modg.bookshelf.data.remote.ShelfDto import org.modg.bookshelf.data.remote.ShelfDto
import org.modg.bookshelf.data.remote.UserRecordDto
import retrofit2.HttpException import retrofit2.HttpException
import retrofit2.Response import retrofit2.Response
import java.io.IOException
fun httpError(code: Int): HttpException { fun httpError(code: Int): HttpException {
val body = "{}".toResponseBody("application/json".toMediaType()) val body = "{}".toResponseBody("application/json".toMediaType())
@@ -37,6 +40,16 @@ class FakePocketBaseApi : PocketBaseApi {
val updateShelfErrors = mutableMapOf<String, HttpException>() val updateShelfErrors = mutableMapOf<String, HttpException>()
val updateBookErrors = mutableMapOf<String, HttpException>() val updateBookErrors = mutableMapOf<String, HttpException>()
/** Defaults to a normal, successful refresh — most tests aren't exercising the auth-expiry path. */
var authRefreshHttpError: HttpException? = null
var authRefreshIOException: IOException? = null
var authRefreshCancellation: CancellationException? = null
var refreshedToken: String = "refreshed-fake-token"
var refreshedUserId: String = "fake-user-id"
/** One-shot: exercises the "a sync whose pull fails leaves the one-time-repull flag unset" case. */
var nextListBooksError: HttpException? = null
fun seedBook(dto: BookDto) { fun seedBook(dto: BookDto) {
books[dto.id] = dto books[dto.id] = dto
} }
@@ -44,9 +57,19 @@ class FakePocketBaseApi : PocketBaseApi {
override suspend fun authWithPassword(body: AuthWithPasswordRequest): AuthResponse = override suspend fun authWithPassword(body: AuthWithPasswordRequest): AuthResponse =
AuthResponse(token = "fake-token") AuthResponse(token = "fake-token")
override suspend fun authRefresh(): AuthResponse {
callLog += "authRefresh"
authRefreshCancellation?.let { throw it }
authRefreshIOException?.let { throw it }
authRefreshHttpError?.let { throw it }
return AuthResponse(token = refreshedToken, record = UserRecordDto(id = refreshedUserId))
}
// ---- books ---- // ---- books ----
override suspend fun listBooks(filter: String?, sort: String, perPage: Int, page: Int): PbListResponse<BookDto> { override suspend fun listBooks(filter: String?, sort: String, perPage: Int, page: Int): PbListResponse<BookDto> {
callLog += "listBooks"
nextListBooksError?.let { nextListBooksError = null; throw it }
val cursor = filter?.substringAfter("'")?.substringBefore("'") val cursor = filter?.substringAfter("'")?.substringBefore("'")
val items = books.values.filter { cursor == null || it.updated > cursor }.sortedBy { it.updated } val items = books.values.filter { cursor == null || it.updated > cursor }.sortedBy { it.updated }
return PbListResponse(page = 1, totalPages = 1, totalItems = items.size, items = items) return PbListResponse(page = 1, totalPages = 1, totalItems = items.size, items = items)
@@ -2,11 +2,16 @@ package org.modg.bookshelf.data.repo
import androidx.room.Room import androidx.room.Room
import androidx.test.core.app.ApplicationProvider import androidx.test.core.app.ApplicationProvider
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.runTest
import org.junit.After import org.junit.After
import org.junit.Assert.assertEquals import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertNull import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue import org.junit.Assert.assertTrue
import org.junit.Assert.fail
import org.junit.Before import org.junit.Before
import org.junit.Test import org.junit.Test
import org.junit.runner.RunWith import org.junit.runner.RunWith
@@ -36,6 +41,11 @@ class SyncEngineTest {
.build() .build()
settingsStore = SettingsStore(ApplicationProvider.getApplicationContext()) settingsStore = SettingsStore(ApplicationProvider.getApplicationContext())
settingsStore.setServerUrl("https://example.test") settingsStore.setServerUrl("https://example.test")
// DataStore's on-disk state leaks across Robolectric test methods in
// this class (see SettingsStoreTest's setUp for the same issue) — the
// one-time-repull flag being true from an earlier test would silently
// change the repull tests' behavior.
settingsStore.resetFullRepullDoneForTesting()
api = FakePocketBaseApi() api = FakePocketBaseApi()
engine = SyncEngine( engine = SyncEngine(
apiProvider = { api }, apiProvider = { api },
@@ -198,4 +208,124 @@ class SyncEngineTest {
assertEquals("My local edit", db.bookDao().getByIdIncludingDeleted("book1")?.title) assertEquals("My local edit", db.bookDao().getByIdIncludingDeleted("book1")?.title)
assertEquals(SyncState.PENDING_UPDATE, db.bookDao().getByIdIncludingDeleted("book1")?.syncState) assertEquals(SyncState.PENDING_UPDATE, db.bookDao().getByIdIncludingDeleted("book1")?.syncState)
} }
// ---------------------------------------------------------- auth refresh (2026-09-17)
@Test
fun `sync refreshes the token before any push or pull, and persists the refreshed token`() = runTest {
db.bookcaseDao().upsert(bookcase("case1"))
api.refreshedToken = "brand-new-token"
api.refreshedUserId = "user-42"
val result = engine.sync()
assertTrue(result is SyncResult.Success)
val refreshIdx = api.callLog.indexOf("authRefresh")
val pushIdx = api.callLog.indexOf("createBookcase:case1")
assertTrue("expected authRefresh before any push, got ${api.callLog}", refreshIdx == 0 && refreshIdx < pushIdx)
assertEquals("brand-new-token", settingsStore.authToken.first())
assertEquals("user-42", settingsStore.userId.first())
}
@Test
fun `a 401 on refresh is the 2026-09-17 incident's regression test - AuthExpired, token cleared, nothing else touched`() = runTest {
settingsStore.setAuthToken("stale-5-day-old-token")
settingsStore.setUserEmail("reader@example.com")
db.bookDao().upsert(book("book1", state = SyncState.PENDING_UPDATE).copy(title = "unsynced edit"))
api.authRefreshHttpError = httpError(401)
val result = engine.sync()
assertTrue(result is SyncResult.AuthExpired)
assertNull("token must be cleared so the app doesn't keep hammering the server with it", settingsStore.authToken.first())
assertEquals("https://example.test", settingsStore.serverUrl.first())
assertEquals("reader@example.com", settingsStore.userEmail.first())
assertEquals("no push/pull call may have been made", listOf("authRefresh"), api.callLog)
val stillLocal = db.bookDao().getByIdIncludingDeleted("book1")
assertEquals("the PENDING_UPDATE book must survive an unauthenticated sync attempt intact", "unsynced edit", stillLocal?.title)
assertEquals(SyncState.PENDING_UPDATE, stillLocal?.syncState)
}
@Test
fun `a 403 on refresh also reports AuthExpired`() = runTest {
api.authRefreshHttpError = httpError(403)
assertTrue(engine.sync() is SyncResult.AuthExpired)
}
@Test
fun `an IOException on refresh is a plain Failure and does not clear the token`() = runTest {
settingsStore.setAuthToken("still-good-token")
api.authRefreshIOException = java.io.IOException("no network")
val result = engine.sync()
assertTrue(result is SyncResult.Failure)
assertEquals("still-good-token", settingsStore.authToken.first())
}
@Test
fun `a 401 from a push call mid-sync reports AuthExpired and does not hard-delete the record`() = runTest {
db.bookDao().upsert(book("book1", state = SyncState.PENDING_UPDATE).copy(title = "keep me"))
api.updateBookErrors["book1"] = httpError(401)
val result = engine.sync()
assertTrue(result is SyncResult.AuthExpired)
val stillLocal = db.bookDao().getByIdIncludingDeleted("book1")
assertNotNull("a 401 must never be mistaken for the 404-means-deleted case", stillLocal)
assertEquals("keep me", stillLocal?.title)
assertEquals(SyncState.PENDING_UPDATE, stillLocal?.syncState)
}
@Test
fun `CancellationException thrown during sync propagates instead of becoming a Failure`() = runTest {
api.authRefreshCancellation = CancellationException("scope cancelled")
try {
engine.sync()
fail("expected CancellationException to propagate")
} catch (e: CancellationException) {
// expected
}
}
// ---------------------------------------------------------- one-time full re-pull (2026-09-17)
@Test
fun `first sync after the fix restores a book the incident hard-deleted, and marks the repull done`() = runTest {
// Mirrors the incident: the pull cursor is already past the lost row's
// `updated`, and the row is gone locally, but it is still intact on the server.
settingsStore.setCursor(SettingsStore.COLLECTION_BOOKS, "2024-06-01 00:00:00.000Z")
api.seedBook(BookDto(id = "lost-book", title = "Recovered", updated = "2024-01-01 00:00:00.000Z", created = "2024-01-01 00:00:00.000Z"))
assertNull(db.bookDao().getByIdIncludingDeleted("lost-book"))
val result = engine.sync()
assertTrue(result is SyncResult.Success)
assertEquals("Recovered", db.bookDao().getById("lost-book")?.title)
assertTrue(settingsStore.fullRepullDone.first())
}
@Test
fun `a sync whose pull fails leaves the one-time-repull flag unset so the next sync retries it`() = runTest {
api.nextListBooksError = httpError(500)
val result = engine.sync()
assertTrue(result is SyncResult.Failure)
assertFalse(settingsStore.fullRepullDone.first())
}
@Test
fun `once the repull flag is set, a normal sync no longer clears cursors`() = runTest {
settingsStore.setFullRepullDone()
settingsStore.setCursor(SettingsStore.COLLECTION_BOOKS, "2024-06-01 00:00:00.000Z")
// Older than the cursor: an incremental pull must NOT see this one.
api.seedBook(BookDto(id = "old-book", title = "Too old", updated = "2024-01-01 00:00:00.000Z", created = "2024-01-01 00:00:00.000Z"))
engine.sync()
assertNull(db.bookDao().getByIdIncludingDeleted("old-book"))
}
} }
@@ -20,6 +20,13 @@ class LibrarySyncPresenterTest {
assertEquals("Sync failed — showing local library", state.label) assertEquals("Sync failed — showing local library", state.label)
} }
@Test
fun `an expired session is a distinct status from a plain failure, not "Sync failed"`() {
val state = LibrarySyncPresenter.present(nowMillis = 1_000L, lastSyncMillis = null, isSyncing = false, lastOutcome = SyncOutcome.AUTH_EXPIRED)
assertEquals(SyncStatus.AuthExpired, state.status)
assertEquals("Signed out — sign in again to sync", state.label)
}
@Test @Test
fun `no server configured is offline, not an error`() { fun `no server configured is offline, not an error`() {
val state = LibrarySyncPresenter.present(nowMillis = 1_000L, lastSyncMillis = null, isSyncing = false, lastOutcome = SyncOutcome.NO_SERVER) val state = LibrarySyncPresenter.present(nowMillis = 1_000L, lastSyncMillis = null, isSyncing = false, lastOutcome = SyncOutcome.NO_SERVER)
@@ -97,6 +97,31 @@ class LibraryViewModelTest {
initialShelfId = null, initialShelfId = null,
) )
// --- sync (2026-09-17 auth-expiry incident) ---
@Test
fun `an expired session maps to the AuthExpired sync bar state, not a generic failure`() = runTest {
settingsStore.setServerUrl("https://example.test")
val api = org.modg.bookshelf.data.repo.FakePocketBaseApi()
api.authRefreshHttpError = org.modg.bookshelf.data.repo.httpError(401)
val vm = LibraryViewModel(
coverDownloader = coverHost.downloader(java.io.File(context.cacheDir, "cover-preload")),
computeDispatcher = Dispatchers.Main,
bookRepository = bookRepository,
locationRepository = locationRepository,
syncEngine = SyncEngine(ApiProvider { api }, db.bookDao(), db.bookcaseDao(), db.shelfDao(), settingsStore),
settingsStore = settingsStore,
bookSearchRepository = BookSearchRepository(OkHttpClient(), Json { ignoreUnknownKeys = true }),
metadataRepository = MetadataRepository(OkHttpClient(), Json { ignoreUnknownKeys = true }),
initialShelfId = null,
)
val bar = vm.uiState.first { it.syncBar.status == org.modg.bookshelf.ui.components.SyncStatus.AuthExpired }.syncBar
assertEquals(org.modg.bookshelf.ui.components.SyncStatus.AuthExpired, bar.status)
assertEquals("Signed out — sign in again to sync", bar.label)
}
// --- typing vs submit --- // --- typing vs submit ---
@Test @Test
@@ -150,7 +175,12 @@ class LibraryViewModelTest {
vm.onQueryChange("something else") vm.onQueryChange("something else")
assertEquals(OnlineSearchState.NotSearched, vm.onlineSearch.first()) assertEquals(OnlineSearchState.NotSearched, vm.onlineSearch.first())
gate.countDown() // release the blocked background call so its thread isn't leaked gate.countDown() // release the blocked background call
// Wait for the cancelled job to actually finish unwinding on its own thread before
// this test returns -- otherwise it can still be dispatching onto Dispatchers.Main
// after tearDown() (or even the next test's setUp()) touches it, which fails with
// "Dispatchers.Main is used concurrently with setting it" in whatever test runs next.
vm.onlineSearchJob?.join()
} }
// --- partial and total source failure --- // --- partial and total source failure ---
@@ -71,6 +71,10 @@ class LibraryScreenPaparazziTest {
@Test @Test
fun libraryEmptyLight() = snapshotBoth("library-empty") { Empty() } fun libraryEmptyLight() = snapshotBoth("library-empty") { Empty() }
/** 2026-09-17 incident: the sync bar's distinct "session expired" state — never "Sync failed". */
@Test
fun libraryAuthExpiredLight() = snapshotBoth("library-auth-expired") { AuthExpired() }
@Test @Test
fun librarySearchBothSectionsLight() = snapshotBoth("library-search-both-sections") { fun librarySearchBothSectionsLight() = snapshotBoth("library-search-both-sections") {
SearchShell( SearchShell(
@@ -170,6 +174,13 @@ class LibraryScreenPaparazziTest {
@Composable @Composable
private fun Empty() = Shell(books = emptyList(), syncStatus = SyncStatus.Offline, syncLabel = "Not synced yet") private fun Empty() = Shell(books = emptyList(), syncStatus = SyncStatus.Offline, syncLabel = "Not synced yet")
@Composable
private fun AuthExpired() = Shell(
books = ScreenFixtures.books,
syncStatus = SyncStatus.AuthExpired,
syncLabel = "Signed out — sign in again to sync",
)
/** Same chrome as [Shell] but with a non-blank query, so [LibraryScreen] shows the sectioned search-results view instead of the grid. */ /** Same chrome as [Shell] but with a non-blank query, so [LibraryScreen] shows the sectioned search-results view instead of the grid. */
@Composable @Composable
private fun SearchShell(localMatches: List<BookEntity>, onlineSearch: OnlineSearchState) { private fun SearchShell(localMatches: List<BookEntity>, onlineSearch: OnlineSearchState) {
@@ -7,6 +7,8 @@ import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.ArrowBack import androidx.compose.material.icons.filled.ArrowBack
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
@@ -49,14 +51,21 @@ class SettingsScreenPaparazziTest {
@Test @Test
fun settingsDiagnosticsEmptyLight() = snapshotBoth("settings-diagnostics-empty") { DiagnosticsEmpty() } fun settingsDiagnosticsEmptyLight() = snapshotBoth("settings-diagnostics-empty") { DiagnosticsEmpty() }
@Composable /** 2026-09-17 incident: "Your session expired. Sign in again." — never "Sync failed: HTTP 401". */
private fun Populated() = Shell(crash = sampleCrash) @Test
fun settingsAuthExpiredLight() = snapshotBoth("settings-auth-expired") { AuthExpired() }
@Composable @Composable
private fun DiagnosticsEmpty() = Shell(crash = null) private fun Populated() = Shell(crash = sampleCrash, authExpired = false)
@Composable @Composable
private fun Shell(crash: CrashReport?) { private fun DiagnosticsEmpty() = Shell(crash = null, authExpired = false)
@Composable
private fun AuthExpired() = Shell(crash = null, authExpired = true)
@Composable
private fun Shell(crash: CrashReport?, authExpired: Boolean) {
val lastSync = System.currentTimeMillis() - 2 * 60 * 1000L val lastSync = System.currentTimeMillis() - 2 * 60 * 1000L
BookshelfScaffold( BookshelfScaffold(
title = "Settings", title = "Settings",
@@ -64,7 +73,11 @@ class SettingsScreenPaparazziTest {
IconButton(onClick = {}) { Icon(Icons.Filled.ArrowBack, contentDescription = "Back") } IconButton(onClick = {}) { Icon(Icons.Filled.ArrowBack, contentDescription = "Back") }
}, },
syncStatusBar = { syncStatusBar = {
SyncStatusBar(status = SyncStatus.Synced, label = "Synced • ${formatLastSync(lastSync)}") if (authExpired) {
SyncStatusBar(status = SyncStatus.AuthExpired, label = "Signed out — sign in again to sync")
} else {
SyncStatusBar(status = SyncStatus.Synced, label = "Synced • ${formatLastSync(lastSync)}")
}
}, },
) { innerPadding -> ) { innerPadding ->
PaperSurface(modifier = Modifier.fillMaxWidth()) { PaperSurface(modifier = Modifier.fillMaxWidth()) {
@@ -82,7 +95,17 @@ class SettingsScreenPaparazziTest {
SectionHeading("Sync") SectionHeading("Sync")
InfoRow(label = "Last synced", value = formatLastSync(lastSync)) InfoRow(label = "Last synced", value = formatLastSync(lastSync))
PrimaryButton(text = "Sync now", onClick = {}, modifier = Modifier.padding(top = 8.dp)) if (authExpired) {
Text(
text = "Your session expired. Sign in again.",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.error,
modifier = Modifier.padding(top = 8.dp),
)
PrimaryButton(text = "Sign in", onClick = {}, modifier = Modifier.padding(top = 8.dp))
} else {
PrimaryButton(text = "Sync now", onClick = {}, modifier = Modifier.padding(top = 8.dp))
}
GoldDivider(modifier = Modifier.padding(vertical = 16.dp)) GoldDivider(modifier = Modifier.padding(vertical = 16.dp))
@@ -0,0 +1,116 @@
package org.modg.bookshelf.ui.settings
import androidx.room.Room
import androidx.test.core.app.ApplicationProvider
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
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.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import org.modg.bookshelf.data.local.BookshelfDatabase
import org.modg.bookshelf.data.prefs.SettingsStore
import org.modg.bookshelf.data.remote.ApiProvider
import org.modg.bookshelf.data.repo.AuthRepository
import org.modg.bookshelf.data.repo.BookRepository
import org.modg.bookshelf.data.repo.FakePocketBaseApi
import org.modg.bookshelf.data.repo.SyncEngine
import org.modg.bookshelf.data.repo.httpError
import org.modg.bookshelf.diagnostics.CrashReporter
import org.modg.bookshelf.ui.components.SyncStatus
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
/**
* 2026-09-17 incident: [SettingsViewModel.syncNow] used to surface an expired
* session as "Sync failed: HTTP 401", which reads as "try again later" rather
* than "you must sign in again". Covers the distinct [SyncResult.AuthExpired]
* mapping this class's SPEC section adds.
*/
@OptIn(ExperimentalCoroutinesApi::class)
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [34])
class SettingsViewModelTest {
private lateinit var db: BookshelfDatabase
private lateinit var settingsStore: SettingsStore
private lateinit var api: FakePocketBaseApi
private lateinit var context: android.content.Context
@Before
fun setUp() {
Dispatchers.setMain(UnconfinedTestDispatcher())
context = ApplicationProvider.getApplicationContext()
db = Room.inMemoryDatabaseBuilder(context, BookshelfDatabase::class.java)
.allowMainThreadQueries()
.build()
settingsStore = SettingsStore(context)
api = FakePocketBaseApi()
}
@After
fun tearDown() {
db.close()
Dispatchers.resetMain()
}
private fun viewModel(): SettingsViewModel = SettingsViewModel(
authRepository = AuthRepository(ApiProvider { api }, settingsStore),
settingsStore = settingsStore,
bookRepository = BookRepository(db.bookDao(), context),
syncEngine = SyncEngine(ApiProvider { api }, db.bookDao(), db.bookcaseDao(), db.shelfDao(), settingsStore),
crashReporter = CrashReporter(context),
)
@Test
fun `a plain sync failure keeps the generic error message`() = runTest {
settingsStore.setServerUrl("https://example.test")
api.authRefreshIOException = java.io.IOException("no network")
val vm = viewModel()
vm.syncNow()
// Not "!isSyncing" — that's also (trivially) true of the very first
// combined emission, before this sync's failure has landed.
val state = vm.uiState.first { it.syncError != null }
assertFalse(state.syncAuthExpired)
assertEquals(SyncStatus.Error, state.syncStatus)
}
@Test
fun `an expired session maps to syncAuthExpired, not a generic sync error`() = runTest {
settingsStore.setServerUrl("https://example.test")
api.authRefreshHttpError = httpError(401)
val vm = viewModel()
vm.syncNow()
val state = vm.uiState.first { it.syncAuthExpired }
assertTrue(state.syncAuthExpired)
assertEquals(SyncStatus.AuthExpired, state.syncStatus)
assertEquals(null, state.syncError)
}
@Test
fun `a later successful sync clears a previous auth-expired state`() = runTest {
settingsStore.setServerUrl("https://example.test")
api.authRefreshHttpError = httpError(401)
val vm = viewModel()
vm.syncNow()
vm.uiState.first { it.syncAuthExpired }
api.authRefreshHttpError = null
vm.syncNow()
val state = vm.uiState.first { !it.isSyncing && !it.syncAuthExpired }
assertFalse(state.syncAuthExpired)
}
}
@@ -0,0 +1,63 @@
package org.modg.bookshelf.ui.setup
import androidx.test.core.app.ApplicationProvider
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
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.After
import org.junit.Assert.assertEquals
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import org.modg.bookshelf.data.prefs.SettingsStore
import org.modg.bookshelf.data.remote.ApiProvider
import org.modg.bookshelf.data.repo.AuthRepository
import org.modg.bookshelf.data.repo.FakePocketBaseApi
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
/**
* 2026-09-17 incident: after an auth expiry clears only the token (see
* SettingsStore.clearAuthToken), re-signing-in should be typing the password
* only. Covers that the setup screen's ViewModel pre-fills what's already
* stored, whether that's from a first-run-in-progress or a post-expiry return trip.
*/
@OptIn(ExperimentalCoroutinesApi::class)
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [34])
class SetupViewModelTest {
private lateinit var settingsStore: SettingsStore
private lateinit var authRepository: AuthRepository
@Before
fun setUp() {
Dispatchers.setMain(UnconfinedTestDispatcher())
settingsStore = SettingsStore(ApplicationProvider.getApplicationContext())
authRepository = AuthRepository(ApiProvider { FakePocketBaseApi() }, settingsStore)
}
@After
fun tearDown() {
Dispatchers.resetMain()
}
@Test
fun `pre-fills the stored server URL and email when present`() = runTest {
settingsStore.setServerUrl("https://library.montanaro.home")
settingsStore.setUserEmail("reader@example.com")
val viewModel = SetupViewModel(authRepository)
val state = viewModel.uiState.first {
it.serverUrl == "https://library.montanaro.home" && it.email == "reader@example.com"
}
assertEquals("https://library.montanaro.home", state.serverUrl)
assertEquals("reader@example.com", state.email)
assertEquals("", state.password) // never pre-filled — SPEC "typing the password only"
}
}
+1
View File
@@ -0,0 +1 @@
d109abe5-9e54-4496-912d-b200c51d373e
+18
View File
@@ -0,0 +1,18 @@
=== M-authexpiry-fix attempts=1 quota_waits=0 hard_fails=0 ===
cost=$1.3351339999999998 turns=44 err=?
--- result (tail) ---
ar onlineSearchJob` to `internal var onlineSearchJob`, matching the file's existing convention (same reasoning already documented on `performOnlineSearch`) of loosening visibility so tests can synchronize on it directly.
- `app/app/src/test/java/org/modg/bookshelf/ui/library/LibraryViewModelTest.kt`: after `gate.countDown()`, added `vm.onlineSearchJob?.join()` so the test genuinely waits for the cancelled job to finish before returning, with a comment explaining why.
No changes to the feature logic, SPEC, build files, or anything else — nothing else in the gate's failure pointed at the auth-expiry implementation itself.
**Verification:**
- `assembleDebug` → exit 0.
- `testDebugUnitTest` → exit 0, reran 4 more times with `--rerun` after the fix, all clean (previously failed on run 2 of 2 before the fix).
- Test count: 355 passed, 2 skipped, 0 failures (baseline was 337) — via summed XML.
- `verifyPaparazziDebug` → exit 0.
- Recorded the 4 new Paparazzi snapshots (`library-auth-expired-{light,dark}`, `settings-auth-expired-{light,dark}`) and viewed all four PNGs: library grid shows the "Signed out — sign in again to sync" sync bar; settings shows "Your session expired. Sign in again." with a "Sign in" button. Both match spec's design language in light and dark, and `git status` confirmed no other snapshots were touched by the record run.
- Grepped a forced recompile for Kotlin warnings in touched files: none.
- Final full sequence run in order (assembleDebug → testDebugUnitTest → verifyPaparazziDebug): all exit 0.
I did not re-review the original worker's auth-expiry design/logic (token refresh, full re-pull, AuthExpired propagation) since none of that was implicated by the gate failure — per instructions, I only touched what was making the gate red.
+1
View File
@@ -0,0 +1 @@
6bcc6e7a-a2a5-449e-b75a-a62eea1cc657
+13
View File
@@ -0,0 +1,13 @@
=== M-authexpiry attempts=1 quota_waits=0 hard_fails=0 ===
cost=$4.886013000000001 turns=123 err=?
--- result (tail) ---
ption` handler before it can reach a `code() == 404` branch, so it can never be misread as "deleted." A local dirty (`PENDING_*`) record is never touched by an `AuthExpired` result — the exception aborts the whole `sync()` before the offending DAO write and before any subsequent push/pull calls run.
**Full re-pull safety:** relied on `SyncEngine.shouldApplyRemote` (SyncEngine.kt, in the `applyIncomingBook`/`Shelf`/`Bookcase` helpers) — no local record → always inserted; a `SYNCED` local record only loses to a remote that's `>=` its own `updated`; a dirty (`PENDING_*`) local record only loses to a remote strictly newer than its own `updated`. So a full re-pull can only ever add back rows or apply genuinely newer server state — it can't clobber an unsynced local edit.
**Deviations:**
- I did not re-run baseline `testDebugUnitTest` before touching code — I trusted the prompt's stated 337/2-skipped baseline. I did do the required post-change runs.
- Added a small `internal` test-only method (`SettingsStore.resetFullRepullDoneForTesting`) purely to prevent DataStore's known cross-test-method state leak (the same issue `SettingsStoreTest` already documents) from corrupting the new repull tests. No production caller.
- Dropped one planned SetupViewModel test ("starts blank on first run") because it wasn't required and was fragile against the same cross-test DataStore leak; kept only the required pre-fill test, which is self-contained.
**Out of scope but noticed:** several pre-existing Kotlin deprecation warnings (`Icons.Filled.ArrowBack`, `Icons.Outlined.Sort`, `centerAlignedTopAppBarColors`, `Modifier.menuAnchor()`, `LocalLifecycleOwner`) and a nullable-`ClassLoader` warning in some metadata client tests — none in files I touched, all pre-existing, left alone.
+14
View File
@@ -0,0 +1,14 @@
A previous worker implemented the task in ~/bookshelf/tasks/M-authexpiry.txt and left the
working tree FAILING its acceptance gate. Read, in order: ~/bookshelf/docs/SPEC.md,
~/bookshelf/tasks/M-authexpiry.txt (the original instructions — every constraint in it binds
you too), ~/bookshelf/logs/M-authexpiry.summary (that worker's report), then the gate output at
~/bookshelf/logs/gate-M-authexpiry.log. Lines starting "GATE:" and non-zero exits are the failures.
Fix ONLY what makes the gate red. Do not redesign or re-implement the feature. The
test count must end ABOVE 337 with zero failures; do not delete or @Ignore tests to
get there. If a Paparazzi verify failed because snapshots were never recorded, record
them and LOOK at the PNGs before accepting them.
Run in the FOREGROUND (never background a build): ./tasks/gw assembleDebug,
./tasks/gw testDebugUnitTest, ./tasks/gw verifyPaparazziDebug. Do not git commit/add/push.
Report what was wrong, what you changed, and the verbatim output tails.