Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 17 additions & 20 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,22 @@ server advertises their APIs. The five bottom navigation destinations are retain

> These are **debug-signed** builds for direct sideloading, not Play Store releases. Each release bumps `versionCode`, so a newer APK installs over the previous one in place.

## What's New in v1.13.0

Pinakes Android 1.13.0 follows Pinakes 0.9.2 (Mobile API 1.11.0). In the staff area the visitor log checks in a member without the card: *Find member* opens the same member search as the loan desk, and the visit is recorded exactly as with the card, only once even after a retry. The button appears only when the server announces `staff_attendance_member_pick` in `/health`; on older servers the app behaves as 1.12.0.

## What's New

The complete history is on the [Releases page](https://github.com/fabiodalez-dev/Pinakes-Android/releases).

### v1.12.0

Follows Pinakes 0.9.0 and 0.9.1 (Mobile API 1.10.0). Reader and staff loans show the usable return day the server computes from the branch closures calendar, and lateness follows the server in the library timezone, so a loan due on a closed day is no longer shown as late. Library settings → Loans gains the closures calendar section with the same-day handover switch.

### v1.11.0

Follows Pinakes 0.9.0: everything the 0.9.0 admin adds can be done from the staff area, including library settings, patron types and loan rules, the closures calendar, stocktake, patron ledger, MARC import and export, and the visitor log. The reader side follows the new circulation rules; on older servers every new field is optional and the new screens stay hidden.

## What it is

A single self-contained app any Pinakes library can hand to its members. On first
Expand Down Expand Up @@ -271,7 +287,7 @@ _contract/ OpenAPI snapshot + API spec the app is built against
All core screens are implemented and verified end-to-end on an emulator against a
live instance (onboarding, login, catalog, detail, loans, reservations, audio, PDF,
themes, languages). UnifiedPush registration is wired in the data layer; the
distributor receiver is a follow-up. See `STATUS.md` for the full breakdown.
distributor receiver is a follow-up. See `STATUS.md` for the full breakdown, and [the request-by-request Uwe verification](docs/UWE-PARITY.md) for the reader-side parity with the website.

## License

Expand All @@ -286,22 +302,3 @@ and published online resources follow the server data. Public PDFs use the
URL supplied by the server; the website action opens the article’s full page. Older servers retain the existing periodicals browser.
See [the article integration notes](docs/emeroteca-standalone-articles.md).

## Android 1.6 / current server parity

The updated client reads Mobile API 1.5.0, Desiderata 1.2.0, Emeroteca 1.13.0 and
Archives 1.5.1. Update the server/plugins before expecting the new optional
collection screens. Older instances remain usable; unsupported article facets
show an upgrade message rather than silently returning unfiltered results.

Desiderata is the library's wanted collection, separate from a member's wishlist.
Donation contact details come from the verified account. Proposals require
consent and survive transport retries/process death without creating inventory.
Archives supports paged hierarchy, text/level/year search, authorities, all
public documents and export links. Article/book citations are formatted by the
server, with five styles and text/HTML clipboard copies. Every digital book
attachment is shown; audio switches one native player at a time.

Administrative cataloguing and uploads open the protected PHP website, including
the existing article form. No native administrative CRUD API is invented.

See [the request-by-request Uwe verification](docs/UWE-PARITY.md).
26 changes: 19 additions & 7 deletions app/src/main/java/com/pinakes/app/data/local/AppDatabase.kt
Original file line number Diff line number Diff line change
Expand Up @@ -4,17 +4,18 @@ import android.content.Context
import androidx.room.Database
import androidx.room.Room
import androidx.room.RoomDatabase
import androidx.room.migration.Migration
import androidx.sqlite.db.SupportSQLiteDatabase

/**
* Local cache database. Everything in it can be rebuilt from the server, so schema changes use
* destructive migration: v1 (40-row snapshot) → v2 (whole catalogue + loans + wishlist + sync
* bookkeeping) → v3 (folded search key) simply starts empty and the next sync refills it
* (the sync watermark lives in the same database, so it restarts with a full pass). Excluded from backups
* (`backup_rules.xml`, `data_extraction_rules.xml`): it belongs to one instance and one user.
* Local cache database. V4 adds the usable return day, the server lateness and the pickup deadline
* of a loan while keeping data cached by v3.
* Older schemas can be rebuilt from the server; clearing the sync watermark triggers a full sync.
* The database is excluded from backups because it belongs to one instance and one user.
*/
@Database(
entities = [CachedBook::class, CachedLoan::class, CachedWishlistItem::class, SyncMeta::class],
version = 3,
version = 4,
exportSchema = false,
)
abstract class AppDatabase : RoomDatabase() {
Expand All @@ -25,6 +26,15 @@ abstract class AppDatabase : RoomDatabase() {
companion object {
const val FILE_NAME = "pinakes-cache.db"

/** v4 keeps the v3 rows and adds the server calendar facts of a loan, null until the next refresh. */
internal val MIGRATION_3_4 = object : Migration(3, 4) {
override fun migrate(db: SupportSQLiteDatabase) {
db.execSQL("ALTER TABLE cached_loans ADD COLUMN effectiveDueAt TEXT")
db.execSQL("ALTER TABLE cached_loans ADD COLUMN isOverdue INTEGER")
db.execSQL("ALTER TABLE cached_loans ADD COLUMN pickupDeadline TEXT")
}
}

@Volatile
private var INSTANCE: AppDatabase? = null

Expand All @@ -34,7 +44,9 @@ abstract class AppDatabase : RoomDatabase() {
context.applicationContext,
AppDatabase::class.java,
FILE_NAME,
).fallbackToDestructiveMigration().build().also { INSTANCE = it }
).addMigrations(MIGRATION_3_4)
.fallbackToDestructiveMigration()
.build().also { INSTANCE = it }
}
}
}
12 changes: 9 additions & 3 deletions app/src/main/java/com/pinakes/app/data/local/LibraryCache.kt
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,11 @@ data class CachedLoan(
val requestedAt: String? = null,
val loanedAt: String? = null,
val dueAt: String? = null,
// Server calendar facts (Mobile API 1.10.0): offline rows must not fall back to the stored due date.
val effectiveDueAt: String? = null,
val isOverdue: Boolean? = null,
val dueAttention: Boolean = false,
val pickupDeadline: String? = null,
val returnedAt: String? = null,
val renewals: Int? = null,
val cancellable: Boolean = false,
Expand Down Expand Up @@ -75,13 +79,15 @@ data class SyncMeta(
fun LoanItem.toCached(bucket: String, position: Int): CachedLoan = CachedLoan(
bucket = bucket, id = id, position = position, bookId = bookId, title = title, coverUrl = coverUrl,
status = status, statusLabel = statusLabel, requestedAt = requestedAt, loanedAt = loanedAt, dueAt = dueAt,
dueAttention = dueAttention, returnedAt = returnedAt, renewals = renewals, cancellable = cancellable,
effectiveDueAt = effectiveDueAt, isOverdue = isOverdue,
dueAttention = dueAttention, pickupDeadline = pickupDeadline, returnedAt = returnedAt,
renewals = renewals, cancellable = cancellable,
)

fun CachedLoan.toLoanItem(): LoanItem = LoanItem(
id = id, bookId = bookId, title = title, coverUrl = coverUrl, status = status, statusLabel = statusLabel,
requestedAt = requestedAt, loanedAt = loanedAt, dueAt = dueAt, dueAttention = dueAttention,
returnedAt = returnedAt, renewals = renewals, cancellable = cancellable,
requestedAt = requestedAt, loanedAt = loanedAt, dueAt = dueAt, effectiveDueAt = effectiveDueAt,
isOverdue = isOverdue, dueAttention = dueAttention, pickupDeadline = pickupDeadline, returnedAt = returnedAt, renewals = renewals, cancellable = cancellable,
)

fun ReservationItem.toCached(position: Int): CachedLoan = CachedLoan(
Expand Down
3 changes: 3 additions & 0 deletions app/src/main/java/com/pinakes/app/data/model/Models.kt
Original file line number Diff line number Diff line change
Expand Up @@ -534,6 +534,9 @@ data class LoanItem(
@SerialName("is_overdue") val isOverdue: Boolean? = null,
// Computed in the library timezone; do not re-derive from device LocalDate.
@SerialName("due_attention") val dueAttention: Boolean = false,
// Mobile API 1.12.0: last day to collect a da_ritirare loan, already moved past the
// copy branch closures by the server. Null for other states and on older servers.
@SerialName("pickup_deadline") val pickupDeadline: String? = null,
Comment thread
pullfrog[bot] marked this conversation as resolved.
Resolved
@SerialName("returned_at") val returnedAt: String? = null,
val renewals: Int? = null,
// Mobile API 1.4.4 presentation hint; DELETE /loans/{id} revalidates it.
Expand Down
6 changes: 5 additions & 1 deletion app/src/main/java/com/pinakes/app/ui/common/StatusMapping.kt
Original file line number Diff line number Diff line change
Expand Up @@ -103,7 +103,7 @@ object StatusMapping {
fun loanNeedsAttention(loan: LoanItem): Boolean =
loan.dueAttention || loanGroup(loan) == LoanGroup.Overdue

enum class LoanDateKind { Overdue, Returned, Requested, Due, Borrowed }
enum class LoanDateKind { Overdue, Returned, Requested, Pickup, Due, Borrowed }

data class LoanDate(val kind: LoanDateKind, val value: String? = null)

Expand All @@ -119,6 +119,10 @@ object StatusMapping {
return loan.requestedAt?.let { LoanDate(LoanDateKind.Requested, it) }
}
if (loan.returnedAt != null) return LoanDate(LoanDateKind.Returned, loan.returnedAt)
// A ready loan is not in hand yet: the date that matters is the last pickup day.
if (loan.status == "da_ritirare" && !loan.pickupDeadline.isNullOrBlank()) {
return LoanDate(LoanDateKind.Pickup, loan.pickupDeadline)
}
if (loan.displayDueAt != null) return LoanDate(LoanDateKind.Due, loan.displayDueAt)
if (loan.loanedAt != null) return LoanDate(LoanDateKind.Borrowed, loan.loanedAt)
return null
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -263,6 +263,8 @@ private fun androidx.compose.foundation.lazy.LazyItemScope.LoanRow(
stringResource(R.string.library_returned_on, DateFormat.date(requireNotNull(date.value)))
StatusMapping.LoanDateKind.Requested ->
stringResource(R.string.library_requested_on, DateFormat.date(requireNotNull(date.value)))
StatusMapping.LoanDateKind.Pickup ->
stringResource(R.string.library_pickup_by, DateFormat.date(requireNotNull(date.value)))
StatusMapping.LoanDateKind.Due ->
stringResource(R.string.library_due_label, DateFormat.date(requireNotNull(date.value)))
StatusMapping.LoanDateKind.Borrowed ->
Expand Down
83 changes: 83 additions & 0 deletions app/src/test/java/com/pinakes/app/CacheMigrationTest.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
package com.pinakes.app

import android.database.sqlite.SQLiteDatabase
import androidx.room.Room
import androidx.test.core.app.ApplicationProvider
import com.pinakes.app.data.local.AppDatabase
import com.pinakes.app.data.local.CachedLoan
import com.pinakes.app.data.local.toLoansData
import kotlinx.coroutines.runBlocking
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
import java.io.File

/**
* Upgrading the offline cache keeps what v3 stored. The v3 schema below is the one Room generated
* for version 3 and never changes; Room validates the migrated tables against the current
* entities, so a column added to an entity without a migration fails here instead of on a phone.
* No destructive fallback in this builder: a missing migration is an error, not an empty cache.
*/
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [34], application = android.app.Application::class)
class CacheMigrationTest {
private val context get() = ApplicationProvider.getApplicationContext<android.app.Application>()
private lateinit var file: File

@Before fun setup() {
file = context.getDatabasePath("migration-test.db")
file.parentFile?.mkdirs()
file.delete()
}

@After fun teardown() {
file.delete()
}

private fun createV3() {
val raw = SQLiteDatabase.openOrCreateDatabase(file, null)
V3_SCHEMA.forEach(raw::execSQL)
raw.execSQL("INSERT INTO cached_loans (bucket,id,position,bookId,title,status,dueAt,dueAttention,cancellable) VALUES ('active',7,0,70,'Kept','da_ritirare','2030-12-30',0,1)")
raw.execSQL("INSERT INTO sync_meta (`key`,`value`) VALUES ('watermark','w3')")
raw.version = 3
raw.close()
}

@Test fun v3RowsSurviveAndTheNewCalendarFieldsStartEmpty() = runBlocking {
createV3()
val db = Room.databaseBuilder(context, AppDatabase::class.java, file.absolutePath)
.addMigrations(AppDatabase.MIGRATION_3_4)
.allowMainThreadQueries()
.build()
try {
val loan = db.libraryDao().loans(CachedLoan.LOAN_BUCKETS).toLoansData().active.single()
assertEquals("Kept", loan.title)
assertEquals("2030-12-30", loan.dueAt)
assertNull(loan.effectiveDueAt)
assertNull(loan.isOverdue)
assertNull(loan.pickupDeadline)
assertEquals("w3", db.syncMetaDao().get("watermark"))
assertEquals(4, db.openHelper.readableDatabase.version)
} finally {
db.close()
}
}

private companion object {
val V3_SCHEMA = listOf(
"CREATE TABLE IF NOT EXISTS `cached_books` (`id` INTEGER NOT NULL, `title` TEXT NOT NULL, `subtitle` TEXT, `author` TEXT, `publisher` TEXT, `genre` TEXT, `year` INTEGER, `language` TEXT, `mediaType` TEXT, `isbn13` TEXT, `isbn10` TEXT, `ean` TEXT, `pages` INTEGER, `description` TEXT, `locationLabel` TEXT, `coverUrl` TEXT, `copiesTotal` INTEGER NOT NULL, `copiesAvailable` INTEGER NOT NULL, `loanableNow` INTEGER NOT NULL, `isWanted` INTEGER NOT NULL, `updatedAt` TEXT NOT NULL, `searchKey` TEXT NOT NULL, PRIMARY KEY(`id`))",
"CREATE INDEX IF NOT EXISTS `index_cached_books_title` ON `cached_books` (`title`)",
"CREATE INDEX IF NOT EXISTS `index_cached_books_updatedAt` ON `cached_books` (`updatedAt`)",
"CREATE TABLE IF NOT EXISTS `cached_loans` (`bucket` TEXT NOT NULL, `id` INTEGER NOT NULL, `position` INTEGER NOT NULL, `bookId` INTEGER NOT NULL, `title` TEXT NOT NULL, `coverUrl` TEXT, `status` TEXT NOT NULL, `statusLabel` TEXT, `requestedAt` TEXT, `loanedAt` TEXT, `dueAt` TEXT, `dueAttention` INTEGER NOT NULL, `returnedAt` TEXT, `renewals` INTEGER, `cancellable` INTEGER NOT NULL, `queuePosition` INTEGER, `requestedFrom` TEXT, `requestedTo` TEXT, `reservedAt` TEXT, `expiresAt` TEXT, PRIMARY KEY(`bucket`, `id`))",
"CREATE TABLE IF NOT EXISTS `cached_wishlist` (`bookId` INTEGER NOT NULL, `position` INTEGER NOT NULL, `title` TEXT NOT NULL, `author` TEXT, `year` INTEGER, `coverUrl` TEXT, `copiesAvailable` INTEGER NOT NULL, `loanableNow` INTEGER NOT NULL, PRIMARY KEY(`bookId`))",
"CREATE TABLE IF NOT EXISTS `sync_meta` (`key` TEXT NOT NULL, `value` TEXT NOT NULL, PRIMARY KEY(`key`))",
"CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
"INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'v3')",
)
}
}
25 changes: 25 additions & 0 deletions app/src/test/java/com/pinakes/app/CalendarLoanAlignmentTest.kt
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,31 @@ class CalendarLoanAlignmentTest {
assertEquals(R.string.loan_status_on_loan, StatusMapping.loan(loan).second.resId)
}

@Test fun readyLoanShowsTheLastPickupDayTheServerMovedPastAClosure() {
val loan = json.decodeFromString<LoanItem>("""{
"id":8,"status":"da_ritirare","due_at":"2030-12-30","effective_due_at":"2030-12-30",
"pickup_deadline":"2030-12-27","is_overdue":false
}""")
assertEquals("2030-12-27", loan.pickupDeadline)
assertEquals(StatusMapping.LoanDate(StatusMapping.LoanDateKind.Pickup, "2030-12-27"), StatusMapping.loanDate(loan))
}

@Test fun readyLoanFromAnOlderServerFallsBackToTheReturnDay() {
val loan = json.decodeFromString<LoanItem>("""{"status":"da_ritirare","due_at":"2030-12-30"}""")
assertNull(loan.pickupDeadline)
assertEquals(StatusMapping.LoanDate(StatusMapping.LoanDateKind.Due, "2030-12-30"), StatusMapping.loanDate(loan))
}

@Test fun blankPickupDayFallsBackToTheReturnDay() {
val loan = LoanItem(status = "da_ritirare", dueAt = "2030-12-30", pickupDeadline = " ", isOverdue = false)
assertEquals(StatusMapping.LoanDate(StatusMapping.LoanDateKind.Due, "2030-12-30"), StatusMapping.loanDate(loan))
}

@Test fun pickupDayIsIgnoredOnceTheLoanIsCollected() {
val loan = LoanItem(status = "in_corso", dueAt = "2030-12-30", pickupDeadline = "2030-12-27", isOverdue = false)
assertEquals(StatusMapping.LoanDate(StatusMapping.LoanDateKind.Due, "2030-12-30"), StatusMapping.loanDate(loan))
}

@Test fun olderReaderPayloadKeepsItsPreviousStatusAndDate() {
val loan = json.decodeFromString<LoanItem>("""{"status":"in_ritardo","due_at":"2030-12-25"}""")
assertNull(loan.isOverdue)
Expand Down
Loading
Loading