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