Wave 2: data layer (C) + metadata/scanning (D)

Room entities/DAOs/DB, PocketBase Retrofit client + auth interceptor, SyncEngine
(push-then-pull, LWW, tombstones, client-generated ids), SettingsStore, AppContainer.
Open Library + Google Books merge, ISBN validation, CameraX + ML Kit scanner plumbing.

Verified by orchestrator: assembleDebug exit 0; testDebugUnitTest exit 0, 68 tests,
0 failures, 0 errors.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016bThmkmyUUdqQpy3MXFFe5
This commit is contained in:
2026-09-06 03:24:04 +00:00
parent 6c17e42037
commit d1a73a1193
61 changed files with 3104 additions and 26 deletions
View File
@@ -0,0 +1,97 @@
package org.modg.bookshelf
import android.content.Context
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.serialization.json.Json
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.OkHttpClient
import okhttp3.logging.HttpLoggingInterceptor
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.remote.PbAuthInterceptor
import org.modg.bookshelf.data.remote.PocketBaseApi
import org.modg.bookshelf.data.repo.AuthRepository
import org.modg.bookshelf.data.repo.BookRepository
import org.modg.bookshelf.data.repo.LocationRepository
import org.modg.bookshelf.data.repo.SyncEngine
import com.jakewharton.retrofit2.converter.kotlinx.serialization.asConverterFactory
import retrofit2.Retrofit
/**
* Manual DI container (SPEC: no Hilt/kapt). Held by [BookshelfApplication] for
* the life of the process.
*
* The Retrofit/OkHttp stack is built lazily and rebuilt only when the
* configured server URL changes, since the URL isn't known until the user
* finishes the setup screen — see SPEC "Server URL is NOT hardcoded". Every
* repository still works (against Room only) before that point; [apiProvider]
* just returns null until a server is configured.
*
* Wave 3 (metadata/scanning) adds its own plain lookup class and is expected
* to read whatever it needs (e.g. [bookRepository]) from here — nothing about
* this container's shape needs to change to support that.
*/
class AppContainer(private val context: Context) {
val settingsStore = SettingsStore(context)
private val database by lazy { BookshelfDatabase.build(context) }
val bookRepository by lazy { BookRepository(database.bookDao(), context) }
val locationRepository by lazy {
LocationRepository(database.bookcaseDao(), database.shelfDao(), database.bookDao())
}
private val json = Json {
ignoreUnknownKeys = true
encodeDefaults = false
explicitNulls = false
}
private val okHttpClient: OkHttpClient by lazy {
OkHttpClient.Builder()
.addInterceptor(PbAuthInterceptor { runBlocking { settingsStore.authToken.first() } })
.addInterceptor(HttpLoggingInterceptor().apply { level = HttpLoggingInterceptor.Level.BASIC })
.build()
}
private val apiMutex = Mutex()
private var cachedBaseUrl: String? = null
private var cachedApi: PocketBaseApi? = null
/** Returns null when no server URL has been configured yet. */
private suspend fun currentApi(): PocketBaseApi? {
val url = settingsStore.serverUrl.first()?.takeIf { it.isNotBlank() } ?: return null
return apiMutex.withLock {
if (url != cachedBaseUrl || cachedApi == null) {
cachedBaseUrl = url
cachedApi = Retrofit.Builder()
.baseUrl("$url/")
.client(okHttpClient)
.addConverterFactory(json.asConverterFactory("application/json".toMediaType()))
.build()
.create(PocketBaseApi::class.java)
}
cachedApi
}
}
val apiProvider = ApiProvider { currentApi() }
val authRepository by lazy { AuthRepository(apiProvider, settingsStore) }
val syncEngine by lazy {
SyncEngine(
apiProvider = apiProvider,
bookDao = database.bookDao(),
bookcaseDao = database.bookcaseDao(),
shelfDao = database.shelfDao(),
settingsStore = settingsStore,
)
}
}
@@ -4,9 +4,14 @@ import android.app.Application
/** /**
* Application entry point. Per SPEC, DI is a hand-rolled [AppContainer] held * Application entry point. Per SPEC, DI is a hand-rolled [AppContainer] held
* here (no Hilt/kapt) — data.repo/data.local/data.remote wiring lands with * here (no Hilt/kapt).
* the waves that introduce those packages. This scaffold wave intentionally
* leaves the container empty rather than stubbing out APIs that don't exist
* yet.
*/ */
class BookshelfApplication : Application() class BookshelfApplication : Application() {
lateinit var appContainer: AppContainer
private set
override fun onCreate() {
super.onCreate()
appContainer = AppContainer(this)
}
}
@@ -0,0 +1,57 @@
package org.modg.bookshelf.data.local
import androidx.room.Dao
import androidx.room.Query
import androidx.room.Upsert
import kotlinx.coroutines.flow.Flow
@Dao
interface BookDao {
/** All non-deleted books. Every screen-facing read filters `deleted = 0`. */
@Query("SELECT * FROM books WHERE deleted = 0 ORDER BY title COLLATE NOCASE ASC")
fun observeAll(): Flow<List<BookEntity>>
@Query("SELECT * FROM books WHERE deleted = 0 ORDER BY createdAt DESC")
fun observeAllByRecentlyAdded(): Flow<List<BookEntity>>
@Query("SELECT * FROM books WHERE shelfId = :shelfId AND deleted = 0 ORDER BY title COLLATE NOCASE ASC")
fun observeByShelf(shelfId: String): Flow<List<BookEntity>>
@Query(
"""
SELECT * FROM books WHERE deleted = 0 AND (
title LIKE '%' || :query || '%' OR
authorsJson LIKE '%' || :query || '%' OR
isbn13 LIKE '%' || :query || '%' OR
isbn10 LIKE '%' || :query || '%'
) ORDER BY title COLLATE NOCASE ASC
""",
)
fun search(query: String): Flow<List<BookEntity>>
@Query("SELECT * FROM books WHERE id = :id AND deleted = 0")
suspend fun getById(id: String): BookEntity?
/** Used by sync (needs tombstones too) and by soft-delete/undo flows. */
@Query("SELECT * FROM books WHERE id = :id")
suspend fun getByIdIncludingDeleted(id: String): BookEntity?
@Query("SELECT * FROM books WHERE isbn13 = :isbn13 AND deleted = 0 LIMIT 1")
suspend fun findByIsbn13(isbn13: String): BookEntity?
@Query("SELECT COUNT(*) FROM books WHERE shelfId = :shelfId AND deleted = 0")
suspend fun countByShelf(shelfId: String): Int
@Query("SELECT * FROM books WHERE syncState != 'SYNCED'")
suspend fun getPendingSync(): List<BookEntity>
@Upsert
suspend fun upsert(book: BookEntity)
@Upsert
suspend fun upsertAll(books: List<BookEntity>)
@Query("DELETE FROM books WHERE id = :id")
suspend fun hardDelete(id: String)
}
@@ -0,0 +1,48 @@
package org.modg.bookshelf.data.local
import androidx.room.Entity
import androidx.room.Index
import androidx.room.PrimaryKey
/**
* Mirrors the PocketBase `books` collection 1:1 (see SPEC.md). [authorsJson]
* holds a JSON-encoded `List<String>` — kept as a raw string column so Room
* doesn't need a TypeConverter for it; callers encode/decode with
* kotlinx.serialization at the repository boundary.
*
* [coverUrl] is the resolved, browsable URL for the uploaded PocketBase file
* (built from the record id + filename once a cover has synced up).
* [localCoverPath] is a path into app-private storage used when a cover was
* downloaded from the metadata source but hasn't been uploaded yet (offline).
*/
@Entity(
tableName = "books",
indices = [
Index("isbn13"),
Index("shelfId"),
Index("deleted"),
Index("syncState"),
],
)
data class BookEntity(
@PrimaryKey val id: String,
val title: String,
val subtitle: String? = null,
val authorsJson: String = "[]",
val isbn13: String? = null,
val isbn10: String? = null,
val publisher: String? = null,
val publishedDate: String? = null,
val pageCount: Int? = null,
val description: String? = null,
val coverUrl: String? = null,
val coverSourceUrl: String? = null,
val shelfId: String? = null,
val notes: String? = null,
val addedBy: String? = null,
val deleted: Boolean = false,
val createdAt: Long,
val updatedAt: Long,
val syncState: SyncState = SyncState.PENDING_CREATE,
val localCoverPath: String? = null,
)
@@ -0,0 +1,31 @@
package org.modg.bookshelf.data.local
import androidx.room.Dao
import androidx.room.Query
import androidx.room.Upsert
import kotlinx.coroutines.flow.Flow
@Dao
interface BookcaseDao {
@Query("SELECT * FROM bookcases WHERE deleted = 0 ORDER BY position ASC, name COLLATE NOCASE ASC")
fun observeAll(): Flow<List<BookcaseEntity>>
@Query("SELECT * FROM bookcases WHERE id = :id AND deleted = 0")
suspend fun getById(id: String): BookcaseEntity?
@Query("SELECT * FROM bookcases WHERE id = :id")
suspend fun getByIdIncludingDeleted(id: String): BookcaseEntity?
@Query("SELECT * FROM bookcases WHERE syncState != 'SYNCED'")
suspend fun getPendingSync(): List<BookcaseEntity>
@Upsert
suspend fun upsert(bookcase: BookcaseEntity)
@Upsert
suspend fun upsertAll(bookcases: List<BookcaseEntity>)
@Query("DELETE FROM bookcases WHERE id = :id")
suspend fun hardDelete(id: String)
}
@@ -0,0 +1,21 @@
package org.modg.bookshelf.data.local
import androidx.room.Entity
import androidx.room.Index
import androidx.room.PrimaryKey
/** Mirrors the PocketBase `bookcases` collection 1:1 (see SPEC.md). */
@Entity(
tableName = "bookcases",
indices = [Index("deleted"), Index("syncState")],
)
data class BookcaseEntity(
@PrimaryKey val id: String,
val name: String,
val note: String? = null,
val position: Int = 0,
val deleted: Boolean = false,
val createdAt: Long,
val updatedAt: Long,
val syncState: SyncState = SyncState.PENDING_CREATE,
)
@@ -0,0 +1,27 @@
package org.modg.bookshelf.data.local
import android.content.Context
import androidx.room.Database
import androidx.room.Room
import androidx.room.RoomDatabase
import androidx.room.TypeConverters
@Database(
entities = [BookEntity::class, BookcaseEntity::class, ShelfEntity::class],
version = 1,
exportSchema = false,
)
@TypeConverters(Converters::class)
abstract class BookshelfDatabase : RoomDatabase() {
abstract fun bookDao(): BookDao
abstract fun bookcaseDao(): BookcaseDao
abstract fun shelfDao(): ShelfDao
companion object {
const val NAME = "bookshelf.db"
fun build(context: Context): BookshelfDatabase =
Room.databaseBuilder(context.applicationContext, BookshelfDatabase::class.java, NAME)
.build()
}
}
@@ -0,0 +1,12 @@
package org.modg.bookshelf.data.local
import androidx.room.TypeConverter
/** The only non-primitive column type across the three entities is [SyncState]. */
class Converters {
@TypeConverter
fun fromSyncState(value: SyncState): String = value.name
@TypeConverter
fun toSyncState(value: String): SyncState = SyncState.valueOf(value)
}
@@ -0,0 +1,21 @@
package org.modg.bookshelf.data.local
import kotlin.random.Random
/**
* PocketBase accepts client-supplied ids on create, so new records get their
* id generated here rather than waiting on a server round-trip — required for
* offline-first creation. Per SPEC: 15-char lowercase alphanumeric, and this
* id is never remapped after push.
*/
object IdGenerator {
private const val ALPHABET = "abcdefghijklmnopqrstuvwxyz0123456789"
private const val LENGTH = 15
fun newId(random: Random = Random.Default): String =
buildString(LENGTH) {
repeat(LENGTH) {
append(ALPHABET[random.nextInt(ALPHABET.length)])
}
}
}
@@ -0,0 +1,34 @@
package org.modg.bookshelf.data.local
import androidx.room.Dao
import androidx.room.Query
import androidx.room.Upsert
import kotlinx.coroutines.flow.Flow
@Dao
interface ShelfDao {
@Query("SELECT * FROM shelves WHERE deleted = 0 ORDER BY position ASC, label COLLATE NOCASE ASC")
fun observeAll(): Flow<List<ShelfEntity>>
@Query("SELECT * FROM shelves WHERE bookcaseId = :bookcaseId AND deleted = 0 ORDER BY position ASC, label COLLATE NOCASE ASC")
fun observeByBookcase(bookcaseId: String): Flow<List<ShelfEntity>>
@Query("SELECT * FROM shelves WHERE id = :id AND deleted = 0")
suspend fun getById(id: String): ShelfEntity?
@Query("SELECT * FROM shelves WHERE id = :id")
suspend fun getByIdIncludingDeleted(id: String): ShelfEntity?
@Query("SELECT * FROM shelves WHERE syncState != 'SYNCED'")
suspend fun getPendingSync(): List<ShelfEntity>
@Upsert
suspend fun upsert(shelf: ShelfEntity)
@Upsert
suspend fun upsertAll(shelves: List<ShelfEntity>)
@Query("DELETE FROM shelves WHERE id = :id")
suspend fun hardDelete(id: String)
}
@@ -0,0 +1,21 @@
package org.modg.bookshelf.data.local
import androidx.room.Entity
import androidx.room.Index
import androidx.room.PrimaryKey
/** Mirrors the PocketBase `shelves` collection 1:1 (see SPEC.md). */
@Entity(
tableName = "shelves",
indices = [Index("bookcaseId"), Index("deleted"), Index("syncState")],
)
data class ShelfEntity(
@PrimaryKey val id: String,
val bookcaseId: String,
val label: String,
val position: Int = 0,
val deleted: Boolean = false,
val createdAt: Long,
val updatedAt: Long,
val syncState: SyncState = SyncState.PENDING_CREATE,
)
@@ -0,0 +1,14 @@
package org.modg.bookshelf.data.local
/**
* Where a local row stands relative to the PocketBase server. Every mutation
* to a locally-owned entity goes through one of the PENDING_* states first —
* nothing is considered durable until [SyncEngine][org.modg.bookshelf.data.repo.SyncEngine]
* has pushed it and gotten a 2xx back.
*/
enum class SyncState {
SYNCED,
PENDING_CREATE,
PENDING_UPDATE,
PENDING_DELETE,
}
@@ -0,0 +1,18 @@
package org.modg.bookshelf.data.metadata
/**
* Source-agnostic lookup result. Produced by [OpenLibraryClient] / [GoogleBooksClient],
* combined by [MetadataMerger], and returned by [MetadataRepository].
*/
data class BookMetadata(
val isbn13: String? = null,
val isbn10: String? = null,
val title: String? = null,
val subtitle: String? = null,
val authors: List<String> = emptyList(),
val publisher: String? = null,
val publishedDate: String? = null,
val pageCount: Int? = null,
val description: String? = null,
val coverUrl: String? = null,
)
@@ -0,0 +1,46 @@
package org.modg.bookshelf.data.metadata
import java.io.IOException
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import kotlinx.serialization.SerializationException
import kotlinx.serialization.json.Json
import okhttp3.OkHttpClient
import okhttp3.Request
/**
* Google Books lookup — SPEC.md "Book metadata lookup" fallback source. No API key.
* Never throws: network/parse failures fail soft and return null.
*/
class GoogleBooksClient(
private val httpClient: OkHttpClient,
json: Json,
) {
private val json = Json(from = json) { ignoreUnknownKeys = true }
suspend fun lookup(isbn13: String): BookMetadata? = withContext(Dispatchers.IO) {
val body = fetchBody(isbn13) ?: return@withContext null
parseResponse(body)
}
private fun fetchBody(isbn13: String): String? = try {
val request = Request.Builder()
.url("https://www.googleapis.com/books/v1/volumes?q=isbn:$isbn13")
.build()
httpClient.newCall(request).execute().use { response ->
if (!response.isSuccessful) null else response.body?.string()
}
} catch (e: IOException) {
null
}
/** Package-visible for offline fixture tests — parses a raw response body with no network involved. */
internal fun parseResponse(body: String): BookMetadata? = try {
val dto = json.decodeFromString(GoogleBooksResponseDto.serializer(), body)
dto.items.firstOrNull()?.volumeInfo?.toBookMetadata()
} catch (e: SerializationException) {
null
} catch (e: IllegalArgumentException) {
null
}
}
@@ -0,0 +1,59 @@
package org.modg.bookshelf.data.metadata
import kotlinx.serialization.Serializable
/** https://www.googleapis.com/books/v1/volumes?q=isbn:{isbn} */
@Serializable
data class GoogleBooksResponseDto(
val items: List<GoogleBooksItemDto> = emptyList(),
)
@Serializable
data class GoogleBooksItemDto(
val volumeInfo: GoogleBooksVolumeInfoDto? = null,
)
@Serializable
data class GoogleBooksVolumeInfoDto(
val title: String? = null,
val subtitle: String? = null,
val authors: List<String> = emptyList(),
val publisher: String? = null,
val publishedDate: String? = null,
val pageCount: Int? = null,
val description: String? = null,
val imageLinks: GoogleBooksImageLinksDto? = null,
val industryIdentifiers: List<GoogleBooksIndustryIdentifierDto> = emptyList(),
)
@Serializable
data class GoogleBooksImageLinksDto(
val smallThumbnail: String? = null,
val thumbnail: String? = null,
)
@Serializable
data class GoogleBooksIndustryIdentifierDto(
val type: String? = null,
val identifier: String? = null,
)
/** Maps the GB DTO to the source-agnostic [BookMetadata], forcing https + zoom=2 on the cover per SPEC. */
fun GoogleBooksVolumeInfoDto.toBookMetadata(): BookMetadata = BookMetadata(
isbn13 = industryIdentifiers.firstOrNull { it.type == "ISBN_13" }?.identifier,
isbn10 = industryIdentifiers.firstOrNull { it.type == "ISBN_10" }?.identifier,
title = title,
subtitle = subtitle,
authors = authors,
publisher = publisher,
publishedDate = publishedDate,
pageCount = pageCount,
description = description,
coverUrl = normalizeCoverUrl(imageLinks?.thumbnail ?: imageLinks?.smallThumbnail),
)
private fun normalizeCoverUrl(raw: String?): String? {
if (raw.isNullOrBlank()) return null
val https = raw.replaceFirst("http://", "https://")
return if ("zoom=" in https) https.replace(Regex("zoom=\\d+"), "zoom=2") else "$https&zoom=2"
}
@@ -0,0 +1,61 @@
package org.modg.bookshelf.data.metadata
/**
* ISBN normalization, checksum validation, and ISBN-10 -> ISBN-13 conversion.
* Pure logic, no I/O — see SPEC.md "Book metadata lookup".
*/
object IsbnUtils {
/** Strips hyphens/spaces and uppercases (so a trailing ISBN-10 check digit of 'x' becomes 'X'). */
fun normalize(raw: String): String =
raw.trim().filterNot { it == '-' || it == ' ' }.uppercase()
/** True if [isbn] is exactly 13 digits with a valid ISBN-13 checksum. Expects an already-normalized string. */
fun isValidIsbn13(isbn: String): Boolean {
if (isbn.length != 13 || !isbn.all(Char::isDigit)) return false
val sum = isbn.mapIndexed { i, c -> (c - '0') * if (i % 2 == 0) 1 else 3 }.sum()
return sum % 10 == 0
}
/** True if [isbn] is exactly 10 characters (digits, trailing 'X' allowed) with a valid ISBN-10 checksum. */
fun isValidIsbn10(isbn: String): Boolean {
if (isbn.length != 10) return false
var sum = 0
for (i in 0 until 10) {
val c = isbn[i]
val value = when {
c.isDigit() -> c - '0'
c == 'X' && i == 9 -> 10
else -> return false
}
sum += value * (10 - i)
}
return sum % 11 == 0
}
/** Converts a valid ISBN-10 to its ISBN-13 equivalent (978 prefix + recomputed check digit), or null if invalid. */
fun isbn10ToIsbn13(isbn10: String): String? {
if (!isValidIsbn10(isbn10)) return null
val core = "978" + isbn10.substring(0, 9)
return core + isbn13CheckDigit(core)
}
private fun isbn13CheckDigit(core12: String): Int {
val sum = core12.mapIndexed { i, c -> (c - '0') * if (i % 2 == 0) 1 else 3 }.sum()
val remainder = sum % 10
return if (remainder == 0) 0 else 10 - remainder
}
/**
* Normalizes [raw] and returns a valid ISBN-13, converting from ISBN-10 if needed.
* Returns null if [raw] is not a checksum-valid ISBN-10 or ISBN-13.
*/
fun toIsbn13(raw: String): String? {
val normalized = normalize(raw)
return when {
normalized.length == 13 && isValidIsbn13(normalized) -> normalized
normalized.length == 10 && isValidIsbn10(normalized) -> isbn10ToIsbn13(normalized)
else -> null
}
}
}
@@ -0,0 +1,38 @@
package org.modg.bookshelf.data.metadata
/**
* SPEC.md "Book metadata lookup" merge rule: prefer whichever source has a title,
* fill blanks from the other, return null if both miss.
*/
object MetadataMerger {
fun merge(openLibrary: BookMetadata?, googleBooks: BookMetadata?): BookMetadata? {
val primary = when {
openLibrary.hasTitle() -> openLibrary
googleBooks.hasTitle() -> googleBooks
else -> return null
}
val secondary = if (primary === openLibrary) googleBooks else openLibrary
return fillBlanks(primary!!, secondary)
}
private fun BookMetadata?.hasTitle(): Boolean = !this?.title.isNullOrBlank()
private fun fillBlanks(primary: BookMetadata, secondary: BookMetadata?): BookMetadata {
if (secondary == null) return primary
return primary.copy(
isbn13 = primary.isbn13 ?: secondary.isbn13,
isbn10 = primary.isbn10 ?: secondary.isbn10,
subtitle = primary.subtitle.orBlank(secondary.subtitle),
authors = primary.authors.ifEmpty { secondary.authors },
publisher = primary.publisher.orBlank(secondary.publisher),
publishedDate = primary.publishedDate.orBlank(secondary.publishedDate),
pageCount = primary.pageCount ?: secondary.pageCount,
description = primary.description.orBlank(secondary.description),
coverUrl = primary.coverUrl.orBlank(secondary.coverUrl),
)
}
private fun String?.orBlank(fallback: String?): String? =
this?.takeIf { it.isNotBlank() } ?: fallback
}
@@ -0,0 +1,31 @@
package org.modg.bookshelf.data.metadata
import kotlinx.coroutines.async
import kotlinx.coroutines.coroutineScope
import kotlinx.serialization.json.Json
import okhttp3.OkHttpClient
/**
* Single entry point for book metadata lookup (SPEC.md "Book metadata lookup").
* Queries both sources concurrently and merges per [MetadataMerger]. Returns null
* if [isbn] doesn't checksum-validate or if both sources miss — callers (the scan
* screen) must then fall back to manual entry pre-filled with the scanned ISBN.
*/
class MetadataRepository(
private val openLibraryClient: OpenLibraryClient,
private val googleBooksClient: GoogleBooksClient,
) {
constructor(httpClient: OkHttpClient, json: Json) : this(
OpenLibraryClient(httpClient, json),
GoogleBooksClient(httpClient, json),
)
suspend fun lookup(isbn: String): BookMetadata? {
val isbn13 = IsbnUtils.toIsbn13(isbn) ?: return null
return coroutineScope {
val openLibrary = async { openLibraryClient.lookup(isbn13) }
val googleBooks = async { googleBooksClient.lookup(isbn13) }
MetadataMerger.merge(openLibrary.await(), googleBooks.await())
}
}
}
@@ -0,0 +1,50 @@
package org.modg.bookshelf.data.metadata
import java.io.IOException
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import kotlinx.serialization.SerializationException
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.decodeFromJsonElement
import kotlinx.serialization.json.jsonObject
import okhttp3.OkHttpClient
import okhttp3.Request
/**
* Open Library lookup — SPEC.md "Book metadata lookup" primary source.
* Never throws: network/parse failures fail soft and return null.
*/
class OpenLibraryClient(
private val httpClient: OkHttpClient,
json: Json,
) {
// Real responses carry fields this DTO doesn't model; never let an unknown key throw.
private val json = Json(from = json) { ignoreUnknownKeys = true }
suspend fun lookup(isbn13: String): BookMetadata? = withContext(Dispatchers.IO) {
val body = fetchBody(isbn13) ?: return@withContext null
parseResponse(body, isbn13)
}
private fun fetchBody(isbn13: String): String? = try {
val request = Request.Builder()
.url("https://openlibrary.org/api/books?bibkeys=ISBN:$isbn13&format=json&jscmd=data")
.build()
httpClient.newCall(request).execute().use { response ->
if (!response.isSuccessful) null else response.body?.string()
}
} catch (e: IOException) {
null
}
/** Package-visible for offline fixture tests — parses a raw response body with no network involved. */
internal fun parseResponse(body: String, isbn13: String): BookMetadata? = try {
val root = json.parseToJsonElement(body).jsonObject
val entry = root["ISBN:$isbn13"]?.jsonObject ?: return null
json.decodeFromJsonElement<OpenLibraryBookDto>(entry).toBookMetadata(isbn13)
} catch (e: SerializationException) {
null
} catch (e: IllegalArgumentException) {
null
}
}
@@ -0,0 +1,47 @@
package org.modg.bookshelf.data.metadata
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
/**
* https://openlibrary.org/api/books?bibkeys=ISBN:{isbn}&format=json&jscmd=data
* Response is a JSON object keyed by "ISBN:{isbn}"; this DTO models one value.
* Real responses carry many more fields (excerpts, ebooks, key, url, ...) that
* we don't need — the caller's Json must have ignoreUnknownKeys = true.
*/
@Serializable
data class OpenLibraryBookDto(
val title: String? = null,
val subtitle: String? = null,
val authors: List<OpenLibraryAuthorDto> = emptyList(),
val publishers: List<OpenLibraryPublisherDto> = emptyList(),
@SerialName("publish_date") val publishDate: String? = null,
@SerialName("number_of_pages") val numberOfPages: Int? = null,
val identifiers: OpenLibraryIdentifiersDto? = null,
)
@Serializable
data class OpenLibraryAuthorDto(val name: String? = null)
@Serializable
data class OpenLibraryPublisherDto(val name: String? = null)
@Serializable
data class OpenLibraryIdentifiersDto(
@SerialName("isbn_10") val isbn10: List<String> = emptyList(),
@SerialName("isbn_13") val isbn13: List<String> = emptyList(),
)
/** Maps the OL DTO to the source-agnostic [BookMetadata], deriving the cover URL per SPEC. */
fun OpenLibraryBookDto.toBookMetadata(lookupIsbn13: String): BookMetadata = BookMetadata(
isbn13 = identifiers?.isbn13?.firstOrNull() ?: lookupIsbn13,
isbn10 = identifiers?.isbn10?.firstOrNull(),
title = title,
subtitle = subtitle,
authors = authors.mapNotNull { it.name },
publisher = publishers.firstOrNull()?.name,
publishedDate = publishDate,
pageCount = numberOfPages,
description = null,
coverUrl = "https://covers.openlibrary.org/b/isbn/$lookupIsbn13-L.jpg",
)
@@ -0,0 +1,72 @@
package org.modg.bookshelf.data.prefs
import android.content.Context
import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.longPreferencesKey
import androidx.datastore.preferences.core.stringPreferencesKey
import androidx.datastore.preferences.preferencesDataStore
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
private val Context.dataStore: DataStore<Preferences> by preferencesDataStore(name = "bookshelf_settings")
/**
* Everything that must survive process death and isn't book/location data:
* server URL (never hardcoded, per SPEC), auth token, per-collection sync
* cursors (stored as the PocketBase UTC date string so pull filters can use
* them verbatim), and last-sync time.
*/
class SettingsStore(private val context: Context) {
private object Keys {
val SERVER_URL = stringPreferencesKey("server_url")
val AUTH_TOKEN = stringPreferencesKey("auth_token")
val USER_ID = stringPreferencesKey("user_id")
val LAST_SYNC_TIME = longPreferencesKey("last_sync_time")
fun cursor(collection: String) = stringPreferencesKey("cursor_$collection")
}
val serverUrl: Flow<String?> = context.dataStore.data.map { it[Keys.SERVER_URL] }
val authToken: Flow<String?> = context.dataStore.data.map { it[Keys.AUTH_TOKEN] }
val userId: Flow<String?> = context.dataStore.data.map { it[Keys.USER_ID] }
val lastSyncTime: Flow<Long?> = context.dataStore.data.map { it[Keys.LAST_SYNC_TIME] }
fun cursorFor(collection: String): Flow<String?> =
context.dataStore.data.map { it[Keys.cursor(collection)] }
suspend fun setServerUrl(url: String) {
context.dataStore.edit { it[Keys.SERVER_URL] = url }
}
suspend fun setAuthToken(token: String) {
context.dataStore.edit { it[Keys.AUTH_TOKEN] = token }
}
suspend fun setUserId(userId: String) {
context.dataStore.edit { it[Keys.USER_ID] = userId }
}
suspend fun setCursor(collection: String, cursor: String) {
context.dataStore.edit { it[Keys.cursor(collection)] = cursor }
}
suspend fun setLastSyncTime(epochMillis: Long) {
context.dataStore.edit { it[Keys.LAST_SYNC_TIME] = epochMillis }
}
/** Sign out: drop the token/user identity but keep the server URL — no need to re-enter it. */
suspend fun clearAuth() {
context.dataStore.edit {
it.remove(Keys.AUTH_TOKEN)
it.remove(Keys.USER_ID)
}
}
companion object {
const val COLLECTION_BOOKS = "books"
const val COLLECTION_SHELVES = "shelves"
const val COLLECTION_BOOKCASES = "bookcases"
}
}
@@ -0,0 +1,12 @@
package org.modg.bookshelf.data.remote
/**
* Indirection so data.repo classes don't depend on AppContainer (which builds
* the Retrofit instance lazily, since the server URL isn't known until the
* user finishes the setup screen). Returns null when no server is configured
* yet — callers must treat that as "skip network, Room is still the source
* of truth" rather than an error.
*/
fun interface ApiProvider {
suspend fun api(): PocketBaseApi?
}
@@ -0,0 +1,76 @@
package org.modg.bookshelf.data.remote
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
/** Generic PocketBase list-records envelope. */
@Serializable
data class PbListResponse<T>(
val page: Int = 1,
val perPage: Int = 0,
val totalItems: Int = 0,
val totalPages: Int = 0,
val items: List<T> = emptyList(),
)
@Serializable
data class BookDto(
val id: String = "",
val title: String = "",
val subtitle: String = "",
val authors: List<String> = emptyList(),
val isbn13: String = "",
val isbn10: String = "",
val publisher: String = "",
@SerialName("published_date") val publishedDate: String = "",
@SerialName("page_count") val pageCount: Int? = null,
val description: String = "",
val cover: String = "",
@SerialName("cover_source_url") val coverSourceUrl: String = "",
val shelf: String = "",
val notes: String = "",
@SerialName("added_by") val addedBy: String = "",
val deleted: Boolean = false,
val created: String = "",
val updated: String = "",
)
@Serializable
data class BookcaseDto(
val id: String = "",
val name: String = "",
val note: String = "",
val position: Int = 0,
val deleted: Boolean = false,
val created: String = "",
val updated: String = "",
)
@Serializable
data class ShelfDto(
val id: String = "",
val bookcase: String = "",
val label: String = "",
val position: Int = 0,
val deleted: Boolean = false,
val created: String = "",
val updated: String = "",
)
@Serializable
data class AuthWithPasswordRequest(
val identity: String,
val password: String,
)
@Serializable
data class AuthResponse(
val token: String = "",
val record: UserRecordDto = UserRecordDto(),
)
@Serializable
data class UserRecordDto(
val id: String = "",
val email: String = "",
)
@@ -0,0 +1,24 @@
package org.modg.bookshelf.data.remote
import okhttp3.Interceptor
import okhttp3.Response
/**
* Attaches the PocketBase auth token, if any, to every request. PocketBase
* expects the raw token in the `Authorization` header — no "Bearer " prefix.
*
* [tokenProvider] is a plain synchronous supplier so this class stays trivial
* to unit test; AppContainer wires it to a blocking read of SettingsStore.
*/
class PbAuthInterceptor(private val tokenProvider: () -> String?) : Interceptor {
override fun intercept(chain: Interceptor.Chain): Response {
val token = tokenProvider()
val original = chain.request()
val request = if (token.isNullOrBlank()) {
original
} else {
original.newBuilder().header("Authorization", token).build()
}
return chain.proceed(request)
}
}
@@ -0,0 +1,34 @@
package org.modg.bookshelf.data.remote
import java.time.Instant
import java.time.ZoneOffset
import java.time.format.DateTimeFormatter
import java.time.format.DateTimeFormatterBuilder
import java.time.temporal.ChronoField
/**
* PocketBase autodate fields serialize as `"2024-01-02 15:04:05.123Z"` (space
* separator, always UTC, millisecond precision) — not quite RFC3339. Cursor
* comparisons can stay lexical (the format is fixed-width and zero-padded, so
* string order == chronological order) but last-write-wins needs real millis.
*/
object PbDateFormat {
private val FORMATTER: DateTimeFormatter = DateTimeFormatterBuilder()
.appendPattern("yyyy-MM-dd HH:mm:ss")
.appendFraction(ChronoField.MILLI_OF_SECOND, 0, 3, true)
.appendLiteral('Z')
.toFormatter()
/** Returns epoch millis, or [fallback] (default: now) if [value] can't be parsed. */
fun parseToEpochMillis(value: String, fallback: Long = System.currentTimeMillis()): Long {
if (value.isBlank()) return fallback
return try {
Instant.from(FORMATTER.withZone(ZoneOffset.UTC).parse(value)).toEpochMilli()
} catch (e: Exception) {
fallback
}
}
fun formatEpochMillis(epochMillis: Long): String =
FORMATTER.withZone(ZoneOffset.UTC).format(Instant.ofEpochMilli(epochMillis))
}
@@ -0,0 +1,74 @@
package org.modg.bookshelf.data.remote
import okhttp3.MultipartBody
import retrofit2.http.Body
import retrofit2.http.GET
import retrofit2.http.Multipart
import retrofit2.http.PATCH
import retrofit2.http.POST
import retrofit2.http.Part
import retrofit2.http.Path
import retrofit2.http.Query
/**
* Retrofit contract for the self-hosted PocketBase server. Auth token is
* attached by [PbAuthInterceptor]; base URL is whatever the user entered on
* the setup screen (see AppContainer — it is never hardcoded).
*/
interface PocketBaseApi {
@POST("api/collections/users/auth-with-password")
suspend fun authWithPassword(@Body body: AuthWithPasswordRequest): AuthResponse
// ---- books ----
@GET("api/collections/books/records")
suspend fun listBooks(
@Query("filter") filter: String? = null,
@Query("sort") sort: String = "updated",
@Query("perPage") perPage: Int = 200,
@Query("page") page: Int = 1,
): PbListResponse<BookDto>
@POST("api/collections/books/records")
suspend fun createBook(@Body body: BookDto): BookDto
@PATCH("api/collections/books/records/{id}")
suspend fun updateBook(@Path("id") id: String, @Body body: BookDto): BookDto
@Multipart
@PATCH("api/collections/books/records/{id}")
suspend fun uploadBookCover(@Path("id") id: String, @Part cover: MultipartBody.Part): BookDto
// ---- shelves ----
@GET("api/collections/shelves/records")
suspend fun listShelves(
@Query("filter") filter: String? = null,
@Query("sort") sort: String = "updated",
@Query("perPage") perPage: Int = 200,
@Query("page") page: Int = 1,
): PbListResponse<ShelfDto>
@POST("api/collections/shelves/records")
suspend fun createShelf(@Body body: ShelfDto): ShelfDto
@PATCH("api/collections/shelves/records/{id}")
suspend fun updateShelf(@Path("id") id: String, @Body body: ShelfDto): ShelfDto
// ---- bookcases ----
@GET("api/collections/bookcases/records")
suspend fun listBookcases(
@Query("filter") filter: String? = null,
@Query("sort") sort: String = "updated",
@Query("perPage") perPage: Int = 200,
@Query("page") page: Int = 1,
): PbListResponse<BookcaseDto>
@POST("api/collections/bookcases/records")
suspend fun createBookcase(@Body body: BookcaseDto): BookcaseDto
@PATCH("api/collections/bookcases/records/{id}")
suspend fun updateBookcase(@Path("id") id: String, @Body body: BookcaseDto): BookcaseDto
}
@@ -0,0 +1,47 @@
package org.modg.bookshelf.data.repo
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
import org.modg.bookshelf.data.prefs.SettingsStore
import org.modg.bookshelf.data.remote.ApiProvider
import org.modg.bookshelf.data.remote.AuthWithPasswordRequest
/**
* Server URL + credentials, per SPEC: entered on first run, never hardcoded.
* Login is the one operation that genuinely can't work offline; every other
* repository in this app is Room-first.
*/
class AuthRepository(
private val apiProvider: ApiProvider,
private val settingsStore: SettingsStore,
) {
val isLoggedIn: Flow<Boolean> = settingsStore.authToken.map { !it.isNullOrBlank() }
val serverUrl: Flow<String?> = settingsStore.serverUrl
/** Normalizes per SPEC: requires https, strips a trailing slash. */
suspend fun setServerUrl(rawUrl: String): Result<Unit> {
val trimmed = rawUrl.trim().trimEnd('/')
if (!trimmed.startsWith("https://") && !trimmed.startsWith("http://")) {
return Result.failure(IllegalArgumentException("Server URL must start with http:// or https://"))
}
settingsStore.setServerUrl(trimmed)
return Result.success(Unit)
}
suspend fun login(email: String, password: String): Result<Unit> {
val api = apiProvider.api()
?: return Result.failure(IllegalStateException("Server URL is not configured yet"))
return try {
val response = api.authWithPassword(AuthWithPasswordRequest(identity = email, password = password))
settingsStore.setAuthToken(response.token)
settingsStore.setUserId(response.record.id)
Result.success(Unit)
} catch (e: Exception) {
Result.failure(e)
}
}
suspend fun signOut() {
settingsStore.clearAuth()
}
}
@@ -0,0 +1,148 @@
package org.modg.bookshelf.data.repo
import android.content.Context
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.withContext
import kotlinx.serialization.json.Json
import kotlinx.serialization.encodeToString
import kotlinx.serialization.decodeFromString
import okhttp3.OkHttpClient
import okhttp3.Request
import org.modg.bookshelf.data.local.BookDao
import org.modg.bookshelf.data.local.BookEntity
import org.modg.bookshelf.data.local.IdGenerator
import org.modg.bookshelf.data.local.SyncState
import java.io.File
import java.io.IOException
private val authorsJson = Json { ignoreUnknownKeys = true }
fun encodeAuthors(authors: List<String>): String = authorsJson.encodeToString(authors)
fun decodeAuthors(json: String): List<String> =
if (json.isBlank()) emptyList() else runCatching { authorsJson.decodeFromString<List<String>>(json) }.getOrDefault(emptyList())
/**
* Everything reads from Room and nothing here blocks on network — writes are
* applied locally as PENDING_* immediately and picked up by the next
* [SyncEngine] pass. Soft delete only; hard delete is reserved for the sync
* engine reconciling with the server (404 / successfully-pushed tombstones).
*/
class BookRepository(
private val bookDao: BookDao,
private val context: Context,
private val downloadClient: OkHttpClient = OkHttpClient(),
) {
fun observeAll(): Flow<List<BookEntity>> = bookDao.observeAll()
fun observeAllByRecentlyAdded(): Flow<List<BookEntity>> = bookDao.observeAllByRecentlyAdded()
fun observeByShelf(shelfId: String): Flow<List<BookEntity>> = bookDao.observeByShelf(shelfId)
fun search(query: String): Flow<List<BookEntity>> = bookDao.search(query)
suspend fun getById(id: String): BookEntity? = bookDao.getById(id)
suspend fun findByIsbn13(isbn13: String): BookEntity? = bookDao.findByIsbn13(isbn13)
suspend fun countByShelf(shelfId: String): Int = bookDao.countByShelf(shelfId)
/**
* Creates a new book. If [coverSourceUrl] is set, attempts to download the
* cover to app-private storage now (best-effort; failures are swallowed —
* the SyncEngine will simply have nothing to upload, and the UI still has
* [coverSourceUrl] to fall back on for display).
*/
suspend fun createBook(
title: String,
subtitle: String? = null,
authors: List<String> = emptyList(),
isbn13: String? = null,
isbn10: String? = null,
publisher: String? = null,
publishedDate: String? = null,
pageCount: Int? = null,
description: String? = null,
coverSourceUrl: String? = null,
shelfId: String? = null,
notes: String? = null,
addedBy: String? = null,
): String {
val id = IdGenerator.newId()
val now = System.currentTimeMillis()
val localCoverPath = coverSourceUrl?.let { downloadCoverBestEffort(id, it) }
bookDao.upsert(
BookEntity(
id = id,
title = title,
subtitle = subtitle,
authorsJson = encodeAuthors(authors),
isbn13 = isbn13,
isbn10 = isbn10,
publisher = publisher,
publishedDate = publishedDate,
pageCount = pageCount,
description = description,
coverSourceUrl = coverSourceUrl,
shelfId = shelfId,
notes = notes,
addedBy = addedBy,
createdAt = now,
updatedAt = now,
syncState = SyncState.PENDING_CREATE,
localCoverPath = localCoverPath,
),
)
return id
}
/** Persists an edited entity, bumping updatedAt and the sync state (unless still un-synced). */
suspend fun save(book: BookEntity) {
val nextState = if (book.syncState == SyncState.PENDING_CREATE) {
SyncState.PENDING_CREATE
} else {
SyncState.PENDING_UPDATE
}
bookDao.upsert(book.copy(updatedAt = System.currentTimeMillis(), syncState = nextState))
}
/** Soft-deletes with tombstone propagation, unless the record never made it to the server. */
suspend fun softDelete(id: String) {
val existing = bookDao.getByIdIncludingDeleted(id) ?: return
if (existing.syncState == SyncState.PENDING_CREATE) {
bookDao.hardDelete(id)
} else {
bookDao.upsert(
existing.copy(
deleted = true,
updatedAt = System.currentTimeMillis(),
syncState = SyncState.PENDING_DELETE,
),
)
}
}
/** Undo for the detail screen's soft-delete snackbar. No-ops if the record was hard-deleted already. */
suspend fun undoDelete(id: String) {
val existing = bookDao.getByIdIncludingDeleted(id) ?: return
if (!existing.deleted) return
bookDao.upsert(
existing.copy(
deleted = false,
updatedAt = System.currentTimeMillis(),
syncState = SyncState.PENDING_UPDATE,
),
)
}
private suspend fun downloadCoverBestEffort(id: String, url: String): String? = withContext(Dispatchers.IO) {
try {
val request = Request.Builder().url(url).build()
downloadClient.newCall(request).execute().use { response ->
if (!response.isSuccessful) return@withContext null
val bytes = response.body.bytes()
val dir = File(context.filesDir, "covers").apply { mkdirs() }
val file = File(dir, "$id.jpg")
file.writeBytes(bytes)
file.absolutePath
}
} catch (e: IOException) {
null
}
}
}
@@ -0,0 +1,71 @@
package org.modg.bookshelf.data.repo
import kotlinx.coroutines.flow.Flow
import org.modg.bookshelf.data.local.BookDao
import org.modg.bookshelf.data.local.BookcaseDao
import org.modg.bookshelf.data.local.BookcaseEntity
import org.modg.bookshelf.data.local.IdGenerator
import org.modg.bookshelf.data.local.ShelfDao
import org.modg.bookshelf.data.local.ShelfEntity
import org.modg.bookshelf.data.local.SyncState
/** Bookcases/shelves CRUD — same offline-first, soft-delete rules as [BookRepository]. */
class LocationRepository(
private val bookcaseDao: BookcaseDao,
private val shelfDao: ShelfDao,
private val bookDao: BookDao,
) {
fun observeBookcases(): Flow<List<BookcaseEntity>> = bookcaseDao.observeAll()
fun observeShelves(): Flow<List<ShelfEntity>> = shelfDao.observeAll()
fun observeShelvesByBookcase(bookcaseId: String): Flow<List<ShelfEntity>> = shelfDao.observeByBookcase(bookcaseId)
suspend fun getBookcase(id: String): BookcaseEntity? = bookcaseDao.getById(id)
suspend fun getShelf(id: String): ShelfEntity? = shelfDao.getById(id)
suspend fun bookCountForShelf(shelfId: String): Int = bookDao.countByShelf(shelfId)
suspend fun createBookcase(name: String, note: String? = null, position: Int = 0): String {
val id = IdGenerator.newId()
val now = System.currentTimeMillis()
bookcaseDao.upsert(
BookcaseEntity(id = id, name = name, note = note, position = position, createdAt = now, updatedAt = now),
)
return id
}
suspend fun createShelf(bookcaseId: String, label: String, position: Int = 0): String {
val id = IdGenerator.newId()
val now = System.currentTimeMillis()
shelfDao.upsert(
ShelfEntity(id = id, bookcaseId = bookcaseId, label = label, position = position, createdAt = now, updatedAt = now),
)
return id
}
suspend fun saveBookcase(bookcase: BookcaseEntity) {
val nextState = if (bookcase.syncState == SyncState.PENDING_CREATE) SyncState.PENDING_CREATE else SyncState.PENDING_UPDATE
bookcaseDao.upsert(bookcase.copy(updatedAt = System.currentTimeMillis(), syncState = nextState))
}
suspend fun saveShelf(shelf: ShelfEntity) {
val nextState = if (shelf.syncState == SyncState.PENDING_CREATE) SyncState.PENDING_CREATE else SyncState.PENDING_UPDATE
shelfDao.upsert(shelf.copy(updatedAt = System.currentTimeMillis(), syncState = nextState))
}
suspend fun softDeleteBookcase(id: String) {
val existing = bookcaseDao.getByIdIncludingDeleted(id) ?: return
if (existing.syncState == SyncState.PENDING_CREATE) {
bookcaseDao.hardDelete(id)
} else {
bookcaseDao.upsert(existing.copy(deleted = true, updatedAt = System.currentTimeMillis(), syncState = SyncState.PENDING_DELETE))
}
}
suspend fun softDeleteShelf(id: String) {
val existing = shelfDao.getByIdIncludingDeleted(id) ?: return
if (existing.syncState == SyncState.PENDING_CREATE) {
shelfDao.hardDelete(id)
} else {
shelfDao.upsert(existing.copy(deleted = true, updatedAt = System.currentTimeMillis(), syncState = SyncState.PENDING_DELETE))
}
}
}
@@ -0,0 +1,382 @@
package org.modg.bookshelf.data.repo
import kotlinx.coroutines.flow.first
import okhttp3.MultipartBody
import okhttp3.RequestBody.Companion.asRequestBody
import okhttp3.MediaType.Companion.toMediaTypeOrNull
import org.modg.bookshelf.data.local.BookDao
import org.modg.bookshelf.data.local.BookEntity
import org.modg.bookshelf.data.local.BookcaseDao
import org.modg.bookshelf.data.local.BookcaseEntity
import org.modg.bookshelf.data.local.ShelfDao
import org.modg.bookshelf.data.local.ShelfEntity
import org.modg.bookshelf.data.local.SyncState
import org.modg.bookshelf.data.prefs.SettingsStore
import org.modg.bookshelf.data.remote.ApiProvider
import org.modg.bookshelf.data.remote.BookDto
import org.modg.bookshelf.data.remote.BookcaseDto
import org.modg.bookshelf.data.remote.PbDateFormat
import org.modg.bookshelf.data.remote.PocketBaseApi
import org.modg.bookshelf.data.remote.ShelfDto
import retrofit2.HttpException
import java.io.File
import java.io.IOException
sealed class SyncResult {
object Success : SyncResult()
object Skipped : SyncResult()
data class Failure(val error: Throwable) : SyncResult()
}
/**
* Push-then-pull, last-write-wins on `updated`, per SPEC. Never throws out of
* [sync] — a failed sync is reported as [SyncResult.Failure] for a quiet
* status line, never a crash or a blocking dialog. Order across collections
* is bookcases -> shelves -> books both ways, so relations resolve cleanly
* even though Room does not enforce foreign keys between them (sync would
* otherwise have to special-case out-of-order pages).
*/
class SyncEngine(
private val apiProvider: ApiProvider,
private val bookDao: BookDao,
private val bookcaseDao: BookcaseDao,
private val shelfDao: ShelfDao,
private val settingsStore: SettingsStore,
) {
suspend fun sync(): SyncResult {
val api = apiProvider.api() ?: return SyncResult.Skipped
return try {
pushBookcases(api)
pushShelves(api)
pushBooks(api)
pullBookcases(api)
pullShelves(api)
pullBooks(api)
settingsStore.setLastSyncTime(System.currentTimeMillis())
SyncResult.Success
} catch (e: IOException) {
SyncResult.Failure(e)
} catch (e: Exception) {
SyncResult.Failure(e)
}
}
// ---------------------------------------------------------------- push
private suspend fun pushBookcases(api: PocketBaseApi) {
for (bookcase in bookcaseDao.getPendingSync()) {
when (bookcase.syncState) {
SyncState.PENDING_CREATE -> createBookcase(api, bookcase)
SyncState.PENDING_UPDATE, SyncState.PENDING_DELETE -> updateBookcase(api, bookcase)
SyncState.SYNCED -> Unit
}
}
}
private suspend fun createBookcase(api: PocketBaseApi, entity: BookcaseEntity) {
try {
val response = api.createBookcase(entity.toDto())
bookcaseDao.upsert(entity.synced(response.updated))
} catch (e: HttpException) {
if (e.code() == 409) {
// duplicate id on the server: fall back to PATCH, per SPEC.
updateBookcase(api, entity.copy(syncState = SyncState.PENDING_UPDATE))
} else throw e
}
}
private suspend fun updateBookcase(api: PocketBaseApi, entity: BookcaseEntity) {
try {
val response = api.updateBookcase(entity.id, entity.toDto())
bookcaseDao.upsert(entity.synced(response.updated))
} catch (e: HttpException) {
if (e.code() == 404) bookcaseDao.hardDelete(entity.id) else throw e
}
}
private suspend fun pushShelves(api: PocketBaseApi) {
for (shelf in shelfDao.getPendingSync()) {
when (shelf.syncState) {
SyncState.PENDING_CREATE -> createShelf(api, shelf)
SyncState.PENDING_UPDATE, SyncState.PENDING_DELETE -> updateShelf(api, shelf)
SyncState.SYNCED -> Unit
}
}
}
private suspend fun createShelf(api: PocketBaseApi, entity: ShelfEntity) {
try {
val response = api.createShelf(entity.toDto())
shelfDao.upsert(entity.synced(response.updated))
} catch (e: HttpException) {
if (e.code() == 409) {
updateShelf(api, entity.copy(syncState = SyncState.PENDING_UPDATE))
} else throw e
}
}
private suspend fun updateShelf(api: PocketBaseApi, entity: ShelfEntity) {
try {
val response = api.updateShelf(entity.id, entity.toDto())
shelfDao.upsert(entity.synced(response.updated))
} catch (e: HttpException) {
if (e.code() == 404) shelfDao.hardDelete(entity.id) else throw e
}
}
private suspend fun pushBooks(api: PocketBaseApi) {
for (book in bookDao.getPendingSync()) {
when (book.syncState) {
SyncState.PENDING_CREATE -> createBook(api, book)
SyncState.PENDING_UPDATE, SyncState.PENDING_DELETE -> updateBook(api, book)
SyncState.SYNCED -> Unit
}
}
}
private suspend fun createBook(api: PocketBaseApi, entity: BookEntity) {
try {
val response = api.createBook(entity.toDto())
uploadCoverIfNeeded(api, entity.synced(response.updated))
} catch (e: HttpException) {
if (e.code() == 409) {
updateBook(api, entity.copy(syncState = SyncState.PENDING_UPDATE))
} else throw e
}
}
private suspend fun updateBook(api: PocketBaseApi, entity: BookEntity) {
try {
val response = api.updateBook(entity.id, entity.toDto())
uploadCoverIfNeeded(api, entity.synced(response.updated))
} catch (e: HttpException) {
if (e.code() == 404) bookDao.hardDelete(entity.id) else throw e
}
}
/**
* Covers travel out-of-band from the JSON body (PocketBase file fields
* need multipart). A locally-downloaded cover that hasn't made it up yet
* rides along on the next successful create/update push.
*/
private suspend fun uploadCoverIfNeeded(api: PocketBaseApi, synced: BookEntity) {
val path = synced.localCoverPath
if (path == null) {
bookDao.upsert(synced)
return
}
val file = File(path)
if (!file.exists()) {
bookDao.upsert(synced.copy(localCoverPath = null))
return
}
try {
val body = file.asRequestBody("image/jpeg".toMediaTypeOrNull())
val part = MultipartBody.Part.createFormData("cover", file.name, body)
val response = api.uploadBookCover(synced.id, part)
val baseUrl = settingsStore.serverUrl.first()
val coverUrl = if (response.cover.isNotBlank() && baseUrl != null) {
"$baseUrl/api/files/books/${synced.id}/${response.cover}"
} else {
synced.coverUrl
}
bookDao.upsert(synced.copy(localCoverPath = null, coverUrl = coverUrl))
} catch (e: HttpException) {
// Upload failed (e.g. server rejected the file); keep localCoverPath so we retry
// next sync, but the record itself is still synced otherwise.
bookDao.upsert(synced)
}
}
// ---------------------------------------------------------------- pull
private suspend fun pullBookcases(api: PocketBaseApi) {
var cursor = settingsStore.cursorFor(SettingsStore.COLLECTION_BOOKCASES).first()
var page = 1
while (true) {
val filter = cursor?.takeIf { it.isNotBlank() }?.let { "(updated>'$it')" }
val response = api.listBookcases(filter = filter, page = page)
for (dto in response.items) {
applyIncomingBookcase(dto)
val c = cursor
if (c == null || dto.updated > c) cursor = dto.updated
}
if (response.items.isEmpty() || page >= response.totalPages) break
page++
}
cursor?.let { settingsStore.setCursor(SettingsStore.COLLECTION_BOOKCASES, it) }
}
private suspend fun applyIncomingBookcase(dto: BookcaseDto) {
val remoteUpdated = PbDateFormat.parseToEpochMillis(dto.updated)
val local = bookcaseDao.getByIdIncludingDeleted(dto.id)
if (shouldApplyRemote(local?.updatedAt, local?.syncState, remoteUpdated)) {
bookcaseDao.upsert(dto.toEntity(remoteUpdated))
}
}
private suspend fun pullShelves(api: PocketBaseApi) {
var cursor = settingsStore.cursorFor(SettingsStore.COLLECTION_SHELVES).first()
var page = 1
while (true) {
val filter = cursor?.takeIf { it.isNotBlank() }?.let { "(updated>'$it')" }
val response = api.listShelves(filter = filter, page = page)
for (dto in response.items) {
applyIncomingShelf(dto)
val c = cursor
if (c == null || dto.updated > c) cursor = dto.updated
}
if (response.items.isEmpty() || page >= response.totalPages) break
page++
}
cursor?.let { settingsStore.setCursor(SettingsStore.COLLECTION_SHELVES, it) }
}
private suspend fun applyIncomingShelf(dto: ShelfDto) {
val remoteUpdated = PbDateFormat.parseToEpochMillis(dto.updated)
val local = shelfDao.getByIdIncludingDeleted(dto.id)
if (shouldApplyRemote(local?.updatedAt, local?.syncState, remoteUpdated)) {
shelfDao.upsert(dto.toEntity(remoteUpdated))
}
}
private suspend fun pullBooks(api: PocketBaseApi) {
var cursor = settingsStore.cursorFor(SettingsStore.COLLECTION_BOOKS).first()
var page = 1
while (true) {
val filter = cursor?.takeIf { it.isNotBlank() }?.let { "(updated>'$it')" }
val response = api.listBooks(filter = filter, page = page)
for (dto in response.items) {
applyIncomingBook(dto)
val c = cursor
if (c == null || dto.updated > c) cursor = dto.updated
}
if (response.items.isEmpty() || page >= response.totalPages) break
page++
}
cursor?.let { settingsStore.setCursor(SettingsStore.COLLECTION_BOOKS, it) }
}
private suspend fun applyIncomingBook(dto: BookDto) {
val remoteUpdated = PbDateFormat.parseToEpochMillis(dto.updated)
val local = bookDao.getByIdIncludingDeleted(dto.id)
if (shouldApplyRemote(local?.updatedAt, local?.syncState, remoteUpdated)) {
val baseUrl = settingsStore.serverUrl.first()
val coverUrl = if (dto.cover.isNotBlank() && baseUrl != null) {
"$baseUrl/api/files/books/${dto.id}/${dto.cover}"
} else {
null
}
bookDao.upsert(dto.toEntity(remoteUpdated, localCoverPath = local?.localCoverPath, coverUrl = coverUrl))
}
}
companion object {
/**
* Last-write-wins conflict resolution (SPEC: "do not build anything
* cleverer"). No local record -> always take remote. A locally-synced
* record -> remote wins unless it's stale (can happen with clock
* skew / re-delivery). A locally-dirty record (push failed this
* round, so it's still pending) only loses if the remote copy is
* strictly newer.
*/
internal fun shouldApplyRemote(localUpdatedAt: Long?, localSyncState: SyncState?, remoteUpdatedAt: Long): Boolean {
if (localUpdatedAt == null || localSyncState == null) return true
return if (localSyncState == SyncState.SYNCED) {
remoteUpdatedAt >= localUpdatedAt
} else {
remoteUpdatedAt > localUpdatedAt
}
}
}
}
// ---------------------------------------------------------------- mapping
private fun BookEntity.toDto(): BookDto = BookDto(
id = id,
title = title,
subtitle = subtitle ?: "",
authors = decodeAuthors(authorsJson),
isbn13 = isbn13 ?: "",
isbn10 = isbn10 ?: "",
publisher = publisher ?: "",
publishedDate = publishedDate ?: "",
pageCount = pageCount,
description = description ?: "",
coverSourceUrl = coverSourceUrl ?: "",
shelf = shelfId ?: "",
notes = notes ?: "",
addedBy = addedBy ?: "",
deleted = deleted,
)
private fun BookEntity.synced(updated: String): BookEntity =
copy(syncState = SyncState.SYNCED, updatedAt = PbDateFormat.parseToEpochMillis(updated, fallback = updatedAt))
private fun BookDto.toEntity(updatedMillis: Long, localCoverPath: String?, coverUrl: String?): BookEntity = BookEntity(
id = id,
title = title,
subtitle = subtitle.ifBlank { null },
authorsJson = encodeAuthors(authors),
isbn13 = isbn13.ifBlank { null },
isbn10 = isbn10.ifBlank { null },
publisher = publisher.ifBlank { null },
publishedDate = publishedDate.ifBlank { null },
pageCount = pageCount,
description = description.ifBlank { null },
coverUrl = coverUrl,
coverSourceUrl = coverSourceUrl.ifBlank { null },
shelfId = shelf.ifBlank { null },
notes = notes.ifBlank { null },
addedBy = addedBy.ifBlank { null },
deleted = deleted,
createdAt = PbDateFormat.parseToEpochMillis(created, fallback = updatedMillis),
updatedAt = updatedMillis,
syncState = SyncState.SYNCED,
localCoverPath = localCoverPath,
)
private fun BookcaseEntity.toDto(): BookcaseDto = BookcaseDto(
id = id,
name = name,
note = note ?: "",
position = position,
deleted = deleted,
)
private fun BookcaseEntity.synced(updated: String): BookcaseEntity =
copy(syncState = SyncState.SYNCED, updatedAt = PbDateFormat.parseToEpochMillis(updated, fallback = updatedAt))
private fun BookcaseDto.toEntity(updatedMillis: Long): BookcaseEntity = BookcaseEntity(
id = id,
name = name,
note = note.ifBlank { null },
position = position,
deleted = deleted,
createdAt = PbDateFormat.parseToEpochMillis(created, fallback = updatedMillis),
updatedAt = updatedMillis,
syncState = SyncState.SYNCED,
)
private fun ShelfEntity.toDto(): ShelfDto = ShelfDto(
id = id,
bookcase = bookcaseId,
label = label,
position = position,
deleted = deleted,
)
private fun ShelfEntity.synced(updated: String): ShelfEntity =
copy(syncState = SyncState.SYNCED, updatedAt = PbDateFormat.parseToEpochMillis(updated, fallback = updatedAt))
private fun ShelfDto.toEntity(updatedMillis: Long): ShelfEntity = ShelfEntity(
id = id,
bookcaseId = bookcase,
label = label,
position = position,
deleted = deleted,
createdAt = PbDateFormat.parseToEpochMillis(created, fallback = updatedMillis),
updatedAt = updatedMillis,
syncState = SyncState.SYNCED,
)
@@ -0,0 +1,48 @@
package org.modg.bookshelf.ui.scan
import androidx.camera.core.ExperimentalGetImage
import androidx.camera.core.ImageAnalysis
import androidx.camera.core.ImageProxy
import com.google.mlkit.vision.barcode.BarcodeScannerOptions
import com.google.mlkit.vision.barcode.BarcodeScanning
import com.google.mlkit.vision.barcode.common.Barcode
import com.google.mlkit.vision.common.InputImage
/**
* CameraX [ImageAnalysis.Analyzer] wrapping ML Kit BarcodeScanning, restricted to the
* book-barcode formats per SPEC.md "Barcode scanning" (EAN_13, EAN_8, UPC_A). Every
* decoded value is routed through [controller]'s [ScannerController.onBarcodeScanned],
* which owns checksum validation and debouncing — this class is thin CameraX/ML Kit glue.
*/
class IsbnBarcodeAnalyzer(
private val controller: ScannerController,
) : ImageAnalysis.Analyzer {
private val scanner = BarcodeScanning.getClient(
BarcodeScannerOptions.Builder()
.setBarcodeFormats(
Barcode.FORMAT_EAN_13,
Barcode.FORMAT_EAN_8,
Barcode.FORMAT_UPC_A,
)
.build()
)
@ExperimentalGetImage
override fun analyze(imageProxy: ImageProxy) {
val mediaImage = imageProxy.image
if (mediaImage == null) {
imageProxy.close()
return
}
val image = InputImage.fromMediaImage(mediaImage, imageProxy.imageInfo.rotationDegrees)
scanner.process(image)
.addOnSuccessListener { barcodes -> barcodes.forEach { controller.onBarcodeScanned(it.rawValue) } }
.addOnCompleteListener { imageProxy.close() }
}
/** Releases the underlying ML Kit detector. Call when the camera use case is torn down. */
fun close() {
scanner.close()
}
}
@@ -0,0 +1,34 @@
package org.modg.bookshelf.ui.scan
import org.modg.bookshelf.data.metadata.IsbnUtils
/**
* Pure decode-to-ISBN pipeline shared by the analyzer and its tests: normalizes a raw
* barcode value, validates the ISBN-13 checksum (this is what makes EAN_8/UPC_A reads
* fall out as "non-book barcodes" per SPEC.md "Barcode scanning" — they can never be
* 13 digits), and debounces repeat reads of the same code.
*/
class ScanCodeFilter(
private val debounceMillis: Long = 2000L,
private val nowMillis: () -> Long = System::currentTimeMillis,
) {
private var lastCode: String? = null
private var lastEmitMillis: Long = Long.MIN_VALUE
/** Returns the normalized ISBN-13 if [rawValue] is a valid, non-debounced hit; null otherwise. */
fun accept(rawValue: String?): String? {
val normalized = IsbnUtils.normalize(rawValue ?: return null)
if (!IsbnUtils.isValidIsbn13(normalized)) return null
val now = nowMillis()
if (normalized == lastCode && now - lastEmitMillis < debounceMillis) return null
lastCode = normalized
lastEmitMillis = now
return normalized
}
/** Allows the next scan of any code (including a repeat) to emit immediately, e.g. after Skip. */
fun reset() {
lastCode = null
lastEmitMillis = Long.MIN_VALUE
}
}
@@ -0,0 +1,45 @@
package org.modg.bookshelf.ui.scan
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.flow.asStateFlow
/**
* Small stateful holder for the scan screen (SPEC.md "Barcode scanning") that wave 3's
* screen wraps. Owns debounced scan-result plumbing plus torch and camera-permission
* state; deliberately has no CameraX/ML Kit dependency so it's plain-unit-testable.
*/
class ScannerController(
private val codeFilter: ScanCodeFilter = ScanCodeFilter(),
) {
private val _scanResults = MutableSharedFlow<String>(extraBufferCapacity = 1)
/** Emits a debounced, checksum-valid ISBN-13 each time [IsbnBarcodeAnalyzer] sees a new book barcode. */
val scanResults: SharedFlow<String> = _scanResults.asSharedFlow()
private val _torchEnabled = MutableStateFlow(false)
val torchEnabled: StateFlow<Boolean> = _torchEnabled.asStateFlow()
private val _permissionDenied = MutableStateFlow(false)
val permissionDenied: StateFlow<Boolean> = _permissionDenied.asStateFlow()
/** Called by [IsbnBarcodeAnalyzer] for every decoded barcode's raw value. */
fun onBarcodeScanned(rawValue: String?) {
codeFilter.accept(rawValue)?.let { _scanResults.tryEmit(it) }
}
fun toggleTorch() {
_torchEnabled.value = !_torchEnabled.value
}
fun setPermissionDenied(denied: Boolean) {
_permissionDenied.value = denied
}
/** Call after a save/skip so the same book's barcode can be scanned again immediately (continuous mode). */
fun resetDebounce() {
codeFilter.reset()
}
}
@@ -0,0 +1,127 @@
package org.modg.bookshelf.data.local
import androidx.room.Room
import androidx.test.core.app.ApplicationProvider
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.test.runTest
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
/**
* Real Room, in-memory, run on the JVM via Robolectric — proves the actual
* SQL, not a hand-rolled fake. The specific thing SPEC calls out to verify:
* every read-facing query filters `deleted = 0`.
*/
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [34])
class BookDaoTest {
private lateinit var db: BookshelfDatabase
private lateinit var dao: BookDao
@Before
fun setUp() {
db = Room.inMemoryDatabaseBuilder(ApplicationProvider.getApplicationContext(), BookshelfDatabase::class.java)
.allowMainThreadQueries()
.build()
dao = db.bookDao()
}
@After
fun tearDown() {
db.close()
}
private fun book(id: String, title: String, deleted: Boolean = false, shelfId: String? = null) = BookEntity(
id = id,
title = title,
deleted = deleted,
shelfId = shelfId,
createdAt = 1_000L,
updatedAt = 1_000L,
syncState = SyncState.SYNCED,
)
@Test
fun `observeAll excludes soft-deleted rows`() = runTest {
dao.upsertAll(
listOf(
book("a", "Alpha"),
book("b", "Beta", deleted = true),
book("c", "Charlie"),
),
)
val visible = dao.observeAll().first()
assertEquals(setOf("a", "c"), visible.map { it.id }.toSet())
assertTrue(visible.none { it.deleted })
}
@Test
fun `getById returns null for a soft-deleted row but getByIdIncludingDeleted still finds it`() = runTest {
dao.upsert(book("a", "Alpha", deleted = true))
assertNull(dao.getById("a"))
assertEquals("Alpha", dao.getByIdIncludingDeleted("a")?.title)
}
@Test
fun `observeByShelf filters both by shelf and by deleted`() = runTest {
dao.upsertAll(
listOf(
book("a", "On shelf, visible", shelfId = "shelf1"),
book("b", "On shelf, deleted", deleted = true, shelfId = "shelf1"),
book("c", "Other shelf", shelfId = "shelf2"),
),
)
val result = dao.observeByShelf("shelf1").first()
assertEquals(listOf("a"), result.map { it.id })
}
@Test
fun `countByShelf ignores soft-deleted books`() = runTest {
dao.upsertAll(
listOf(
book("a", "One", shelfId = "shelf1"),
book("b", "Two", shelfId = "shelf1"),
book("c", "Deleted", deleted = true, shelfId = "shelf1"),
),
)
assertEquals(2, dao.countByShelf("shelf1"))
}
@Test
fun `getPendingSync only returns rows that still need pushing`() = runTest {
dao.upsertAll(
listOf(
book("a", "Synced").copy(syncState = SyncState.SYNCED),
book("b", "New").copy(syncState = SyncState.PENDING_CREATE),
book("c", "Edited").copy(syncState = SyncState.PENDING_UPDATE),
),
)
val pending = dao.getPendingSync().map { it.id }.toSet()
assertEquals(setOf("b", "c"), pending)
}
@Test
fun `hardDelete actually removes the row, unlike soft delete`() = runTest {
dao.upsert(book("a", "Alpha"))
dao.hardDelete("a")
assertNull(dao.getByIdIncludingDeleted("a"))
}
}
@@ -0,0 +1,29 @@
package org.modg.bookshelf.data.local
import kotlin.random.Random
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
class IdGeneratorTest {
@Test
fun `id is 15 chars of lowercase alphanumerics`() {
val id = IdGenerator.newId()
assertEquals(15, id.length)
assertTrue("id was '$id'", id.matches(Regex("^[a-z0-9]{15}$")))
}
@Test
fun `many generated ids are unique`() {
val ids = (1..5_000).map { IdGenerator.newId() }.toSet()
assertEquals(5_000, ids.size)
}
@Test
fun `is deterministic for a seeded random, proving the alphabet is exactly a-z0-9`() {
val id = IdGenerator.newId(Random(42))
assertEquals(15, id.length)
assertTrue(id.all { it in "abcdefghijklmnopqrstuvwxyz0123456789" })
}
}
@@ -0,0 +1,71 @@
package org.modg.bookshelf.data.local
import androidx.room.Room
import androidx.test.core.app.ApplicationProvider
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.test.runTest
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
/** Same `deleted = 0` contract as books, for bookcases/shelves. */
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [34])
class LocationDaoTest {
private lateinit var db: BookshelfDatabase
@Before
fun setUp() {
db = Room.inMemoryDatabaseBuilder(ApplicationProvider.getApplicationContext(), BookshelfDatabase::class.java)
.allowMainThreadQueries()
.build()
}
@After
fun tearDown() = db.close()
private fun bookcase(id: String, name: String, deleted: Boolean = false, position: Int = 0) = BookcaseEntity(
id = id, name = name, deleted = deleted, position = position, createdAt = 0L, updatedAt = 0L, syncState = SyncState.SYNCED,
)
private fun shelf(id: String, bookcaseId: String, label: String, deleted: Boolean = false, position: Int = 0) = ShelfEntity(
id = id, bookcaseId = bookcaseId, label = label, deleted = deleted, position = position, createdAt = 0L, updatedAt = 0L, syncState = SyncState.SYNCED,
)
@Test
fun `bookcases observeAll excludes deleted and sorts by position`() = runTest {
val dao = db.bookcaseDao()
dao.upsertAll(
listOf(
bookcase("a", "Living room", position = 1),
bookcase("b", "Attic", deleted = true, position = 0),
bookcase("c", "Office", position = 0),
),
)
val visible = dao.observeAll().first()
assertEquals(listOf("c", "a"), visible.map { it.id })
}
@Test
fun `shelves observeByBookcase filters by parent and by deleted`() = runTest {
val dao = db.shelfDao()
dao.upsertAll(
listOf(
shelf("s1", "case1", "Top"),
shelf("s2", "case1", "Bottom", deleted = true),
shelf("s3", "case2", "Only shelf"),
),
)
val result = dao.observeByBookcase("case1").first()
assertEquals(listOf("s1"), result.map { it.id })
}
}
@@ -0,0 +1,53 @@
package org.modg.bookshelf.data.metadata
import kotlinx.serialization.json.Json
import okhttp3.OkHttpClient
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Test
/**
* Parses checked-in sample JSON fixtures (src/test/resources/fixtures) — no network
* involved, offline-safe. [GoogleBooksClient.parseResponse] is the unit under test;
* [GoogleBooksClient.lookup] itself is untested here because it requires a live socket.
*/
class GoogleBooksClientTest {
private val client = GoogleBooksClient(OkHttpClient(), Json)
@Test
fun `parses a successful response into BookMetadata and forces https zoom=2 on the cover`() {
val body = fixture("googlebooks_success.json")
val result = client.parseResponse(body)
checkNotNull(result)
assertEquals("Effective Java", result.title)
assertEquals(listOf("Joshua Bloch"), result.authors)
assertEquals("Addison-Wesley Professional", result.publisher)
assertEquals("2017-12-27", result.publishedDate)
assertEquals(412, result.pageCount)
assertEquals("9780134685991", result.isbn13)
assertEquals("0134685997", result.isbn10)
assertEquals(
"https://books.google.com/books/content?id=ABC123XYZ&printsec=frontcover&img=1&zoom=2",
result.coverUrl,
)
}
@Test
fun `returns null when there are no items`() {
val body = fixture("googlebooks_no_items.json")
assertNull(client.parseResponse(body))
}
@Test
fun `fails soft on malformed json instead of throwing`() {
val body = fixture("malformed.json")
assertNull(client.parseResponse(body))
}
private fun fixture(name: String): String =
checkNotNull(javaClass.classLoader.getResourceAsStream("fixtures/$name")) { "missing fixture $name" }
.bufferedReader()
.readText()
}
@@ -0,0 +1,69 @@
package org.modg.bookshelf.data.metadata
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
class IsbnUtilsTest {
@Test
fun `normalize strips hyphens and spaces and uppercases`() {
assertEquals("9780201558029", IsbnUtils.normalize("978-0-201-55802-9"))
assertEquals("9780201558029", IsbnUtils.normalize(" 978 0201558029 "))
assertEquals("020155802X", IsbnUtils.normalize("0-201-55802-x"))
}
@Test
fun `isValidIsbn13 accepts known-valid checksums`() {
assertTrue(IsbnUtils.isValidIsbn13("9780201558029"))
assertTrue(IsbnUtils.isValidIsbn13("9780134685991"))
}
@Test
fun `isValidIsbn13 rejects known-invalid checksums and wrong length`() {
assertFalse(IsbnUtils.isValidIsbn13("9780201558020")) // bad check digit
assertFalse(IsbnUtils.isValidIsbn13("978020155802")) // 12 digits
assertFalse(IsbnUtils.isValidIsbn13("97802015580299")) // 14 digits
assertFalse(IsbnUtils.isValidIsbn13("978020155802X")) // non-digit
}
@Test
fun `isValidIsbn10 accepts known-valid checksums including trailing X`() {
assertTrue(IsbnUtils.isValidIsbn10("0201558025"))
assertTrue(IsbnUtils.isValidIsbn10("0132350882"))
}
@Test
fun `isValidIsbn10 rejects known-invalid checksums`() {
assertFalse(IsbnUtils.isValidIsbn10("0201558020"))
assertFalse(IsbnUtils.isValidIsbn10("013235088X"))
}
@Test
fun `isbn10ToIsbn13 converts correctly`() {
assertEquals("9780201558029", IsbnUtils.isbn10ToIsbn13("0201558025"))
}
@Test
fun `isbn10ToIsbn13 returns null for invalid isbn10`() {
assertNull(IsbnUtils.isbn10ToIsbn13("0201558020"))
}
@Test
fun `toIsbn13 passes through a valid isbn13`() {
assertEquals("9780201558029", IsbnUtils.toIsbn13("978-0-201-55802-9"))
}
@Test
fun `toIsbn13 converts a valid hyphenated isbn10`() {
assertEquals("9780201558029", IsbnUtils.toIsbn13("0-201-55802-5"))
}
@Test
fun `toIsbn13 returns null for garbage input`() {
assertNull(IsbnUtils.toIsbn13("not-an-isbn"))
assertNull(IsbnUtils.toIsbn13("12345"))
}
}
@@ -0,0 +1,72 @@
package org.modg.bookshelf.data.metadata
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Test
class MetadataMergerTest {
private val ol = BookMetadata(
isbn13 = "9780201558029",
title = "Concrete Mathematics",
subtitle = "A Foundation for Computer Science",
authors = listOf("Ronald L. Graham", "Donald E. Knuth"),
publisher = "Addison-Wesley",
publishedDate = "1994",
pageCount = 672,
coverUrl = "https://covers.openlibrary.org/b/isbn/9780201558029-L.jpg",
)
private val gb = BookMetadata(
isbn13 = "9780201558029",
isbn10 = "0201558025",
title = "Concrete Mathematics",
description = "A blend of continuous and discrete mathematics.",
pageCount = 660,
coverUrl = "https://books.google.com/cover.jpg",
)
@Test
fun `OL-only returns OL data untouched`() {
assertEquals(ol, MetadataMerger.merge(ol, null))
}
@Test
fun `GB-only returns GB data untouched`() {
assertEquals(gb, MetadataMerger.merge(null, gb))
}
@Test
fun `neither has a title returns null`() {
val olNoTitle = ol.copy(title = null)
val gbNoTitle = gb.copy(title = "")
assertNull(MetadataMerger.merge(olNoTitle, gbNoTitle))
assertNull(MetadataMerger.merge(null, null))
}
@Test
fun `both present prefers OL as primary and fills blanks from GB`() {
val merged = MetadataMerger.merge(ol, gb)
checkNotNull(merged)
// OL has a title, so OL wins as primary.
assertEquals(ol.title, merged.title)
assertEquals(ol.subtitle, merged.subtitle)
assertEquals(ol.authors, merged.authors)
assertEquals(ol.publisher, merged.publisher)
// OL's own pageCount is non-null, so GB's must NOT override it.
assertEquals(ol.pageCount, merged.pageCount)
// OL has no description; GB fills the blank.
assertEquals(gb.description, merged.description)
// OL's isbn10 is blank; GB fills it.
assertEquals(gb.isbn10, merged.isbn10)
}
@Test
fun `GB is primary when only GB has a title, and OL fills its blanks`() {
val olNoTitle = BookMetadata(title = null, publisher = "Addison-Wesley")
val merged = MetadataMerger.merge(olNoTitle, gb)
checkNotNull(merged)
assertEquals(gb.title, merged.title)
assertEquals(olNoTitle.publisher, merged.publisher)
}
}
@@ -0,0 +1,51 @@
package org.modg.bookshelf.data.metadata
import kotlinx.serialization.json.Json
import okhttp3.OkHttpClient
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Test
/**
* Parses checked-in sample JSON fixtures (src/test/resources/fixtures) — no network
* involved, offline-safe. [OpenLibraryClient.parseResponse] is the unit under test;
* [OpenLibraryClient.lookup] itself is untested here because it requires a live socket.
*/
class OpenLibraryClientTest {
private val client = OpenLibraryClient(OkHttpClient(), Json)
@Test
fun `parses a successful response into BookMetadata`() {
val body = fixture("openlibrary_success.json")
val result = client.parseResponse(body, "9780201558029")
checkNotNull(result)
assertEquals("Concrete Mathematics", result.title)
assertEquals("A Foundation for Computer Science", result.subtitle)
assertEquals(listOf("Ronald L. Graham", "Donald E. Knuth"), result.authors)
assertEquals("Addison-Wesley Professional", result.publisher)
assertEquals("1994", result.publishedDate)
assertEquals(672, result.pageCount)
assertEquals("9780201558029", result.isbn13)
assertEquals("0201558025", result.isbn10)
assertEquals("https://covers.openlibrary.org/b/isbn/9780201558029-L.jpg", result.coverUrl)
}
@Test
fun `returns null when the isbn key is absent from the response`() {
val body = fixture("openlibrary_not_found.json")
assertNull(client.parseResponse(body, "9780201558029"))
}
@Test
fun `fails soft on malformed json instead of throwing`() {
val body = fixture("malformed.json")
assertNull(client.parseResponse(body, "9780201558029"))
}
private fun fixture(name: String): String =
checkNotNull(javaClass.classLoader.getResourceAsStream("fixtures/$name")) { "missing fixture $name" }
.bufferedReader()
.readText()
}
@@ -0,0 +1,37 @@
package org.modg.bookshelf.data.remote
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
import java.time.Instant
class PbDateFormatTest {
@Test
fun `parses PocketBase's space-separated UTC format`() {
val millis = PbDateFormat.parseToEpochMillis("2024-01-02 15:04:05.123Z")
val expected = Instant.parse("2024-01-02T15:04:05.123Z").toEpochMilli()
assertEquals(expected, millis)
}
@Test
fun `round-trips format then parse`() {
val original = Instant.parse("2025-06-15T09:30:00.000Z").toEpochMilli()
val formatted = PbDateFormat.formatEpochMillis(original)
assertEquals(original, PbDateFormat.parseToEpochMillis(formatted))
}
@Test
fun `blank or malformed input falls back instead of throwing`() {
assertEquals(42L, PbDateFormat.parseToEpochMillis("", fallback = 42L))
assertEquals(42L, PbDateFormat.parseToEpochMillis("not a date", fallback = 42L))
}
@Test
fun `lexical string comparison matches chronological order, since cursors compare as strings`() {
val earlier = "2024-01-02 10:00:00.000Z"
val later = "2024-01-02 10:00:00.500Z"
assertTrue(earlier < later)
assertTrue(PbDateFormat.parseToEpochMillis(earlier) < PbDateFormat.parseToEpochMillis(later))
}
}
@@ -0,0 +1,126 @@
package org.modg.bookshelf.data.repo
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.MultipartBody
import okhttp3.ResponseBody.Companion.toResponseBody
import org.modg.bookshelf.data.remote.AuthResponse
import org.modg.bookshelf.data.remote.AuthWithPasswordRequest
import org.modg.bookshelf.data.remote.BookDto
import org.modg.bookshelf.data.remote.BookcaseDto
import org.modg.bookshelf.data.remote.PbListResponse
import org.modg.bookshelf.data.remote.PocketBaseApi
import org.modg.bookshelf.data.remote.ShelfDto
import retrofit2.HttpException
import retrofit2.Response
fun httpError(code: Int): HttpException {
val body = "{}".toResponseBody("application/json".toMediaType())
return HttpException(Response.error<Any>(code, body))
}
/**
* Hand-rolled fake (project has no mocking library) standing in for the
* PocketBase server. Tracks call order so push-ordering can be asserted, and
* supports one-shot error injection so 404/409 handling can be exercised.
*/
class FakePocketBaseApi : PocketBaseApi {
val callLog = mutableListOf<String>()
private val bookcases = mutableMapOf<String, BookcaseDto>()
private val shelves = mutableMapOf<String, ShelfDto>()
private val books = mutableMapOf<String, BookDto>()
var nextCreateBookcaseError: HttpException? = null
var nextCreateShelfError: HttpException? = null
var nextCreateBookError: HttpException? = null
val updateBookcaseErrors = mutableMapOf<String, HttpException>()
val updateShelfErrors = mutableMapOf<String, HttpException>()
val updateBookErrors = mutableMapOf<String, HttpException>()
fun seedBook(dto: BookDto) {
books[dto.id] = dto
}
override suspend fun authWithPassword(body: AuthWithPasswordRequest): AuthResponse =
AuthResponse(token = "fake-token")
// ---- books ----
override suspend fun listBooks(filter: String?, sort: String, perPage: Int, page: Int): PbListResponse<BookDto> {
val cursor = filter?.substringAfter("'")?.substringBefore("'")
val items = books.values.filter { cursor == null || it.updated > cursor }.sortedBy { it.updated }
return PbListResponse(page = 1, totalPages = 1, totalItems = items.size, items = items)
}
override suspend fun createBook(body: BookDto): BookDto {
callLog += "createBook:${body.id}"
nextCreateBookError?.let { nextCreateBookError = null; throw it }
val stored = body.copy(created = "2024-01-01 00:00:00.000Z", updated = "2024-01-01 00:00:00.000Z")
books[body.id] = stored
return stored
}
override suspend fun updateBook(id: String, body: BookDto): BookDto {
callLog += "updateBook:$id"
updateBookErrors.remove(id)?.let { throw it }
val stored = body.copy(id = id, updated = "2024-01-02 00:00:00.000Z")
books[id] = stored
return stored
}
override suspend fun uploadBookCover(id: String, cover: MultipartBody.Part): BookDto {
callLog += "uploadBookCover:$id"
val existing = books[id] ?: BookDto(id = id)
val stored = existing.copy(cover = "cover.jpg")
books[id] = stored
return stored
}
// ---- shelves ----
override suspend fun listShelves(filter: String?, sort: String, perPage: Int, page: Int): PbListResponse<ShelfDto> {
val cursor = filter?.substringAfter("'")?.substringBefore("'")
val items = shelves.values.filter { cursor == null || it.updated > cursor }.sortedBy { it.updated }
return PbListResponse(page = 1, totalPages = 1, totalItems = items.size, items = items)
}
override suspend fun createShelf(body: ShelfDto): ShelfDto {
callLog += "createShelf:${body.id}"
nextCreateShelfError?.let { nextCreateShelfError = null; throw it }
val stored = body.copy(created = "2024-01-01 00:00:00.000Z", updated = "2024-01-01 00:00:00.000Z")
shelves[body.id] = stored
return stored
}
override suspend fun updateShelf(id: String, body: ShelfDto): ShelfDto {
callLog += "updateShelf:$id"
updateShelfErrors.remove(id)?.let { throw it }
val stored = body.copy(id = id, updated = "2024-01-02 00:00:00.000Z")
shelves[id] = stored
return stored
}
// ---- bookcases ----
override suspend fun listBookcases(filter: String?, sort: String, perPage: Int, page: Int): PbListResponse<BookcaseDto> {
val cursor = filter?.substringAfter("'")?.substringBefore("'")
val items = bookcases.values.filter { cursor == null || it.updated > cursor }.sortedBy { it.updated }
return PbListResponse(page = 1, totalPages = 1, totalItems = items.size, items = items)
}
override suspend fun createBookcase(body: BookcaseDto): BookcaseDto {
callLog += "createBookcase:${body.id}"
nextCreateBookcaseError?.let { nextCreateBookcaseError = null; throw it }
val stored = body.copy(created = "2024-01-01 00:00:00.000Z", updated = "2024-01-01 00:00:00.000Z")
bookcases[body.id] = stored
return stored
}
override suspend fun updateBookcase(id: String, body: BookcaseDto): BookcaseDto {
callLog += "updateBookcase:$id"
updateBookcaseErrors.remove(id)?.let { throw it }
val stored = body.copy(id = id, updated = "2024-01-02 00:00:00.000Z")
bookcases[id] = stored
return stored
}
}
@@ -0,0 +1,201 @@
package org.modg.bookshelf.data.repo
import androidx.room.Room
import androidx.test.core.app.ApplicationProvider
import kotlinx.coroutines.test.runTest
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import org.modg.bookshelf.data.local.BookEntity
import org.modg.bookshelf.data.local.BookcaseEntity
import org.modg.bookshelf.data.local.BookshelfDatabase
import org.modg.bookshelf.data.local.ShelfEntity
import org.modg.bookshelf.data.local.SyncState
import org.modg.bookshelf.data.prefs.SettingsStore
import org.modg.bookshelf.data.remote.BookDto
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [34])
class SyncEngineTest {
private lateinit var db: BookshelfDatabase
private lateinit var settingsStore: SettingsStore
private lateinit var api: FakePocketBaseApi
private lateinit var engine: SyncEngine
@Before
fun setUp() = runTest {
db = Room.inMemoryDatabaseBuilder(ApplicationProvider.getApplicationContext(), BookshelfDatabase::class.java)
.allowMainThreadQueries()
.build()
settingsStore = SettingsStore(ApplicationProvider.getApplicationContext())
settingsStore.setServerUrl("https://example.test")
api = FakePocketBaseApi()
engine = SyncEngine(
apiProvider = { api },
bookDao = db.bookDao(),
bookcaseDao = db.bookcaseDao(),
shelfDao = db.shelfDao(),
settingsStore = settingsStore,
)
}
@After
fun tearDown() = db.close()
private fun bookcase(id: String, state: SyncState = SyncState.PENDING_CREATE) = BookcaseEntity(
id = id, name = "Case $id", createdAt = 0L, updatedAt = 0L, syncState = state,
)
private fun shelf(id: String, bookcaseId: String, state: SyncState = SyncState.PENDING_CREATE) = ShelfEntity(
id = id, bookcaseId = bookcaseId, label = "Shelf $id", createdAt = 0L, updatedAt = 0L, syncState = state,
)
private fun book(id: String, shelfId: String? = null, state: SyncState = SyncState.PENDING_CREATE, updatedAt: Long = 0L) = BookEntity(
id = id, title = "Book $id", shelfId = shelfId, createdAt = 0L, updatedAt = updatedAt, syncState = state,
)
// ---------------------------------------------------------- LWW (pure)
@Test
fun `lww - no local record always takes remote`() {
assertTrue(SyncEngine.shouldApplyRemote(localUpdatedAt = null, localSyncState = null, remoteUpdatedAt = 100L))
}
@Test
fun `lww - synced local loses to a newer remote`() {
assertTrue(SyncEngine.shouldApplyRemote(localUpdatedAt = 100L, localSyncState = SyncState.SYNCED, remoteUpdatedAt = 200L))
}
@Test
fun `lww - synced local is kept if remote is older (stale re-delivery)`() {
assertTrue(!SyncEngine.shouldApplyRemote(localUpdatedAt = 200L, localSyncState = SyncState.SYNCED, remoteUpdatedAt = 100L))
}
@Test
fun `lww - dirty local with a newer edit beats an older remote`() {
assertTrue(!SyncEngine.shouldApplyRemote(localUpdatedAt = 200L, localSyncState = SyncState.PENDING_UPDATE, remoteUpdatedAt = 100L))
}
@Test
fun `lww - dirty local loses to a strictly newer remote edit (someone else won the race)`() {
assertTrue(SyncEngine.shouldApplyRemote(localUpdatedAt = 100L, localSyncState = SyncState.PENDING_UPDATE, remoteUpdatedAt = 200L))
}
@Test
fun `lww - exact tie keeps the dirty local instead of discarding an unpushed edit`() {
assertTrue(!SyncEngine.shouldApplyRemote(localUpdatedAt = 150L, localSyncState = SyncState.PENDING_UPDATE, remoteUpdatedAt = 150L))
}
// ---------------------------------------------------------- push ordering
@Test
fun `push order is bookcases then shelves then books`() = runTest {
db.bookDao().upsert(book("book1", shelfId = "shelf1"))
db.shelfDao().upsert(shelf("shelf1", bookcaseId = "case1"))
db.bookcaseDao().upsert(bookcase("case1"))
engine.sync()
val bookcaseIdx = api.callLog.indexOf("createBookcase:case1")
val shelfIdx = api.callLog.indexOf("createShelf:shelf1")
val bookIdx = api.callLog.indexOf("createBook:book1")
assertTrue("expected bookcase before shelf before book, got ${api.callLog}", bookcaseIdx in 0..<shelfIdx && shelfIdx < bookIdx)
}
@Test
fun `successful create push flips syncState to SYNCED`() = runTest {
db.bookcaseDao().upsert(bookcase("case1"))
engine.sync()
val stored = db.bookcaseDao().getById("case1")
assertEquals(SyncState.SYNCED, stored?.syncState)
}
@Test
fun `pending update push sends PATCH and lands SYNCED`() = runTest {
db.bookcaseDao().upsert(bookcase("case1", state = SyncState.PENDING_UPDATE))
engine.sync()
assertTrue(api.callLog.contains("updateBookcase:case1"))
assertEquals(SyncState.SYNCED, db.bookcaseDao().getByIdIncludingDeleted("case1")?.syncState)
}
@Test
fun `404 on update drops the local record entirely`() = runTest {
db.bookDao().upsert(book("book1", state = SyncState.PENDING_UPDATE))
api.updateBookErrors["book1"] = httpError(404)
engine.sync()
assertNull(db.bookDao().getByIdIncludingDeleted("book1"))
}
@Test
fun `404 on delete drops the local record entirely`() = runTest {
db.bookDao().upsert(book("book1", state = SyncState.PENDING_DELETE).copy(deleted = true))
api.updateBookErrors["book1"] = httpError(404)
engine.sync()
assertNull(db.bookDao().getByIdIncludingDeleted("book1"))
}
@Test
fun `409 duplicate id on create falls back to PATCH and still converges to SYNCED`() = runTest {
db.bookDao().upsert(book("book1"))
api.nextCreateBookError = httpError(409)
engine.sync()
assertTrue(api.callLog.contains("createBook:book1"))
assertTrue(api.callLog.contains("updateBook:book1"))
assertEquals(SyncState.SYNCED, db.bookDao().getByIdIncludingDeleted("book1")?.syncState)
}
// ---------------------------------------------------------- pull / conflict integration
@Test
fun `pull overwrites a synced local row with the newer remote copy`() = runTest {
db.bookDao().upsert(book("book1", state = SyncState.SYNCED, updatedAt = 1_000L).copy(title = "Old title"))
api.seedBook(
BookDto(id = "book1", title = "New title from server", updated = "2024-06-01 00:00:00.000Z", created = "2024-01-01 00:00:00.000Z"),
)
engine.sync()
assertEquals("New title from server", db.bookDao().getById("book1")?.title)
}
@Test
fun `pull keeps the local synced row when the remote copy is stale`() = runTest {
db.bookDao().upsert(book("book1", state = SyncState.SYNCED, updatedAt = 9_999_999_999_999L).copy(title = "Current title"))
api.seedBook(
BookDto(id = "book1", title = "Old server copy", updated = "2020-01-01 00:00:00.000Z", created = "2020-01-01 00:00:00.000Z"),
)
engine.sync()
assertEquals("Current title", db.bookDao().getById("book1")?.title)
}
@Test
fun `an unclassified push error aborts the sync without touching the dirty local row`() = runTest {
db.bookDao().upsert(book("book1", state = SyncState.PENDING_UPDATE).copy(title = "My local edit"))
api.updateBookErrors["book1"] = httpError(500)
val result = engine.sync()
assertTrue(result is SyncResult.Failure)
assertEquals("My local edit", db.bookDao().getByIdIncludingDeleted("book1")?.title)
assertEquals(SyncState.PENDING_UPDATE, db.bookDao().getByIdIncludingDeleted("book1")?.syncState)
}
}
@@ -0,0 +1,72 @@
package org.modg.bookshelf.ui.scan
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Test
class ScanCodeFilterTest {
@Test
fun `accepts a checksum-valid isbn13`() {
val filter = ScanCodeFilter()
assertEquals("9780201558029", filter.accept("9780201558029"))
}
@Test
fun `rejects a checksum-invalid isbn13-shaped code`() {
val filter = ScanCodeFilter()
assertNull(filter.accept("9780201558020"))
}
@Test
fun `rejects non-book barcode lengths such as EAN-8 or UPC-A`() {
val filter = ScanCodeFilter()
assertNull(filter.accept("12345670")) // EAN-8 shaped
assertNull(filter.accept("012345678905")) // UPC-A shaped, 12 digits
}
@Test
fun `rejects null raw value`() {
assertNull(ScanCodeFilter().accept(null))
}
@Test
fun `debounces a repeat of the same code within the window`() {
var now = 0L
val filter = ScanCodeFilter(debounceMillis = 2000L, nowMillis = { now })
assertEquals("9780201558029", filter.accept("9780201558029"))
now += 500
assertNull(filter.accept("9780201558029"))
}
@Test
fun `re-emits the same code once the debounce window elapses`() {
var now = 0L
val filter = ScanCodeFilter(debounceMillis = 2000L, nowMillis = { now })
assertEquals("9780201558029", filter.accept("9780201558029"))
now += 2001
assertEquals("9780201558029", filter.accept("9780201558029"))
}
@Test
fun `a different code is never debounced against the previous one`() {
var now = 0L
val filter = ScanCodeFilter(debounceMillis = 2000L, nowMillis = { now })
assertEquals("9780201558029", filter.accept("9780201558029"))
now += 10
assertEquals("9780134685991", filter.accept("9780134685991"))
}
@Test
fun `reset allows an immediate repeat`() {
var now = 0L
val filter = ScanCodeFilter(debounceMillis = 2000L, nowMillis = { now })
assertEquals("9780201558029", filter.accept("9780201558029"))
filter.reset()
assertEquals("9780201558029", filter.accept("9780201558029"))
}
}
@@ -0,0 +1,66 @@
package org.modg.bookshelf.ui.scan
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.launch
import kotlinx.coroutines.test.runCurrent
import kotlinx.coroutines.test.runTest
import kotlinx.coroutines.flow.toList
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
@OptIn(ExperimentalCoroutinesApi::class)
class ScannerControllerTest {
// scanResults has replay = 0 by design (a late subscriber must not see a stale scan),
// so these tests subscribe via backgroundScope BEFORE emitting, and use runCurrent()
// to pump the collector's subscription through before onBarcodeScanned runs.
@Test
fun `valid barcode emits on scanResults`() = runTest {
val controller = ScannerController()
val received = mutableListOf<String>()
backgroundScope.launch { controller.scanResults.toList(received) }
runCurrent()
controller.onBarcodeScanned("9780201558029")
runCurrent()
assertEquals(listOf("9780201558029"), received)
}
@Test
fun `invalid barcode does not reach scanResults`() = runTest {
val controller = ScannerController()
val received = mutableListOf<String>()
backgroundScope.launch { controller.scanResults.toList(received) }
runCurrent()
controller.onBarcodeScanned("not-an-isbn")
runCurrent()
assertTrue(received.isEmpty())
controller.onBarcodeScanned("9780201558029")
runCurrent()
assertEquals(listOf("9780201558029"), received)
}
@Test
fun `torch toggles from its default off state`() {
val controller = ScannerController()
assertFalse(controller.torchEnabled.value)
controller.toggleTorch()
assertTrue(controller.torchEnabled.value)
controller.toggleTorch()
assertFalse(controller.torchEnabled.value)
}
@Test
fun `permission denied state defaults false and reflects setPermissionDenied`() {
val controller = ScannerController()
assertFalse(controller.permissionDenied.value)
controller.setPermissionDenied(true)
assertTrue(controller.permissionDenied.value)
}
}
@@ -0,0 +1,4 @@
{
"kind": "books#volumes",
"totalItems": 0
}
@@ -0,0 +1,29 @@
{
"kind": "books#volumes",
"totalItems": 1,
"items": [
{
"kind": "books#volume",
"id": "ABC123XYZ",
"volumeInfo": {
"title": "Effective Java",
"authors": ["Joshua Bloch"],
"publisher": "Addison-Wesley Professional",
"publishedDate": "2017-12-27",
"description": "The Definitive Guide to Java Platform Best Practices",
"pageCount": 412,
"industryIdentifiers": [
{"type": "ISBN_10", "identifier": "0134685997"},
{"type": "ISBN_13", "identifier": "9780134685991"}
],
"imageLinks": {
"smallThumbnail": "http://books.google.com/books/content?id=ABC123XYZ&printsec=frontcover&img=1&zoom=5",
"thumbnail": "http://books.google.com/books/content?id=ABC123XYZ&printsec=frontcover&img=1&zoom=1"
},
"language": "en"
},
"saleInfo": {"country": "US", "saleability": "FOR_SALE"},
"accessInfo": {"country": "US", "viewability": "PARTIAL"}
}
]
}
@@ -0,0 +1 @@
{ this is not valid json ]
@@ -0,0 +1 @@
{}
@@ -0,0 +1,26 @@
{
"ISBN:9780201558029": {
"publishers": [{"name": "Addison-Wesley Professional"}],
"title": "Concrete Mathematics",
"subtitle": "A Foundation for Computer Science",
"identifiers": {
"isbn_10": ["0201558025"],
"isbn_13": ["9780201558029"]
},
"authors": [
{"name": "Ronald L. Graham", "url": "https://openlibrary.org/authors/OL123456A"},
{"name": "Donald E. Knuth"}
],
"number_of_pages": 672,
"publish_date": "1994",
"cover": {
"small": "https://covers.openlibrary.org/b/id/675832-S.jpg",
"medium": "https://covers.openlibrary.org/b/id/675832-M.jpg",
"large": "https://covers.openlibrary.org/b/id/675832-L.jpg"
},
"key": "/books/OL1234567M",
"url": "https://openlibrary.org/books/OL1234567M",
"notes": "unused field to prove unknown keys are ignored",
"excerpts": [{"text": "some excerpt"}]
}
}
+45 -21
View File
@@ -58,28 +58,52 @@ Independently re-verified on 09-06 after fixing the service:
filter, so anonymous LIST would otherwise return `200 []` instead of an error. The hook forces filter, so anonymous LIST would otherwise return `200 []` instead of an error. The hook forces
403. Keep it; it is why the table above passes. It is auto-loaded by the stock binary. 403. Keep it; it is why the table above passes. It is auto-loaded by the stock binary.
### Wave 1B — Android scaffold + design system: COMPLETE, verified by the orchestrator
The pre-restart worker had gotten much further than the last handoff recorded. On 09-06 the
orchestrator found everything on disk (theme, 7 shared components, MainActivity,
BookshelfApplication, Paparazzi test, all 8 Literata TTFs) and only THREE compile errors,
all the same class of trivial import bug — fixed directly by the orchestrator rather than
spending a worker session on two-line edits:
- `import androidx.compose.foundation.layout.weight` (x2: BookshelfScaffold.kt, the Paparazzi
test) — that resolves to the *internal* `RowColumnParentData.weight`. `weight` is a
ColumnScope/RowScope member; it needs NO import. Delete the line.
- SyncStatusBar.kt was missing `import androidx.compose.runtime.getValue`, so `val x by
transition.animateFloat(...)` had no delegate.
| Check | Result |
|---|---|
| `./gradlew assembleDebug` | **exit 0** — app-debug.apk, 47MB |
| `./gradlew testDebugUnitTest` | **exit 0** |
| `./gradlew recordPaparazziDebug` | **exit 0** — 10 PNGs, light+dark |
Snapshots: `app/app/src/test/snapshots/images/`. The orchestrator eyeballed scaffold-light:
warm paper ground, Literata serif title, thin gold hairline rule. Matches the design language.
### Repo is now a git repo
`git init` + baseline commit `8bcd9f7` at the 1B-green point. This is deliberate: it lets the
orchestrator verify a wave with `git diff --stat` / `git log` instead of reading source files
into Opus context, and gives a rollback that isn't a whole-sprite checkpoint restore.
Root `.gitignore` covers build outputs, `server/pb_data`, `.dev-credentials`, worker logs.
## STATE: what is IN FLIGHT
### Wave 2 — C (data layer) + D (metadata/scanning): LAUNCHED 09-06 ~01:59Z, running in parallel
Prompts: `tasks/C-data.txt`, `tasks/D-metadata.txt`. Sessions:
C-data=c2b92ca5-55d7-49b2-8a89-dc36e3ba4c9f, D-metadata=9a2c0de8-e475-4a43-9302-bc66889ee2bd
Two coordination devices were put in place before launch; keep them for wave 3:
1. **`tasks/gw` — a `flock`-serialized gradle wrapper.** Both workers share ONE Gradle project
dir; concurrent `./gradlew` runs clobber each other's outputs. Both prompts forbid
`./gradlew` and require `tasks/gw`. Reuse this for every future parallel wave.
2. **Disjoint file ownership, stated as a hard boundary in each prompt.** C owns data.local,
data.remote, data.repo, data.prefs, AppContainer, BookshelfApplication. D owns data.metadata
and ui.scan plumbing. NEITHER may touch `app/build.gradle.kts` or `libs.versions.toml` —
the orchestrator confirmed every wave-2 dependency is ALREADY declared and wired, so there
is no legitimate reason for a worker to edit a build file. D must not wire MetadataRepository
into AppContainer (C owns it); D reports the one-line snippet instead, to be applied later.
## STATE: what is NOT done ## STATE: what is NOT done
### Wave 1B — Android scaffold + design system: INCOMPLETE (killed mid-run by the restart) ### Waves 3-4 — not started. Prompts not yet written.
Present: gradle wrapper, `gradle/libs.versions.toml`, `app/build.gradle.kts`, - **Wave 3 (after C+D land and are verified):** E — the six screens (setup, library, detail, scan, locations, settings).
`AndroidManifest.xml`, `proguard-rules.pro`, Literata OFL license.
Missing/unverified: ui/theme (Color/Type/Theme), the shared component set, MainActivity,
Paparazzi setup, and **any evidence the build compiles**.
**Its session SURVIVED and is resumable — prefer this over a restart (saves quota):**
`claude -p --model sonnet --permission-mode bypassPermissions --output-format json \`
` --add-dir ~/bookshelf --resume 6823e72a-69c1-486e-ae5a-18abab84529b`
with a "continue where you left off, don't restart" prompt. (Worker A's session, for
reference, is `5e3bd183-252c-4224-99b5-91779761ccbc`.)
First thing the resumed worker must do: get `./gradlew assembleDebug` GREEN. Everything
downstream is blocked on it.
### Waves 2-4 — not started. Prompts not yet written.
- **Wave 2 (parallel, after 1B is green):**
- C — data layer: Room entities/DAOs/DB, PocketBase Retrofit client + auth interceptor,
`SyncEngine` (push-then-pull, LWW, tombstones, client-generated 15-char ids), SettingsStore.
- D — metadata + scanning: Open Library + Google Books merge, ISBN-13 checksum validation,
CameraX + ML Kit continuous scanning.
- **Wave 3 (after C+D):** E — the six screens (setup, library, detail, scan, locations, settings).
- **Wave 4:** F — Paparazzi screenshots for the user to judge the look, release keystore + - **Wave 4:** F — Paparazzi screenshots for the user to judge the look, release keystore +
signed APK, top-level README, end-to-end sync test against the live PocketBase. signed APK, top-level README, end-to-end sync test against the live PocketBase.
+1
View File
@@ -0,0 +1 @@
c2b92ca5-55d7-49b2-8a89-dc36e3ba4c9f
+11
View File
@@ -0,0 +1,11 @@
=== C-data attempts=1 quota_waits=0 hard_fails=0 ===
cost=$2.0208028 turns=65 err=?
--- result (tail) ---
`BookshelfApplication.kt`
- Tests: `IdGeneratorTest`, `PbDateFormatTest`, `BookDaoTest`, `LocationDaoTest` (Robolectric, prove `deleted=0` filtering), `SyncEngineTest` (15 tests: LWW pure-function cases, push ordering, syncState transitions, 404/409, pull conflict resolution), `FakePocketBaseApi` (hand-written fake, no mocking lib available)
**Gaps / deviations, reported honestly:**
1. **Cover pipeline is best-effort, not fully spec-complete.** I implemented download-on-create (`BookRepository`, plain OkHttp GET to `coverSourceUrl` → app-private file) and multipart upload-on-sync (`SyncEngine.uploadCoverIfNeeded`), building the served `coverUrl` from PocketBase's `/api/files/...` convention. This is untested against a *real* PocketBase server (only via fakes) — I did not have time/scope to run an end-to-end live-server cover round-trip. Logic is sound but should get a real-server smoke test in a later wave.
2. **No Room foreign-key constraints** between books→shelves→bookcases (only indices). Deliberate: enforcing FK would make sync ordering brittle across paginated pulls; push/pull order (bookcases→shelves→books) already handles the realistic cases. Documented in `SyncEngine`'s class doc.
3. **Full library search/sort/filter surface is minimal** — `BookDao` exposes `observeAll`, `observeAllByRecentlyAdded`, `observeByShelf`, `search(query)`; I did not build every SPEC-listed sort/filter combination since that's UI-driven (wave 3/Worker E territory) and risked overengineering ahead of real requirements. Easy to extend.
4. Did not touch `app/build.gradle.kts` / `libs.versions.toml` per instructions — all needed deps (room+ksp, retrofit+kotlinx-serialization converter, okhttp, datastore, coroutines-test, robolectric) were already present and sufficient.
+1
View File
@@ -0,0 +1 @@
9a2c0de8-e475-4a43-9302-bc66889ee2bd
+4
View File
@@ -0,0 +1,4 @@
=== D-metadata attempts=1 quota_waits=0 hard_fails=0 ===
cost=$0.2020432 turns=6
--- result ---
Exit code 0, 0 failures (68 tests, all passing). Fix: rewrote the two `ScannerControllerTest` cases to `backgroundScope.launch { scanResults.toList(...) }` + `runCurrent()` before emitting, so the collector subscribes before `onBarcodeScanned` fires (replay=0 semantics kept unchanged in production code).
+53
View File
@@ -0,0 +1,53 @@
You are Worker C on the Bookshelf project (~/bookshelf). Implement the DATA LAYER.
FIRST, READ THESE — they are the contract, follow them exactly, do not invent
alternative names or restate them back to me:
~/bookshelf/docs/SPEC.md (authoritative product/technical spec)
~/bookshelf/docs/HANDOFF.md (operational state and gotchas already paid for)
## Your scope — these packages ONLY, under app/app/src/main/java/org/modg/bookshelf/
data.local Room: BookEntity/BookcaseEntity/ShelfEntity, SyncState, Converters,
BookDao/BookcaseDao/ShelfDao, BookshelfDatabase
data.remote PocketBaseApi (Retrofit), request/response DTOs, PbAuthInterceptor
data.repo BookRepository, LocationRepository, AuthRepository, SyncEngine
data.prefs SettingsStore (DataStore: server URL, auth token, per-collection
sync cursors, last-sync time)
Plus `AppContainer` (manual DI, per SPEC "NO Hilt/kapt") and its wiring into
the existing BookshelfApplication.kt.
## HARD BOUNDARIES — you share this repo with Worker D, running right now
- DO NOT create or edit anything under `data.metadata`, `ui.scan`, or any `ui.*`
package. Those are Worker D's / wave 3's. Touching them WILL cause a conflict.
- DO NOT edit `app/build.gradle.kts` or `gradle/libs.versions.toml`. Every
dependency you need (room+ksp, retrofit, kotlinx-serialization, okhttp,
datastore, work-runtime, robolectric, coroutines-test) is ALREADY declared and
wired. If you genuinely believe something is missing, DO NOT add it — say so in
your final report and work around it.
- Worker D will need metadata lookup reachable from AppContainer. Do NOT try to
wire it. Just leave AppContainer easy to extend; D exposes a plain class that
gets wired later.
## Build/verify — CRITICAL
Never run `./gradlew` directly; a second worker builds concurrently and you will
corrupt each other's build. ALWAYS build with the serialized wrapper:
~/bookshelf/tasks/gw assembleDebug
~/bookshelf/tasks/gw testDebugUnitTest
It takes the lock and may block until the other worker's build finishes. That is
expected — wait for it, do not bypass it.
## Definition of done — all must actually pass, verified by you, not assumed
1. `~/bookshelf/tasks/gw assembleDebug` exits 0.
2. `~/bookshelf/tasks/gw testDebugUnitTest` exits 0.
3. Real unit tests with real assertions (SPEC: "Do not write assertion-free tests"):
- SyncEngine conflict resolution / last-write-wins on `updated`
- push ordering + syncState transitions, incl. 404-on-update -> drop local,
409/duplicate-id -> switch to PATCH
- client-side 15-char lowercase-alnum id generation
- DAO queries via Robolectric, proving `deleted = 0` filtering works
4. Soft delete everywhere. All reads come from Room. Nothing blocks on network.
## Report back (keep it short — it is read by a token-constrained orchestrator)
- exact pass/fail of the two gradle commands above
- files created, one line each
- anything in SPEC.md you could NOT satisfy, and why. Do not paper over gaps:
a truthfully reported gap is worth more than a false green.
+32
View File
@@ -0,0 +1,32 @@
Your wave-2 work is mostly good and `assembleDebug` passes. But you ended your turn
reporting that `testDebugUnitTest` was "running in background, will report once it
completes" — you never confirmed it. The orchestrator ran it. IT FAILS.
68 tests ran, 2 failed, both yours, both in
`app/app/src/test/java/org/modg/bookshelf/ui/scan/ScannerControllerTest.kt`:
ScannerControllerTest > "valid barcode emits on scanResults"
ScannerControllerTest > "invalid barcode does not reach scanResults"
both: kotlinx.coroutines.test.UncompletedCoroutinesError:
After waiting for 1m, the test body did not run to completion
Diagnosis (confirm it yourself before acting): `ScannerController._scanResults` is a
`MutableSharedFlow<String>(extraBufferCapacity = 1)` with NO `replay`. Both tests call
`onBarcodeScanned(...)` BEFORE anything subscribes, so with replay=0 the emission goes
nowhere, and the later `scanResults.first()` suspends forever until runTest's timeout.
Fix the TEST, not the production semantics, unless you have a concrete reason to do
otherwise. replay=0 is correct for a barcode scanner — a newly-attached collector must
not receive a stale scan from earlier. So make the test subscribe BEFORE emitting, e.g.
start the collector with `async`/`backgroundScope`, use `runCurrent()` to let it
subscribe, then call `onBarcodeScanned(...)`. Keep both tests' original intent intact:
the second one must still prove the invalid barcode is filtered out and only the valid
ISBN arrives. Do not weaken a test into an assertion-free or trivially-true test, and
do not delete a test to make the suite green.
Then VERIFY, and this time actually wait for the result before you answer:
~/bookshelf/tasks/gw testDebugUnitTest
(use that wrapper, never ./gradlew directly). It must exit 0 with 0 failures.
Reply with: the command's real exit code, the failure count, and one line on what you
changed. Nothing else.
+67
View File
@@ -0,0 +1,67 @@
You are Worker D on the Bookshelf project (~/bookshelf). Implement BOOK METADATA
LOOKUP + BARCODE SCANNING.
FIRST, READ THESE — they are the contract, follow them exactly, do not invent
alternative names or restate them back to me:
~/bookshelf/docs/SPEC.md (authoritative product/technical spec — see the
"Book metadata lookup" and "Barcode scanning"
sections especially)
~/bookshelf/docs/HANDOFF.md (operational state and gotchas already paid for)
## Your scope — these packages ONLY, under app/app/src/main/java/org/modg/bookshelf/
data.metadata
- IsbnUtils: ISBN-13 checksum validation, ISBN-10 -> 13 conversion,
normalization (strip hyphens/spaces, handle trailing X)
- OpenLibraryClient and GoogleBooksClient (Retrofit or OkHttp + kotlinx-
serialization; endpoints are in SPEC)
- BookMetadata (source-agnostic result model) and MetadataMerger implementing
SPEC's merge rule: prefer whichever has a title, fill blanks from the other,
return null if both miss
- MetadataRepository: the single entry point, `suspend fun lookup(isbn): BookMetadata?`
ui.scan — SCANNER PLUMBING ONLY, no finished screen (wave 3 builds the screen):
- a CameraX ImageAnalysis analyzer wrapping ML Kit BarcodeScanning
(EAN_13, EAN_8, UPC_A), validating the ISBN-13 checksum before emitting,
debouncing repeat reads of the same code
- a small stateful holder exposing scan results as a Flow, plus torch toggle
and camera-permission-denied states
## HARD BOUNDARIES — you share this repo with Worker C, running right now
- DO NOT create or edit anything under `data.local`, `data.remote`, `data.repo`,
`data.prefs`, `AppContainer`, or `BookshelfApplication.kt`. Those are Worker C's.
Touching them WILL cause a conflict.
- DO NOT edit any `ui.theme` or `ui.components` file — wave 1B finished those and
they are verified green. Reuse them; do not modify them.
- DO NOT edit `app/build.gradle.kts` or `gradle/libs.versions.toml`. Everything
you need (camerax core/camera2/lifecycle/view, mlkit barcode-scanning, retrofit,
kotlinx-serialization, okhttp, coroutines-test, robolectric) is ALREADY declared
and wired. If you think something is missing, DO NOT add it — report it instead.
- Your MetadataRepository must be a plain class with an explicit constructor
(e.g. taking OkHttpClient/Json). Do NOT wire it into AppContainer — Worker C owns
that file. In your final report, give the exact one-line wiring snippet needed.
## Build/verify — CRITICAL
Never run `./gradlew` directly; a second worker builds concurrently and you will
corrupt each other's build. ALWAYS build with the serialized wrapper:
~/bookshelf/tasks/gw assembleDebug
~/bookshelf/tasks/gw testDebugUnitTest
It takes the lock and may block until the other worker's build finishes. That is
expected — wait for it, do not bypass it.
## Definition of done — all must actually pass, verified by you, not assumed
1. `~/bookshelf/tasks/gw assembleDebug` exits 0.
2. `~/bookshelf/tasks/gw testDebugUnitTest` exits 0.
3. Real unit tests with real assertions (SPEC: "Do not write assertion-free tests"):
- ISBN-13 checksum: known-valid and known-invalid ISBNs, ISBN-10 conversion,
hyphen/space handling
- MetadataMerger: OL-only, GB-only, both, neither(-> null), and blank-filling
- client JSON parsing against CHECKED-IN SAMPLE JSON FIXTURES, not live network.
Tests must pass offline with no network access.
4. Network code must never be called on the main thread and must fail soft
(return null / empty) rather than throw on timeout or malformed JSON.
## Report back (keep it short — it is read by a token-constrained orchestrator)
- exact pass/fail of the two gradle commands above
- files created, one line each
- the one-line AppContainer wiring snippet for MetadataRepository
- anything in SPEC.md you could NOT satisfy, and why. Do not paper over gaps:
a truthfully reported gap is worth more than a false green.
Executable
+9
View File
@@ -0,0 +1,9 @@
#!/usr/bin/env bash
# Serialized gradle wrapper. Two workers share one Gradle project dir; concurrent
# builds clobber each other's outputs. Always build via this, never ./gradlew.
export JAVA_HOME="$HOME/toolchain/jdk21"
export ANDROID_HOME="$HOME/toolchain/android-sdk"
export ANDROID_SDK_ROOT="$ANDROID_HOME"
export PATH="$JAVA_HOME/bin:$PATH"
cd "$HOME/bookshelf/app" || exit 1
exec flock "$HOME/bookshelf/.gradle-build.lock" ./gradlew "$@"
+36
View File
@@ -0,0 +1,36 @@
#!/usr/bin/env bash
# run-resume.sh <task-name> <prompt-file>
# Like run-task.sh, but resumes an EXISTING session with a SPECIFIC prompt
# (run-task.sh sends only a generic "continue" message on resume).
set -u
NAME="$1"; PROMPT_FILE="$2"
L="$HOME/bookshelf/logs"; LOG="$L/${NAME}.json"; ERR="$L/${NAME}.err"
SIDF="$L/${NAME}.sid"; ST="$L/${NAME}.state"
MAX_WALL="${MAX_WALL:-86400}"; POLL="${POLL:-600}"; MAX_HARD_FAILS="${MAX_HARD_FAILS:-3}"
export JAVA_HOME="$HOME/toolchain/jdk21"
export ANDROID_HOME="$HOME/toolchain/android-sdk"; export ANDROID_SDK_ROOT="$ANDROID_HOME"
export PATH="$JAVA_HOME/bin:$ANDROID_HOME/platform-tools:$PATH"
cd "$HOME/bookshelf" || exit 1
SID="$(cat "$SIDF")"
say(){ echo "[$(date -Is)] $NAME: $*" >> "$ST"; }
say "RESUME-with-prompt sid=$SID file=$PROMPT_FILE"
deadline=$(( $(date +%s) + MAX_WALL )); attempt=0; hard=0; qw=0
while [ "$(date +%s)" -lt "$deadline" ]; do
attempt=$((attempt+1)); say "attempt $attempt: resume $SID"
claude -p --model sonnet --permission-mode bypassPermissions \
--output-format json --add-dir "$HOME/bookshelf" --resume "$SID" \
< "$PROMPT_FILE" > "$LOG" 2>"$ERR"
rc=$?
blob="$(cat "$LOG" "$ERR" 2>/dev/null | head -c 20000)"
if printf '%s' "$blob" | grep -qiE 'usage limit|limit will reset|limit resets|rate_limit_error|rate limit exceeded|429|too many requests|overloaded_error'; then
qw=$((qw+1)); say "QUOTA hit (wait #$qw), sleeping ${POLL}s"; sleep "$POLL"; continue; fi
isErr="$(jq -r '.is_error // false' "$LOG" 2>/dev/null)"
if [ "$rc" -eq 0 ] && [ "$isErr" != "true" ]; then say "SUCCESS after $attempt attempt(s)"; break; fi
hard=$((hard+1)); say "hard failure #$hard (rc=$rc)"
[ "$hard" -ge "$MAX_HARD_FAILS" ] && { say "GIVING UP"; break; }
sleep 60
done
{ echo "=== $NAME attempts=$attempt quota_waits=$qw hard_fails=$hard ==="
jq -r '"cost=$" + ((.total_cost_usd//0)|tostring) + " turns=" + ((.num_turns//0)|tostring)' "$LOG" 2>/dev/null
echo "--- result ---"; jq -r '.result // "no result"' "$LOG" 2>/dev/null | tail -c 1500
} > "$L/${NAME}.summary"