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:
@@ -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"}]
|
||||
}
|
||||
}
|
||||
+45
-21
@@ -58,28 +58,52 @@ Independently re-verified on 09-06 after fixing the service:
|
||||
filter, so anonymous LIST would otherwise return `200 []` instead of an error. The hook forces
|
||||
403. Keep it; it is why the table above passes. It is auto-loaded by the stock binary.
|
||||
|
||||
### Wave 1B — Android scaffold + design system: COMPLETE, verified by the orchestrator
|
||||
The pre-restart worker had gotten much further than the last handoff recorded. On 09-06 the
|
||||
orchestrator found everything on disk (theme, 7 shared components, MainActivity,
|
||||
BookshelfApplication, Paparazzi test, all 8 Literata TTFs) and only THREE compile errors,
|
||||
all the same class of trivial import bug — fixed directly by the orchestrator rather than
|
||||
spending a worker session on two-line edits:
|
||||
- `import androidx.compose.foundation.layout.weight` (x2: BookshelfScaffold.kt, the Paparazzi
|
||||
test) — that resolves to the *internal* `RowColumnParentData.weight`. `weight` is a
|
||||
ColumnScope/RowScope member; it needs NO import. Delete the line.
|
||||
- SyncStatusBar.kt was missing `import androidx.compose.runtime.getValue`, so `val x by
|
||||
transition.animateFloat(...)` had no delegate.
|
||||
|
||||
| Check | Result |
|
||||
|---|---|
|
||||
| `./gradlew assembleDebug` | **exit 0** — app-debug.apk, 47MB |
|
||||
| `./gradlew testDebugUnitTest` | **exit 0** |
|
||||
| `./gradlew recordPaparazziDebug` | **exit 0** — 10 PNGs, light+dark |
|
||||
|
||||
Snapshots: `app/app/src/test/snapshots/images/`. The orchestrator eyeballed scaffold-light:
|
||||
warm paper ground, Literata serif title, thin gold hairline rule. Matches the design language.
|
||||
|
||||
### Repo is now a git repo
|
||||
`git init` + baseline commit `8bcd9f7` at the 1B-green point. This is deliberate: it lets the
|
||||
orchestrator verify a wave with `git diff --stat` / `git log` instead of reading source files
|
||||
into Opus context, and gives a rollback that isn't a whole-sprite checkpoint restore.
|
||||
Root `.gitignore` covers build outputs, `server/pb_data`, `.dev-credentials`, worker logs.
|
||||
|
||||
## STATE: what is IN FLIGHT
|
||||
### Wave 2 — C (data layer) + D (metadata/scanning): LAUNCHED 09-06 ~01:59Z, running in parallel
|
||||
Prompts: `tasks/C-data.txt`, `tasks/D-metadata.txt`. Sessions:
|
||||
C-data=c2b92ca5-55d7-49b2-8a89-dc36e3ba4c9f, D-metadata=9a2c0de8-e475-4a43-9302-bc66889ee2bd
|
||||
|
||||
Two coordination devices were put in place before launch; keep them for wave 3:
|
||||
1. **`tasks/gw` — a `flock`-serialized gradle wrapper.** Both workers share ONE Gradle project
|
||||
dir; concurrent `./gradlew` runs clobber each other's outputs. Both prompts forbid
|
||||
`./gradlew` and require `tasks/gw`. Reuse this for every future parallel wave.
|
||||
2. **Disjoint file ownership, stated as a hard boundary in each prompt.** C owns data.local,
|
||||
data.remote, data.repo, data.prefs, AppContainer, BookshelfApplication. D owns data.metadata
|
||||
and ui.scan plumbing. NEITHER may touch `app/build.gradle.kts` or `libs.versions.toml` —
|
||||
the orchestrator confirmed every wave-2 dependency is ALREADY declared and wired, so there
|
||||
is no legitimate reason for a worker to edit a build file. D must not wire MetadataRepository
|
||||
into AppContainer (C owns it); D reports the one-line snippet instead, to be applied later.
|
||||
|
||||
## STATE: what is NOT done
|
||||
### Wave 1B — Android scaffold + design system: INCOMPLETE (killed mid-run by the restart)
|
||||
Present: gradle wrapper, `gradle/libs.versions.toml`, `app/build.gradle.kts`,
|
||||
`AndroidManifest.xml`, `proguard-rules.pro`, Literata OFL license.
|
||||
Missing/unverified: ui/theme (Color/Type/Theme), the shared component set, MainActivity,
|
||||
Paparazzi setup, and **any evidence the build compiles**.
|
||||
|
||||
**Its session SURVIVED and is resumable — prefer this over a restart (saves quota):**
|
||||
`claude -p --model sonnet --permission-mode bypassPermissions --output-format json \`
|
||||
` --add-dir ~/bookshelf --resume 6823e72a-69c1-486e-ae5a-18abab84529b`
|
||||
with a "continue where you left off, don't restart" prompt. (Worker A's session, for
|
||||
reference, is `5e3bd183-252c-4224-99b5-91779761ccbc`.)
|
||||
First thing the resumed worker must do: get `./gradlew assembleDebug` GREEN. Everything
|
||||
downstream is blocked on it.
|
||||
|
||||
### Waves 2-4 — not started. Prompts not yet written.
|
||||
- **Wave 2 (parallel, after 1B is green):**
|
||||
- C — data layer: Room entities/DAOs/DB, PocketBase Retrofit client + auth interceptor,
|
||||
`SyncEngine` (push-then-pull, LWW, tombstones, client-generated 15-char ids), SettingsStore.
|
||||
- D — metadata + scanning: Open Library + Google Books merge, ISBN-13 checksum validation,
|
||||
CameraX + ML Kit continuous scanning.
|
||||
- **Wave 3 (after C+D):** E — the six screens (setup, library, detail, scan, locations, settings).
|
||||
### Waves 3-4 — not started. Prompts not yet written.
|
||||
- **Wave 3 (after C+D land and are verified):** E — the six screens (setup, library, detail, scan, locations, settings).
|
||||
- **Wave 4:** F — Paparazzi screenshots for the user to judge the look, release keystore +
|
||||
signed APK, top-level README, end-to-end sync test against the live PocketBase.
|
||||
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
c2b92ca5-55d7-49b2-8a89-dc36e3ba4c9f
|
||||
@@ -0,0 +1,11 @@
|
||||
=== C-data attempts=1 quota_waits=0 hard_fails=0 ===
|
||||
cost=$2.0208028 turns=65 err=?
|
||||
--- result (tail) ---
|
||||
`BookshelfApplication.kt`
|
||||
- Tests: `IdGeneratorTest`, `PbDateFormatTest`, `BookDaoTest`, `LocationDaoTest` (Robolectric, prove `deleted=0` filtering), `SyncEngineTest` (15 tests: LWW pure-function cases, push ordering, syncState transitions, 404/409, pull conflict resolution), `FakePocketBaseApi` (hand-written fake, no mocking lib available)
|
||||
|
||||
**Gaps / deviations, reported honestly:**
|
||||
1. **Cover pipeline is best-effort, not fully spec-complete.** I implemented download-on-create (`BookRepository`, plain OkHttp GET to `coverSourceUrl` → app-private file) and multipart upload-on-sync (`SyncEngine.uploadCoverIfNeeded`), building the served `coverUrl` from PocketBase's `/api/files/...` convention. This is untested against a *real* PocketBase server (only via fakes) — I did not have time/scope to run an end-to-end live-server cover round-trip. Logic is sound but should get a real-server smoke test in a later wave.
|
||||
2. **No Room foreign-key constraints** between books→shelves→bookcases (only indices). Deliberate: enforcing FK would make sync ordering brittle across paginated pulls; push/pull order (bookcases→shelves→books) already handles the realistic cases. Documented in `SyncEngine`'s class doc.
|
||||
3. **Full library search/sort/filter surface is minimal** — `BookDao` exposes `observeAll`, `observeAllByRecentlyAdded`, `observeByShelf`, `search(query)`; I did not build every SPEC-listed sort/filter combination since that's UI-driven (wave 3/Worker E territory) and risked overengineering ahead of real requirements. Easy to extend.
|
||||
4. Did not touch `app/build.gradle.kts` / `libs.versions.toml` per instructions — all needed deps (room+ksp, retrofit+kotlinx-serialization converter, okhttp, datastore, coroutines-test, robolectric) were already present and sufficient.
|
||||
@@ -0,0 +1 @@
|
||||
9a2c0de8-e475-4a43-9302-bc66889ee2bd
|
||||
@@ -0,0 +1,4 @@
|
||||
=== D-metadata attempts=1 quota_waits=0 hard_fails=0 ===
|
||||
cost=$0.2020432 turns=6
|
||||
--- result ---
|
||||
Exit code 0, 0 failures (68 tests, all passing). Fix: rewrote the two `ScannerControllerTest` cases to `backgroundScope.launch { scanResults.toList(...) }` + `runCurrent()` before emitting, so the collector subscribes before `onBarcodeScanned` fires (replay=0 semantics kept unchanged in production code).
|
||||
@@ -0,0 +1,53 @@
|
||||
You are Worker C on the Bookshelf project (~/bookshelf). Implement the DATA LAYER.
|
||||
|
||||
FIRST, READ THESE — they are the contract, follow them exactly, do not invent
|
||||
alternative names or restate them back to me:
|
||||
~/bookshelf/docs/SPEC.md (authoritative product/technical spec)
|
||||
~/bookshelf/docs/HANDOFF.md (operational state and gotchas already paid for)
|
||||
|
||||
## Your scope — these packages ONLY, under app/app/src/main/java/org/modg/bookshelf/
|
||||
data.local Room: BookEntity/BookcaseEntity/ShelfEntity, SyncState, Converters,
|
||||
BookDao/BookcaseDao/ShelfDao, BookshelfDatabase
|
||||
data.remote PocketBaseApi (Retrofit), request/response DTOs, PbAuthInterceptor
|
||||
data.repo BookRepository, LocationRepository, AuthRepository, SyncEngine
|
||||
data.prefs SettingsStore (DataStore: server URL, auth token, per-collection
|
||||
sync cursors, last-sync time)
|
||||
Plus `AppContainer` (manual DI, per SPEC "NO Hilt/kapt") and its wiring into
|
||||
the existing BookshelfApplication.kt.
|
||||
|
||||
## HARD BOUNDARIES — you share this repo with Worker D, running right now
|
||||
- DO NOT create or edit anything under `data.metadata`, `ui.scan`, or any `ui.*`
|
||||
package. Those are Worker D's / wave 3's. Touching them WILL cause a conflict.
|
||||
- DO NOT edit `app/build.gradle.kts` or `gradle/libs.versions.toml`. Every
|
||||
dependency you need (room+ksp, retrofit, kotlinx-serialization, okhttp,
|
||||
datastore, work-runtime, robolectric, coroutines-test) is ALREADY declared and
|
||||
wired. If you genuinely believe something is missing, DO NOT add it — say so in
|
||||
your final report and work around it.
|
||||
- Worker D will need metadata lookup reachable from AppContainer. Do NOT try to
|
||||
wire it. Just leave AppContainer easy to extend; D exposes a plain class that
|
||||
gets wired later.
|
||||
|
||||
## Build/verify — CRITICAL
|
||||
Never run `./gradlew` directly; a second worker builds concurrently and you will
|
||||
corrupt each other's build. ALWAYS build with the serialized wrapper:
|
||||
~/bookshelf/tasks/gw assembleDebug
|
||||
~/bookshelf/tasks/gw testDebugUnitTest
|
||||
It takes the lock and may block until the other worker's build finishes. That is
|
||||
expected — wait for it, do not bypass it.
|
||||
|
||||
## Definition of done — all must actually pass, verified by you, not assumed
|
||||
1. `~/bookshelf/tasks/gw assembleDebug` exits 0.
|
||||
2. `~/bookshelf/tasks/gw testDebugUnitTest` exits 0.
|
||||
3. Real unit tests with real assertions (SPEC: "Do not write assertion-free tests"):
|
||||
- SyncEngine conflict resolution / last-write-wins on `updated`
|
||||
- push ordering + syncState transitions, incl. 404-on-update -> drop local,
|
||||
409/duplicate-id -> switch to PATCH
|
||||
- client-side 15-char lowercase-alnum id generation
|
||||
- DAO queries via Robolectric, proving `deleted = 0` filtering works
|
||||
4. Soft delete everywhere. All reads come from Room. Nothing blocks on network.
|
||||
|
||||
## Report back (keep it short — it is read by a token-constrained orchestrator)
|
||||
- exact pass/fail of the two gradle commands above
|
||||
- files created, one line each
|
||||
- anything in SPEC.md you could NOT satisfy, and why. Do not paper over gaps:
|
||||
a truthfully reported gap is worth more than a false green.
|
||||
@@ -0,0 +1,32 @@
|
||||
Your wave-2 work is mostly good and `assembleDebug` passes. But you ended your turn
|
||||
reporting that `testDebugUnitTest` was "running in background, will report once it
|
||||
completes" — you never confirmed it. The orchestrator ran it. IT FAILS.
|
||||
|
||||
68 tests ran, 2 failed, both yours, both in
|
||||
`app/app/src/test/java/org/modg/bookshelf/ui/scan/ScannerControllerTest.kt`:
|
||||
|
||||
ScannerControllerTest > "valid barcode emits on scanResults"
|
||||
ScannerControllerTest > "invalid barcode does not reach scanResults"
|
||||
both: kotlinx.coroutines.test.UncompletedCoroutinesError:
|
||||
After waiting for 1m, the test body did not run to completion
|
||||
|
||||
Diagnosis (confirm it yourself before acting): `ScannerController._scanResults` is a
|
||||
`MutableSharedFlow<String>(extraBufferCapacity = 1)` with NO `replay`. Both tests call
|
||||
`onBarcodeScanned(...)` BEFORE anything subscribes, so with replay=0 the emission goes
|
||||
nowhere, and the later `scanResults.first()` suspends forever until runTest's timeout.
|
||||
|
||||
Fix the TEST, not the production semantics, unless you have a concrete reason to do
|
||||
otherwise. replay=0 is correct for a barcode scanner — a newly-attached collector must
|
||||
not receive a stale scan from earlier. So make the test subscribe BEFORE emitting, e.g.
|
||||
start the collector with `async`/`backgroundScope`, use `runCurrent()` to let it
|
||||
subscribe, then call `onBarcodeScanned(...)`. Keep both tests' original intent intact:
|
||||
the second one must still prove the invalid barcode is filtered out and only the valid
|
||||
ISBN arrives. Do not weaken a test into an assertion-free or trivially-true test, and
|
||||
do not delete a test to make the suite green.
|
||||
|
||||
Then VERIFY, and this time actually wait for the result before you answer:
|
||||
~/bookshelf/tasks/gw testDebugUnitTest
|
||||
(use that wrapper, never ./gradlew directly). It must exit 0 with 0 failures.
|
||||
|
||||
Reply with: the command's real exit code, the failure count, and one line on what you
|
||||
changed. Nothing else.
|
||||
@@ -0,0 +1,67 @@
|
||||
You are Worker D on the Bookshelf project (~/bookshelf). Implement BOOK METADATA
|
||||
LOOKUP + BARCODE SCANNING.
|
||||
|
||||
FIRST, READ THESE — they are the contract, follow them exactly, do not invent
|
||||
alternative names or restate them back to me:
|
||||
~/bookshelf/docs/SPEC.md (authoritative product/technical spec — see the
|
||||
"Book metadata lookup" and "Barcode scanning"
|
||||
sections especially)
|
||||
~/bookshelf/docs/HANDOFF.md (operational state and gotchas already paid for)
|
||||
|
||||
## Your scope — these packages ONLY, under app/app/src/main/java/org/modg/bookshelf/
|
||||
data.metadata
|
||||
- IsbnUtils: ISBN-13 checksum validation, ISBN-10 -> 13 conversion,
|
||||
normalization (strip hyphens/spaces, handle trailing X)
|
||||
- OpenLibraryClient and GoogleBooksClient (Retrofit or OkHttp + kotlinx-
|
||||
serialization; endpoints are in SPEC)
|
||||
- BookMetadata (source-agnostic result model) and MetadataMerger implementing
|
||||
SPEC's merge rule: prefer whichever has a title, fill blanks from the other,
|
||||
return null if both miss
|
||||
- MetadataRepository: the single entry point, `suspend fun lookup(isbn): BookMetadata?`
|
||||
ui.scan — SCANNER PLUMBING ONLY, no finished screen (wave 3 builds the screen):
|
||||
- a CameraX ImageAnalysis analyzer wrapping ML Kit BarcodeScanning
|
||||
(EAN_13, EAN_8, UPC_A), validating the ISBN-13 checksum before emitting,
|
||||
debouncing repeat reads of the same code
|
||||
- a small stateful holder exposing scan results as a Flow, plus torch toggle
|
||||
and camera-permission-denied states
|
||||
|
||||
## HARD BOUNDARIES — you share this repo with Worker C, running right now
|
||||
- DO NOT create or edit anything under `data.local`, `data.remote`, `data.repo`,
|
||||
`data.prefs`, `AppContainer`, or `BookshelfApplication.kt`. Those are Worker C's.
|
||||
Touching them WILL cause a conflict.
|
||||
- DO NOT edit any `ui.theme` or `ui.components` file — wave 1B finished those and
|
||||
they are verified green. Reuse them; do not modify them.
|
||||
- DO NOT edit `app/build.gradle.kts` or `gradle/libs.versions.toml`. Everything
|
||||
you need (camerax core/camera2/lifecycle/view, mlkit barcode-scanning, retrofit,
|
||||
kotlinx-serialization, okhttp, coroutines-test, robolectric) is ALREADY declared
|
||||
and wired. If you think something is missing, DO NOT add it — report it instead.
|
||||
- Your MetadataRepository must be a plain class with an explicit constructor
|
||||
(e.g. taking OkHttpClient/Json). Do NOT wire it into AppContainer — Worker C owns
|
||||
that file. In your final report, give the exact one-line wiring snippet needed.
|
||||
|
||||
## Build/verify — CRITICAL
|
||||
Never run `./gradlew` directly; a second worker builds concurrently and you will
|
||||
corrupt each other's build. ALWAYS build with the serialized wrapper:
|
||||
~/bookshelf/tasks/gw assembleDebug
|
||||
~/bookshelf/tasks/gw testDebugUnitTest
|
||||
It takes the lock and may block until the other worker's build finishes. That is
|
||||
expected — wait for it, do not bypass it.
|
||||
|
||||
## Definition of done — all must actually pass, verified by you, not assumed
|
||||
1. `~/bookshelf/tasks/gw assembleDebug` exits 0.
|
||||
2. `~/bookshelf/tasks/gw testDebugUnitTest` exits 0.
|
||||
3. Real unit tests with real assertions (SPEC: "Do not write assertion-free tests"):
|
||||
- ISBN-13 checksum: known-valid and known-invalid ISBNs, ISBN-10 conversion,
|
||||
hyphen/space handling
|
||||
- MetadataMerger: OL-only, GB-only, both, neither(-> null), and blank-filling
|
||||
- client JSON parsing against CHECKED-IN SAMPLE JSON FIXTURES, not live network.
|
||||
Tests must pass offline with no network access.
|
||||
4. Network code must never be called on the main thread and must fail soft
|
||||
(return null / empty) rather than throw on timeout or malformed JSON.
|
||||
|
||||
## Report back (keep it short — it is read by a token-constrained orchestrator)
|
||||
- exact pass/fail of the two gradle commands above
|
||||
- files created, one line each
|
||||
- the one-line AppContainer wiring snippet for MetadataRepository
|
||||
- anything in SPEC.md you could NOT satisfy, and why. Do not paper over gaps:
|
||||
a truthfully reported gap is worth more than a false green.
|
||||
@@ -0,0 +1,9 @@
|
||||
#!/usr/bin/env bash
|
||||
# Serialized gradle wrapper. Two workers share one Gradle project dir; concurrent
|
||||
# builds clobber each other's outputs. Always build via this, never ./gradlew.
|
||||
export JAVA_HOME="$HOME/toolchain/jdk21"
|
||||
export ANDROID_HOME="$HOME/toolchain/android-sdk"
|
||||
export ANDROID_SDK_ROOT="$ANDROID_HOME"
|
||||
export PATH="$JAVA_HOME/bin:$PATH"
|
||||
cd "$HOME/bookshelf/app" || exit 1
|
||||
exec flock "$HOME/bookshelf/.gradle-build.lock" ./gradlew "$@"
|
||||
Executable
+36
@@ -0,0 +1,36 @@
|
||||
#!/usr/bin/env bash
|
||||
# run-resume.sh <task-name> <prompt-file>
|
||||
# Like run-task.sh, but resumes an EXISTING session with a SPECIFIC prompt
|
||||
# (run-task.sh sends only a generic "continue" message on resume).
|
||||
set -u
|
||||
NAME="$1"; PROMPT_FILE="$2"
|
||||
L="$HOME/bookshelf/logs"; LOG="$L/${NAME}.json"; ERR="$L/${NAME}.err"
|
||||
SIDF="$L/${NAME}.sid"; ST="$L/${NAME}.state"
|
||||
MAX_WALL="${MAX_WALL:-86400}"; POLL="${POLL:-600}"; MAX_HARD_FAILS="${MAX_HARD_FAILS:-3}"
|
||||
export JAVA_HOME="$HOME/toolchain/jdk21"
|
||||
export ANDROID_HOME="$HOME/toolchain/android-sdk"; export ANDROID_SDK_ROOT="$ANDROID_HOME"
|
||||
export PATH="$JAVA_HOME/bin:$ANDROID_HOME/platform-tools:$PATH"
|
||||
cd "$HOME/bookshelf" || exit 1
|
||||
SID="$(cat "$SIDF")"
|
||||
say(){ echo "[$(date -Is)] $NAME: $*" >> "$ST"; }
|
||||
say "RESUME-with-prompt sid=$SID file=$PROMPT_FILE"
|
||||
deadline=$(( $(date +%s) + MAX_WALL )); attempt=0; hard=0; qw=0
|
||||
while [ "$(date +%s)" -lt "$deadline" ]; do
|
||||
attempt=$((attempt+1)); say "attempt $attempt: resume $SID"
|
||||
claude -p --model sonnet --permission-mode bypassPermissions \
|
||||
--output-format json --add-dir "$HOME/bookshelf" --resume "$SID" \
|
||||
< "$PROMPT_FILE" > "$LOG" 2>"$ERR"
|
||||
rc=$?
|
||||
blob="$(cat "$LOG" "$ERR" 2>/dev/null | head -c 20000)"
|
||||
if printf '%s' "$blob" | grep -qiE 'usage limit|limit will reset|limit resets|rate_limit_error|rate limit exceeded|429|too many requests|overloaded_error'; then
|
||||
qw=$((qw+1)); say "QUOTA hit (wait #$qw), sleeping ${POLL}s"; sleep "$POLL"; continue; fi
|
||||
isErr="$(jq -r '.is_error // false' "$LOG" 2>/dev/null)"
|
||||
if [ "$rc" -eq 0 ] && [ "$isErr" != "true" ]; then say "SUCCESS after $attempt attempt(s)"; break; fi
|
||||
hard=$((hard+1)); say "hard failure #$hard (rc=$rc)"
|
||||
[ "$hard" -ge "$MAX_HARD_FAILS" ] && { say "GIVING UP"; break; }
|
||||
sleep 60
|
||||
done
|
||||
{ echo "=== $NAME attempts=$attempt quota_waits=$qw hard_fails=$hard ==="
|
||||
jq -r '"cost=$" + ((.total_cost_usd//0)|tostring) + " turns=" + ((.num_turns//0)|tostring)' "$LOG" 2>/dev/null
|
||||
echo "--- result ---"; jq -r '.result // "no result"' "$LOG" 2>/dev/null | tail -c 1500
|
||||
} > "$L/${NAME}.summary"
|
||||
Reference in New Issue
Block a user