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
@@ -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
* here (no Hilt/kapt) — data.repo/data.local/data.remote wiring lands with
* the waves that introduce those packages. This scaffold wave intentionally
* leaves the container empty rather than stubbing out APIs that don't exist
* yet.
* here (no Hilt/kapt).
*/
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"}]
}
}