From d1a73a11933d1b640fae280452a38b70341f7c6d Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 6 Sep 2026 03:24:04 +0000 Subject: [PATCH] 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 Claude-Session: https://claude.ai/code/session_016bThmkmyUUdqQpy3MXFFe5 --- .gradle-build.lock | 0 .../java/org/modg/bookshelf/AppContainer.kt | 97 +++++ .../modg/bookshelf/BookshelfApplication.kt | 15 +- .../org/modg/bookshelf/data/local/BookDao.kt | 57 +++ .../modg/bookshelf/data/local/BookEntity.kt | 48 +++ .../modg/bookshelf/data/local/BookcaseDao.kt | 31 ++ .../bookshelf/data/local/BookcaseEntity.kt | 21 + .../bookshelf/data/local/BookshelfDatabase.kt | 27 ++ .../modg/bookshelf/data/local/Converters.kt | 12 + .../modg/bookshelf/data/local/IdGenerator.kt | 21 + .../org/modg/bookshelf/data/local/ShelfDao.kt | 34 ++ .../modg/bookshelf/data/local/ShelfEntity.kt | 21 + .../modg/bookshelf/data/local/SyncState.kt | 14 + .../bookshelf/data/metadata/BookMetadata.kt | 18 + .../data/metadata/GoogleBooksClient.kt | 46 +++ .../data/metadata/GoogleBooksDtos.kt | 59 +++ .../modg/bookshelf/data/metadata/IsbnUtils.kt | 61 +++ .../bookshelf/data/metadata/MetadataMerger.kt | 38 ++ .../data/metadata/MetadataRepository.kt | 31 ++ .../data/metadata/OpenLibraryClient.kt | 50 +++ .../data/metadata/OpenLibraryDtos.kt | 47 +++ .../bookshelf/data/prefs/SettingsStore.kt | 72 ++++ .../modg/bookshelf/data/remote/ApiProvider.kt | 12 + .../org/modg/bookshelf/data/remote/Dtos.kt | 76 ++++ .../data/remote/PbAuthInterceptor.kt | 24 ++ .../bookshelf/data/remote/PbDateFormat.kt | 34 ++ .../bookshelf/data/remote/PocketBaseApi.kt | 74 ++++ .../bookshelf/data/repo/AuthRepository.kt | 47 +++ .../bookshelf/data/repo/BookRepository.kt | 148 +++++++ .../bookshelf/data/repo/LocationRepository.kt | 71 ++++ .../modg/bookshelf/data/repo/SyncEngine.kt | 382 ++++++++++++++++++ .../bookshelf/ui/scan/IsbnBarcodeAnalyzer.kt | 48 +++ .../modg/bookshelf/ui/scan/ScanCodeFilter.kt | 34 ++ .../bookshelf/ui/scan/ScannerController.kt | 45 +++ .../modg/bookshelf/data/local/BookDaoTest.kt | 127 ++++++ .../bookshelf/data/local/IdGeneratorTest.kt | 29 ++ .../bookshelf/data/local/LocationDaoTest.kt | 71 ++++ .../data/metadata/GoogleBooksClientTest.kt | 53 +++ .../bookshelf/data/metadata/IsbnUtilsTest.kt | 69 ++++ .../data/metadata/MetadataMergerTest.kt | 72 ++++ .../data/metadata/OpenLibraryClientTest.kt | 51 +++ .../bookshelf/data/remote/PbDateFormatTest.kt | 37 ++ .../bookshelf/data/repo/FakePocketBaseApi.kt | 126 ++++++ .../bookshelf/data/repo/SyncEngineTest.kt | 201 +++++++++ .../bookshelf/ui/scan/ScanCodeFilterTest.kt | 72 ++++ .../ui/scan/ScannerControllerTest.kt | 66 +++ .../fixtures/googlebooks_no_items.json | 4 + .../fixtures/googlebooks_success.json | 29 ++ .../test/resources/fixtures/malformed.json | 1 + .../fixtures/openlibrary_not_found.json | 1 + .../fixtures/openlibrary_success.json | 26 ++ docs/HANDOFF.md | 66 ++- logs/C-data.sid | 1 + logs/C-data.summary | 11 + logs/D-metadata.sid | 1 + logs/D-metadata.summary | 4 + tasks/C-data.txt | 53 +++ tasks/D-fix.txt | 32 ++ tasks/D-metadata.txt | 67 +++ tasks/gw | 9 + tasks/run-resume.sh | 36 ++ 61 files changed, 3104 insertions(+), 26 deletions(-) create mode 100644 .gradle-build.lock create mode 100644 app/app/src/main/java/org/modg/bookshelf/AppContainer.kt create mode 100644 app/app/src/main/java/org/modg/bookshelf/data/local/BookDao.kt create mode 100644 app/app/src/main/java/org/modg/bookshelf/data/local/BookEntity.kt create mode 100644 app/app/src/main/java/org/modg/bookshelf/data/local/BookcaseDao.kt create mode 100644 app/app/src/main/java/org/modg/bookshelf/data/local/BookcaseEntity.kt create mode 100644 app/app/src/main/java/org/modg/bookshelf/data/local/BookshelfDatabase.kt create mode 100644 app/app/src/main/java/org/modg/bookshelf/data/local/Converters.kt create mode 100644 app/app/src/main/java/org/modg/bookshelf/data/local/IdGenerator.kt create mode 100644 app/app/src/main/java/org/modg/bookshelf/data/local/ShelfDao.kt create mode 100644 app/app/src/main/java/org/modg/bookshelf/data/local/ShelfEntity.kt create mode 100644 app/app/src/main/java/org/modg/bookshelf/data/local/SyncState.kt create mode 100644 app/app/src/main/java/org/modg/bookshelf/data/metadata/BookMetadata.kt create mode 100644 app/app/src/main/java/org/modg/bookshelf/data/metadata/GoogleBooksClient.kt create mode 100644 app/app/src/main/java/org/modg/bookshelf/data/metadata/GoogleBooksDtos.kt create mode 100644 app/app/src/main/java/org/modg/bookshelf/data/metadata/IsbnUtils.kt create mode 100644 app/app/src/main/java/org/modg/bookshelf/data/metadata/MetadataMerger.kt create mode 100644 app/app/src/main/java/org/modg/bookshelf/data/metadata/MetadataRepository.kt create mode 100644 app/app/src/main/java/org/modg/bookshelf/data/metadata/OpenLibraryClient.kt create mode 100644 app/app/src/main/java/org/modg/bookshelf/data/metadata/OpenLibraryDtos.kt create mode 100644 app/app/src/main/java/org/modg/bookshelf/data/prefs/SettingsStore.kt create mode 100644 app/app/src/main/java/org/modg/bookshelf/data/remote/ApiProvider.kt create mode 100644 app/app/src/main/java/org/modg/bookshelf/data/remote/Dtos.kt create mode 100644 app/app/src/main/java/org/modg/bookshelf/data/remote/PbAuthInterceptor.kt create mode 100644 app/app/src/main/java/org/modg/bookshelf/data/remote/PbDateFormat.kt create mode 100644 app/app/src/main/java/org/modg/bookshelf/data/remote/PocketBaseApi.kt create mode 100644 app/app/src/main/java/org/modg/bookshelf/data/repo/AuthRepository.kt create mode 100644 app/app/src/main/java/org/modg/bookshelf/data/repo/BookRepository.kt create mode 100644 app/app/src/main/java/org/modg/bookshelf/data/repo/LocationRepository.kt create mode 100644 app/app/src/main/java/org/modg/bookshelf/data/repo/SyncEngine.kt create mode 100644 app/app/src/main/java/org/modg/bookshelf/ui/scan/IsbnBarcodeAnalyzer.kt create mode 100644 app/app/src/main/java/org/modg/bookshelf/ui/scan/ScanCodeFilter.kt create mode 100644 app/app/src/main/java/org/modg/bookshelf/ui/scan/ScannerController.kt create mode 100644 app/app/src/test/java/org/modg/bookshelf/data/local/BookDaoTest.kt create mode 100644 app/app/src/test/java/org/modg/bookshelf/data/local/IdGeneratorTest.kt create mode 100644 app/app/src/test/java/org/modg/bookshelf/data/local/LocationDaoTest.kt create mode 100644 app/app/src/test/java/org/modg/bookshelf/data/metadata/GoogleBooksClientTest.kt create mode 100644 app/app/src/test/java/org/modg/bookshelf/data/metadata/IsbnUtilsTest.kt create mode 100644 app/app/src/test/java/org/modg/bookshelf/data/metadata/MetadataMergerTest.kt create mode 100644 app/app/src/test/java/org/modg/bookshelf/data/metadata/OpenLibraryClientTest.kt create mode 100644 app/app/src/test/java/org/modg/bookshelf/data/remote/PbDateFormatTest.kt create mode 100644 app/app/src/test/java/org/modg/bookshelf/data/repo/FakePocketBaseApi.kt create mode 100644 app/app/src/test/java/org/modg/bookshelf/data/repo/SyncEngineTest.kt create mode 100644 app/app/src/test/java/org/modg/bookshelf/ui/scan/ScanCodeFilterTest.kt create mode 100644 app/app/src/test/java/org/modg/bookshelf/ui/scan/ScannerControllerTest.kt create mode 100644 app/app/src/test/resources/fixtures/googlebooks_no_items.json create mode 100644 app/app/src/test/resources/fixtures/googlebooks_success.json create mode 100644 app/app/src/test/resources/fixtures/malformed.json create mode 100644 app/app/src/test/resources/fixtures/openlibrary_not_found.json create mode 100644 app/app/src/test/resources/fixtures/openlibrary_success.json create mode 100644 logs/C-data.sid create mode 100644 logs/C-data.summary create mode 100644 logs/D-metadata.sid create mode 100644 logs/D-metadata.summary create mode 100644 tasks/C-data.txt create mode 100644 tasks/D-fix.txt create mode 100644 tasks/D-metadata.txt create mode 100755 tasks/gw create mode 100755 tasks/run-resume.sh diff --git a/.gradle-build.lock b/.gradle-build.lock new file mode 100644 index 0000000..e69de29 diff --git a/app/app/src/main/java/org/modg/bookshelf/AppContainer.kt b/app/app/src/main/java/org/modg/bookshelf/AppContainer.kt new file mode 100644 index 0000000..0933b18 --- /dev/null +++ b/app/app/src/main/java/org/modg/bookshelf/AppContainer.kt @@ -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, + ) + } +} diff --git a/app/app/src/main/java/org/modg/bookshelf/BookshelfApplication.kt b/app/app/src/main/java/org/modg/bookshelf/BookshelfApplication.kt index 8a2ecd5..cde3259 100644 --- a/app/app/src/main/java/org/modg/bookshelf/BookshelfApplication.kt +++ b/app/app/src/main/java/org/modg/bookshelf/BookshelfApplication.kt @@ -4,9 +4,14 @@ import android.app.Application /** * Application entry point. Per SPEC, DI is a hand-rolled [AppContainer] held - * here (no Hilt/kapt) — data.repo/data.local/data.remote wiring lands with - * the waves that introduce those packages. This scaffold wave intentionally - * leaves the container empty rather than stubbing out APIs that don't exist - * yet. + * here (no Hilt/kapt). */ -class BookshelfApplication : Application() +class BookshelfApplication : Application() { + lateinit var appContainer: AppContainer + private set + + override fun onCreate() { + super.onCreate() + appContainer = AppContainer(this) + } +} diff --git a/app/app/src/main/java/org/modg/bookshelf/data/local/BookDao.kt b/app/app/src/main/java/org/modg/bookshelf/data/local/BookDao.kt new file mode 100644 index 0000000..464ceee --- /dev/null +++ b/app/app/src/main/java/org/modg/bookshelf/data/local/BookDao.kt @@ -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> + + @Query("SELECT * FROM books WHERE deleted = 0 ORDER BY createdAt DESC") + fun observeAllByRecentlyAdded(): Flow> + + @Query("SELECT * FROM books WHERE shelfId = :shelfId AND deleted = 0 ORDER BY title COLLATE NOCASE ASC") + fun observeByShelf(shelfId: String): Flow> + + @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> + + @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 + + @Upsert + suspend fun upsert(book: BookEntity) + + @Upsert + suspend fun upsertAll(books: List) + + @Query("DELETE FROM books WHERE id = :id") + suspend fun hardDelete(id: String) +} diff --git a/app/app/src/main/java/org/modg/bookshelf/data/local/BookEntity.kt b/app/app/src/main/java/org/modg/bookshelf/data/local/BookEntity.kt new file mode 100644 index 0000000..bc7f8a0 --- /dev/null +++ b/app/app/src/main/java/org/modg/bookshelf/data/local/BookEntity.kt @@ -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` — 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, +) diff --git a/app/app/src/main/java/org/modg/bookshelf/data/local/BookcaseDao.kt b/app/app/src/main/java/org/modg/bookshelf/data/local/BookcaseDao.kt new file mode 100644 index 0000000..4663f1a --- /dev/null +++ b/app/app/src/main/java/org/modg/bookshelf/data/local/BookcaseDao.kt @@ -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> + + @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 + + @Upsert + suspend fun upsert(bookcase: BookcaseEntity) + + @Upsert + suspend fun upsertAll(bookcases: List) + + @Query("DELETE FROM bookcases WHERE id = :id") + suspend fun hardDelete(id: String) +} diff --git a/app/app/src/main/java/org/modg/bookshelf/data/local/BookcaseEntity.kt b/app/app/src/main/java/org/modg/bookshelf/data/local/BookcaseEntity.kt new file mode 100644 index 0000000..724ffb5 --- /dev/null +++ b/app/app/src/main/java/org/modg/bookshelf/data/local/BookcaseEntity.kt @@ -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, +) diff --git a/app/app/src/main/java/org/modg/bookshelf/data/local/BookshelfDatabase.kt b/app/app/src/main/java/org/modg/bookshelf/data/local/BookshelfDatabase.kt new file mode 100644 index 0000000..db0b0d0 --- /dev/null +++ b/app/app/src/main/java/org/modg/bookshelf/data/local/BookshelfDatabase.kt @@ -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() + } +} diff --git a/app/app/src/main/java/org/modg/bookshelf/data/local/Converters.kt b/app/app/src/main/java/org/modg/bookshelf/data/local/Converters.kt new file mode 100644 index 0000000..27b2efa --- /dev/null +++ b/app/app/src/main/java/org/modg/bookshelf/data/local/Converters.kt @@ -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) +} diff --git a/app/app/src/main/java/org/modg/bookshelf/data/local/IdGenerator.kt b/app/app/src/main/java/org/modg/bookshelf/data/local/IdGenerator.kt new file mode 100644 index 0000000..aeb9e01 --- /dev/null +++ b/app/app/src/main/java/org/modg/bookshelf/data/local/IdGenerator.kt @@ -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)]) + } + } +} diff --git a/app/app/src/main/java/org/modg/bookshelf/data/local/ShelfDao.kt b/app/app/src/main/java/org/modg/bookshelf/data/local/ShelfDao.kt new file mode 100644 index 0000000..1b1de63 --- /dev/null +++ b/app/app/src/main/java/org/modg/bookshelf/data/local/ShelfDao.kt @@ -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> + + @Query("SELECT * FROM shelves WHERE bookcaseId = :bookcaseId AND deleted = 0 ORDER BY position ASC, label COLLATE NOCASE ASC") + fun observeByBookcase(bookcaseId: String): Flow> + + @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 + + @Upsert + suspend fun upsert(shelf: ShelfEntity) + + @Upsert + suspend fun upsertAll(shelves: List) + + @Query("DELETE FROM shelves WHERE id = :id") + suspend fun hardDelete(id: String) +} diff --git a/app/app/src/main/java/org/modg/bookshelf/data/local/ShelfEntity.kt b/app/app/src/main/java/org/modg/bookshelf/data/local/ShelfEntity.kt new file mode 100644 index 0000000..b617b1a --- /dev/null +++ b/app/app/src/main/java/org/modg/bookshelf/data/local/ShelfEntity.kt @@ -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, +) diff --git a/app/app/src/main/java/org/modg/bookshelf/data/local/SyncState.kt b/app/app/src/main/java/org/modg/bookshelf/data/local/SyncState.kt new file mode 100644 index 0000000..13a558b --- /dev/null +++ b/app/app/src/main/java/org/modg/bookshelf/data/local/SyncState.kt @@ -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, +} diff --git a/app/app/src/main/java/org/modg/bookshelf/data/metadata/BookMetadata.kt b/app/app/src/main/java/org/modg/bookshelf/data/metadata/BookMetadata.kt new file mode 100644 index 0000000..50e7c60 --- /dev/null +++ b/app/app/src/main/java/org/modg/bookshelf/data/metadata/BookMetadata.kt @@ -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 = emptyList(), + val publisher: String? = null, + val publishedDate: String? = null, + val pageCount: Int? = null, + val description: String? = null, + val coverUrl: String? = null, +) diff --git a/app/app/src/main/java/org/modg/bookshelf/data/metadata/GoogleBooksClient.kt b/app/app/src/main/java/org/modg/bookshelf/data/metadata/GoogleBooksClient.kt new file mode 100644 index 0000000..604cfa5 --- /dev/null +++ b/app/app/src/main/java/org/modg/bookshelf/data/metadata/GoogleBooksClient.kt @@ -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 + } +} diff --git a/app/app/src/main/java/org/modg/bookshelf/data/metadata/GoogleBooksDtos.kt b/app/app/src/main/java/org/modg/bookshelf/data/metadata/GoogleBooksDtos.kt new file mode 100644 index 0000000..f5361a8 --- /dev/null +++ b/app/app/src/main/java/org/modg/bookshelf/data/metadata/GoogleBooksDtos.kt @@ -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 = emptyList(), +) + +@Serializable +data class GoogleBooksItemDto( + val volumeInfo: GoogleBooksVolumeInfoDto? = null, +) + +@Serializable +data class GoogleBooksVolumeInfoDto( + val title: String? = null, + val subtitle: String? = null, + val authors: List = emptyList(), + val publisher: String? = null, + val publishedDate: String? = null, + val pageCount: Int? = null, + val description: String? = null, + val imageLinks: GoogleBooksImageLinksDto? = null, + val industryIdentifiers: List = 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" +} diff --git a/app/app/src/main/java/org/modg/bookshelf/data/metadata/IsbnUtils.kt b/app/app/src/main/java/org/modg/bookshelf/data/metadata/IsbnUtils.kt new file mode 100644 index 0000000..131dee2 --- /dev/null +++ b/app/app/src/main/java/org/modg/bookshelf/data/metadata/IsbnUtils.kt @@ -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 + } + } +} diff --git a/app/app/src/main/java/org/modg/bookshelf/data/metadata/MetadataMerger.kt b/app/app/src/main/java/org/modg/bookshelf/data/metadata/MetadataMerger.kt new file mode 100644 index 0000000..12a4934 --- /dev/null +++ b/app/app/src/main/java/org/modg/bookshelf/data/metadata/MetadataMerger.kt @@ -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 +} diff --git a/app/app/src/main/java/org/modg/bookshelf/data/metadata/MetadataRepository.kt b/app/app/src/main/java/org/modg/bookshelf/data/metadata/MetadataRepository.kt new file mode 100644 index 0000000..e7b966a --- /dev/null +++ b/app/app/src/main/java/org/modg/bookshelf/data/metadata/MetadataRepository.kt @@ -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()) + } + } +} diff --git a/app/app/src/main/java/org/modg/bookshelf/data/metadata/OpenLibraryClient.kt b/app/app/src/main/java/org/modg/bookshelf/data/metadata/OpenLibraryClient.kt new file mode 100644 index 0000000..5383487 --- /dev/null +++ b/app/app/src/main/java/org/modg/bookshelf/data/metadata/OpenLibraryClient.kt @@ -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(entry).toBookMetadata(isbn13) + } catch (e: SerializationException) { + null + } catch (e: IllegalArgumentException) { + null + } +} diff --git a/app/app/src/main/java/org/modg/bookshelf/data/metadata/OpenLibraryDtos.kt b/app/app/src/main/java/org/modg/bookshelf/data/metadata/OpenLibraryDtos.kt new file mode 100644 index 0000000..3baeed6 --- /dev/null +++ b/app/app/src/main/java/org/modg/bookshelf/data/metadata/OpenLibraryDtos.kt @@ -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 = emptyList(), + val publishers: List = 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 = emptyList(), + @SerialName("isbn_13") val isbn13: List = 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", +) diff --git a/app/app/src/main/java/org/modg/bookshelf/data/prefs/SettingsStore.kt b/app/app/src/main/java/org/modg/bookshelf/data/prefs/SettingsStore.kt new file mode 100644 index 0000000..4f802c4 --- /dev/null +++ b/app/app/src/main/java/org/modg/bookshelf/data/prefs/SettingsStore.kt @@ -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 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 = context.dataStore.data.map { it[Keys.SERVER_URL] } + val authToken: Flow = context.dataStore.data.map { it[Keys.AUTH_TOKEN] } + val userId: Flow = context.dataStore.data.map { it[Keys.USER_ID] } + val lastSyncTime: Flow = context.dataStore.data.map { it[Keys.LAST_SYNC_TIME] } + + fun cursorFor(collection: String): Flow = + 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" + } +} diff --git a/app/app/src/main/java/org/modg/bookshelf/data/remote/ApiProvider.kt b/app/app/src/main/java/org/modg/bookshelf/data/remote/ApiProvider.kt new file mode 100644 index 0000000..579f57a --- /dev/null +++ b/app/app/src/main/java/org/modg/bookshelf/data/remote/ApiProvider.kt @@ -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? +} diff --git a/app/app/src/main/java/org/modg/bookshelf/data/remote/Dtos.kt b/app/app/src/main/java/org/modg/bookshelf/data/remote/Dtos.kt new file mode 100644 index 0000000..4e71d23 --- /dev/null +++ b/app/app/src/main/java/org/modg/bookshelf/data/remote/Dtos.kt @@ -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( + val page: Int = 1, + val perPage: Int = 0, + val totalItems: Int = 0, + val totalPages: Int = 0, + val items: List = emptyList(), +) + +@Serializable +data class BookDto( + val id: String = "", + val title: String = "", + val subtitle: String = "", + val authors: List = 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 = "", +) diff --git a/app/app/src/main/java/org/modg/bookshelf/data/remote/PbAuthInterceptor.kt b/app/app/src/main/java/org/modg/bookshelf/data/remote/PbAuthInterceptor.kt new file mode 100644 index 0000000..131f8af --- /dev/null +++ b/app/app/src/main/java/org/modg/bookshelf/data/remote/PbAuthInterceptor.kt @@ -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) + } +} diff --git a/app/app/src/main/java/org/modg/bookshelf/data/remote/PbDateFormat.kt b/app/app/src/main/java/org/modg/bookshelf/data/remote/PbDateFormat.kt new file mode 100644 index 0000000..a29f7b0 --- /dev/null +++ b/app/app/src/main/java/org/modg/bookshelf/data/remote/PbDateFormat.kt @@ -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)) +} diff --git a/app/app/src/main/java/org/modg/bookshelf/data/remote/PocketBaseApi.kt b/app/app/src/main/java/org/modg/bookshelf/data/remote/PocketBaseApi.kt new file mode 100644 index 0000000..e2824d4 --- /dev/null +++ b/app/app/src/main/java/org/modg/bookshelf/data/remote/PocketBaseApi.kt @@ -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 + + @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 + + @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 + + @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 +} diff --git a/app/app/src/main/java/org/modg/bookshelf/data/repo/AuthRepository.kt b/app/app/src/main/java/org/modg/bookshelf/data/repo/AuthRepository.kt new file mode 100644 index 0000000..4c2b74d --- /dev/null +++ b/app/app/src/main/java/org/modg/bookshelf/data/repo/AuthRepository.kt @@ -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 = settingsStore.authToken.map { !it.isNullOrBlank() } + val serverUrl: Flow = settingsStore.serverUrl + + /** Normalizes per SPEC: requires https, strips a trailing slash. */ + suspend fun setServerUrl(rawUrl: String): Result { + 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 { + 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() + } +} diff --git a/app/app/src/main/java/org/modg/bookshelf/data/repo/BookRepository.kt b/app/app/src/main/java/org/modg/bookshelf/data/repo/BookRepository.kt new file mode 100644 index 0000000..3da7b84 --- /dev/null +++ b/app/app/src/main/java/org/modg/bookshelf/data/repo/BookRepository.kt @@ -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 = authorsJson.encodeToString(authors) +fun decodeAuthors(json: String): List = + if (json.isBlank()) emptyList() else runCatching { authorsJson.decodeFromString>(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> = bookDao.observeAll() + fun observeAllByRecentlyAdded(): Flow> = bookDao.observeAllByRecentlyAdded() + fun observeByShelf(shelfId: String): Flow> = bookDao.observeByShelf(shelfId) + fun search(query: String): Flow> = 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 = 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 + } + } +} diff --git a/app/app/src/main/java/org/modg/bookshelf/data/repo/LocationRepository.kt b/app/app/src/main/java/org/modg/bookshelf/data/repo/LocationRepository.kt new file mode 100644 index 0000000..c8a2cc9 --- /dev/null +++ b/app/app/src/main/java/org/modg/bookshelf/data/repo/LocationRepository.kt @@ -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> = bookcaseDao.observeAll() + fun observeShelves(): Flow> = shelfDao.observeAll() + fun observeShelvesByBookcase(bookcaseId: String): Flow> = 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)) + } + } +} diff --git a/app/app/src/main/java/org/modg/bookshelf/data/repo/SyncEngine.kt b/app/app/src/main/java/org/modg/bookshelf/data/repo/SyncEngine.kt new file mode 100644 index 0000000..30df4b8 --- /dev/null +++ b/app/app/src/main/java/org/modg/bookshelf/data/repo/SyncEngine.kt @@ -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, +) diff --git a/app/app/src/main/java/org/modg/bookshelf/ui/scan/IsbnBarcodeAnalyzer.kt b/app/app/src/main/java/org/modg/bookshelf/ui/scan/IsbnBarcodeAnalyzer.kt new file mode 100644 index 0000000..8218b9d --- /dev/null +++ b/app/app/src/main/java/org/modg/bookshelf/ui/scan/IsbnBarcodeAnalyzer.kt @@ -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() + } +} diff --git a/app/app/src/main/java/org/modg/bookshelf/ui/scan/ScanCodeFilter.kt b/app/app/src/main/java/org/modg/bookshelf/ui/scan/ScanCodeFilter.kt new file mode 100644 index 0000000..58ebb8e --- /dev/null +++ b/app/app/src/main/java/org/modg/bookshelf/ui/scan/ScanCodeFilter.kt @@ -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 + } +} diff --git a/app/app/src/main/java/org/modg/bookshelf/ui/scan/ScannerController.kt b/app/app/src/main/java/org/modg/bookshelf/ui/scan/ScannerController.kt new file mode 100644 index 0000000..703bde6 --- /dev/null +++ b/app/app/src/main/java/org/modg/bookshelf/ui/scan/ScannerController.kt @@ -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(extraBufferCapacity = 1) + /** Emits a debounced, checksum-valid ISBN-13 each time [IsbnBarcodeAnalyzer] sees a new book barcode. */ + val scanResults: SharedFlow = _scanResults.asSharedFlow() + + private val _torchEnabled = MutableStateFlow(false) + val torchEnabled: StateFlow = _torchEnabled.asStateFlow() + + private val _permissionDenied = MutableStateFlow(false) + val permissionDenied: StateFlow = _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() + } +} diff --git a/app/app/src/test/java/org/modg/bookshelf/data/local/BookDaoTest.kt b/app/app/src/test/java/org/modg/bookshelf/data/local/BookDaoTest.kt new file mode 100644 index 0000000..53f0ae1 --- /dev/null +++ b/app/app/src/test/java/org/modg/bookshelf/data/local/BookDaoTest.kt @@ -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")) + } +} diff --git a/app/app/src/test/java/org/modg/bookshelf/data/local/IdGeneratorTest.kt b/app/app/src/test/java/org/modg/bookshelf/data/local/IdGeneratorTest.kt new file mode 100644 index 0000000..c43eecc --- /dev/null +++ b/app/app/src/test/java/org/modg/bookshelf/data/local/IdGeneratorTest.kt @@ -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" }) + } +} diff --git a/app/app/src/test/java/org/modg/bookshelf/data/local/LocationDaoTest.kt b/app/app/src/test/java/org/modg/bookshelf/data/local/LocationDaoTest.kt new file mode 100644 index 0000000..b24e7e1 --- /dev/null +++ b/app/app/src/test/java/org/modg/bookshelf/data/local/LocationDaoTest.kt @@ -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 }) + } +} diff --git a/app/app/src/test/java/org/modg/bookshelf/data/metadata/GoogleBooksClientTest.kt b/app/app/src/test/java/org/modg/bookshelf/data/metadata/GoogleBooksClientTest.kt new file mode 100644 index 0000000..237d75c --- /dev/null +++ b/app/app/src/test/java/org/modg/bookshelf/data/metadata/GoogleBooksClientTest.kt @@ -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() +} diff --git a/app/app/src/test/java/org/modg/bookshelf/data/metadata/IsbnUtilsTest.kt b/app/app/src/test/java/org/modg/bookshelf/data/metadata/IsbnUtilsTest.kt new file mode 100644 index 0000000..4309644 --- /dev/null +++ b/app/app/src/test/java/org/modg/bookshelf/data/metadata/IsbnUtilsTest.kt @@ -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")) + } +} diff --git a/app/app/src/test/java/org/modg/bookshelf/data/metadata/MetadataMergerTest.kt b/app/app/src/test/java/org/modg/bookshelf/data/metadata/MetadataMergerTest.kt new file mode 100644 index 0000000..9e62dba --- /dev/null +++ b/app/app/src/test/java/org/modg/bookshelf/data/metadata/MetadataMergerTest.kt @@ -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) + } +} diff --git a/app/app/src/test/java/org/modg/bookshelf/data/metadata/OpenLibraryClientTest.kt b/app/app/src/test/java/org/modg/bookshelf/data/metadata/OpenLibraryClientTest.kt new file mode 100644 index 0000000..2bc4c22 --- /dev/null +++ b/app/app/src/test/java/org/modg/bookshelf/data/metadata/OpenLibraryClientTest.kt @@ -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() +} diff --git a/app/app/src/test/java/org/modg/bookshelf/data/remote/PbDateFormatTest.kt b/app/app/src/test/java/org/modg/bookshelf/data/remote/PbDateFormatTest.kt new file mode 100644 index 0000000..bca053d --- /dev/null +++ b/app/app/src/test/java/org/modg/bookshelf/data/remote/PbDateFormatTest.kt @@ -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)) + } +} diff --git a/app/app/src/test/java/org/modg/bookshelf/data/repo/FakePocketBaseApi.kt b/app/app/src/test/java/org/modg/bookshelf/data/repo/FakePocketBaseApi.kt new file mode 100644 index 0000000..1cf297b --- /dev/null +++ b/app/app/src/test/java/org/modg/bookshelf/data/repo/FakePocketBaseApi.kt @@ -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(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() + + private val bookcases = mutableMapOf() + private val shelves = mutableMapOf() + private val books = mutableMapOf() + + var nextCreateBookcaseError: HttpException? = null + var nextCreateShelfError: HttpException? = null + var nextCreateBookError: HttpException? = null + val updateBookcaseErrors = mutableMapOf() + val updateShelfErrors = mutableMapOf() + val updateBookErrors = mutableMapOf() + + 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 { + 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 { + 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 { + 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 + } +} diff --git a/app/app/src/test/java/org/modg/bookshelf/data/repo/SyncEngineTest.kt b/app/app/src/test/java/org/modg/bookshelf/data/repo/SyncEngineTest.kt new file mode 100644 index 0000000..a048fc6 --- /dev/null +++ b/app/app/src/test/java/org/modg/bookshelf/data/repo/SyncEngineTest.kt @@ -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..() + 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() + 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) + } +} diff --git a/app/app/src/test/resources/fixtures/googlebooks_no_items.json b/app/app/src/test/resources/fixtures/googlebooks_no_items.json new file mode 100644 index 0000000..3bbe378 --- /dev/null +++ b/app/app/src/test/resources/fixtures/googlebooks_no_items.json @@ -0,0 +1,4 @@ +{ + "kind": "books#volumes", + "totalItems": 0 +} diff --git a/app/app/src/test/resources/fixtures/googlebooks_success.json b/app/app/src/test/resources/fixtures/googlebooks_success.json new file mode 100644 index 0000000..14a5448 --- /dev/null +++ b/app/app/src/test/resources/fixtures/googlebooks_success.json @@ -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"} + } + ] +} diff --git a/app/app/src/test/resources/fixtures/malformed.json b/app/app/src/test/resources/fixtures/malformed.json new file mode 100644 index 0000000..216c89d --- /dev/null +++ b/app/app/src/test/resources/fixtures/malformed.json @@ -0,0 +1 @@ +{ this is not valid json ] diff --git a/app/app/src/test/resources/fixtures/openlibrary_not_found.json b/app/app/src/test/resources/fixtures/openlibrary_not_found.json new file mode 100644 index 0000000..0967ef4 --- /dev/null +++ b/app/app/src/test/resources/fixtures/openlibrary_not_found.json @@ -0,0 +1 @@ +{} diff --git a/app/app/src/test/resources/fixtures/openlibrary_success.json b/app/app/src/test/resources/fixtures/openlibrary_success.json new file mode 100644 index 0000000..3d8c474 --- /dev/null +++ b/app/app/src/test/resources/fixtures/openlibrary_success.json @@ -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"}] + } +} diff --git a/docs/HANDOFF.md b/docs/HANDOFF.md index faae4af..fad2309 100644 --- a/docs/HANDOFF.md +++ b/docs/HANDOFF.md @@ -58,28 +58,52 @@ Independently re-verified on 09-06 after fixing the service: filter, so anonymous LIST would otherwise return `200 []` instead of an error. The hook forces 403. Keep it; it is why the table above passes. It is auto-loaded by the stock binary. +### Wave 1B — Android scaffold + design system: COMPLETE, verified by the orchestrator +The pre-restart worker had gotten much further than the last handoff recorded. On 09-06 the +orchestrator found everything on disk (theme, 7 shared components, MainActivity, +BookshelfApplication, Paparazzi test, all 8 Literata TTFs) and only THREE compile errors, +all the same class of trivial import bug — fixed directly by the orchestrator rather than +spending a worker session on two-line edits: +- `import androidx.compose.foundation.layout.weight` (x2: BookshelfScaffold.kt, the Paparazzi + test) — that resolves to the *internal* `RowColumnParentData.weight`. `weight` is a + ColumnScope/RowScope member; it needs NO import. Delete the line. +- SyncStatusBar.kt was missing `import androidx.compose.runtime.getValue`, so `val x by + transition.animateFloat(...)` had no delegate. + +| Check | Result | +|---|---| +| `./gradlew assembleDebug` | **exit 0** — app-debug.apk, 47MB | +| `./gradlew testDebugUnitTest` | **exit 0** | +| `./gradlew recordPaparazziDebug` | **exit 0** — 10 PNGs, light+dark | + +Snapshots: `app/app/src/test/snapshots/images/`. The orchestrator eyeballed scaffold-light: +warm paper ground, Literata serif title, thin gold hairline rule. Matches the design language. + +### Repo is now a git repo +`git init` + baseline commit `8bcd9f7` at the 1B-green point. This is deliberate: it lets the +orchestrator verify a wave with `git diff --stat` / `git log` instead of reading source files +into Opus context, and gives a rollback that isn't a whole-sprite checkpoint restore. +Root `.gitignore` covers build outputs, `server/pb_data`, `.dev-credentials`, worker logs. + +## STATE: what is IN FLIGHT +### Wave 2 — C (data layer) + D (metadata/scanning): LAUNCHED 09-06 ~01:59Z, running in parallel +Prompts: `tasks/C-data.txt`, `tasks/D-metadata.txt`. Sessions: +C-data=c2b92ca5-55d7-49b2-8a89-dc36e3ba4c9f, D-metadata=9a2c0de8-e475-4a43-9302-bc66889ee2bd + +Two coordination devices were put in place before launch; keep them for wave 3: +1. **`tasks/gw` — a `flock`-serialized gradle wrapper.** Both workers share ONE Gradle project + dir; concurrent `./gradlew` runs clobber each other's outputs. Both prompts forbid + `./gradlew` and require `tasks/gw`. Reuse this for every future parallel wave. +2. **Disjoint file ownership, stated as a hard boundary in each prompt.** C owns data.local, + data.remote, data.repo, data.prefs, AppContainer, BookshelfApplication. D owns data.metadata + and ui.scan plumbing. NEITHER may touch `app/build.gradle.kts` or `libs.versions.toml` — + the orchestrator confirmed every wave-2 dependency is ALREADY declared and wired, so there + is no legitimate reason for a worker to edit a build file. D must not wire MetadataRepository + into AppContainer (C owns it); D reports the one-line snippet instead, to be applied later. + ## STATE: what is NOT done -### Wave 1B — Android scaffold + design system: INCOMPLETE (killed mid-run by the restart) -Present: gradle wrapper, `gradle/libs.versions.toml`, `app/build.gradle.kts`, -`AndroidManifest.xml`, `proguard-rules.pro`, Literata OFL license. -Missing/unverified: ui/theme (Color/Type/Theme), the shared component set, MainActivity, -Paparazzi setup, and **any evidence the build compiles**. - -**Its session SURVIVED and is resumable — prefer this over a restart (saves quota):** -`claude -p --model sonnet --permission-mode bypassPermissions --output-format json \` -` --add-dir ~/bookshelf --resume 6823e72a-69c1-486e-ae5a-18abab84529b` -with a "continue where you left off, don't restart" prompt. (Worker A's session, for -reference, is `5e3bd183-252c-4224-99b5-91779761ccbc`.) -First thing the resumed worker must do: get `./gradlew assembleDebug` GREEN. Everything -downstream is blocked on it. - -### Waves 2-4 — not started. Prompts not yet written. -- **Wave 2 (parallel, after 1B is green):** - - C — data layer: Room entities/DAOs/DB, PocketBase Retrofit client + auth interceptor, - `SyncEngine` (push-then-pull, LWW, tombstones, client-generated 15-char ids), SettingsStore. - - D — metadata + scanning: Open Library + Google Books merge, ISBN-13 checksum validation, - CameraX + ML Kit continuous scanning. -- **Wave 3 (after C+D):** E — the six screens (setup, library, detail, scan, locations, settings). +### Waves 3-4 — not started. Prompts not yet written. +- **Wave 3 (after C+D land and are verified):** E — the six screens (setup, library, detail, scan, locations, settings). - **Wave 4:** F — Paparazzi screenshots for the user to judge the look, release keystore + signed APK, top-level README, end-to-end sync test against the live PocketBase. diff --git a/logs/C-data.sid b/logs/C-data.sid new file mode 100644 index 0000000..b3c04f9 --- /dev/null +++ b/logs/C-data.sid @@ -0,0 +1 @@ +c2b92ca5-55d7-49b2-8a89-dc36e3ba4c9f diff --git a/logs/C-data.summary b/logs/C-data.summary new file mode 100644 index 0000000..ee7149c --- /dev/null +++ b/logs/C-data.summary @@ -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. diff --git a/logs/D-metadata.sid b/logs/D-metadata.sid new file mode 100644 index 0000000..a1c05b8 --- /dev/null +++ b/logs/D-metadata.sid @@ -0,0 +1 @@ +9a2c0de8-e475-4a43-9302-bc66889ee2bd diff --git a/logs/D-metadata.summary b/logs/D-metadata.summary new file mode 100644 index 0000000..c6d9d2c --- /dev/null +++ b/logs/D-metadata.summary @@ -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). diff --git a/tasks/C-data.txt b/tasks/C-data.txt new file mode 100644 index 0000000..5326693 --- /dev/null +++ b/tasks/C-data.txt @@ -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. diff --git a/tasks/D-fix.txt b/tasks/D-fix.txt new file mode 100644 index 0000000..7987532 --- /dev/null +++ b/tasks/D-fix.txt @@ -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(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. diff --git a/tasks/D-metadata.txt b/tasks/D-metadata.txt new file mode 100644 index 0000000..394fb54 --- /dev/null +++ b/tasks/D-metadata.txt @@ -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. diff --git a/tasks/gw b/tasks/gw new file mode 100755 index 0000000..6090907 --- /dev/null +++ b/tasks/gw @@ -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 "$@" diff --git a/tasks/run-resume.sh b/tasks/run-resume.sh new file mode 100755 index 0000000..cf7be30 --- /dev/null +++ b/tasks/run-resume.sh @@ -0,0 +1,36 @@ +#!/usr/bin/env bash +# run-resume.sh +# 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"