Build: GREEN. ./gradlew assembleDebug succeeds; ./gradlew lintDebug passes (0 errors). Kotlin compiles clean.
- APK:
pinakes-debug.apk(repo root, ~20 MB) — copied fromapp/build/outputs/apk/debug/app-debug.apk. Install:adb install -r pinakes-debug.apk. - Package:
com.pinakes.app· versionName1.0· minSdk 26 · target/compileSdk 35 · launchableMainActivity. - Verified on an emulator against a live Pinakes instance (Android 15 / API 35 AVD →
http://10.0.2.2:8081): onboarding →/healthdiscovery → login (200) → catalog search with real books → book cards with correct titles/authors/availability. Two real bugs were found and fixed during this smoke test — see Fixes applied below. The app is fully localized in 4 languages (German verified live) — see i18n.
adb install -r pinakes-debug.apkOn first launch the app shows Onboarding: enter your Pinakes instance URL.
- The app calls
GET <url>/api/v1/health, shows the library name/logo and checksapp_access_enabled+ transport. HTTPS is required except for localhost /127.0.0.1/10.0.2.2(emulator → host) /[::1]. - Local dev API:
http://<lan-ip>:8081(Apache on :8081). From the Android emulator usehttp://10.0.2.2:8081. The Pinakes instance must have mobile app access enabled or login is refused. - Then Login (email/password) → bearer token is stored in EncryptedSharedPreferences and sent
as
Authorization: Beareron every authed call.
| Screen | State | Notes |
|---|---|---|
| 1. Onboarding | ✅ | URL → /health discovery card (name, logo, https + app-access status), continue gated on app-access + secure transport |
| 2. Login | ✅ | Email/password, mapped API error messages (invalid creds, app disabled, rate-limited w/ Retry-After, network), "use a different library" |
| 3. Catalog Search | ✅ | Debounced query, filter bottom sheet (available / genre / author / publisher / language) w/ active-count badge, cursor infinite scroll, BookCards w/ covers + availability chips, loading skeletons / empty / error |
| 4. Book Detail | ✅ | Full payload (cover, metadata, copies, shelf, ISBNs), personal-history chips, Reserve/Request loan, Wishlist toggle, ETag/304 reuse via repo |
| 5. My Library | ✅ | Tabs: Active (active+pending) / History / Reservations; cancel pending reservation w/ confirm; pull-to-refresh |
| 6. Wishlist | ✅ | List + remove (optimistic), pull-to-refresh, empty state |
| 7. Profile | ✅ | View, edit (nome/cognome), change password, in-app language switcher (System / it / en / fr / de), devices list w/ per-device sign-out, logout |
| 8. Notifications | ✅ | Feed w/ per-type icons, read/unread styling, pull-to-refresh |
| 9. Contact | ✅ | POST /messages subject+body form, success state |
- Bottom nav: Search / Library / Wishlist / Profile. Nested routes: Book Detail, Notifications, Contact.
- Design system: Material 3 light and dark, brand magenta
#D70161+ indigo#6366F1, Inter (bundled), rounded cards, soft shadows, brand-gradient header on onboarding/login, navigation transitions + list/press animations, adaptive launcher icon. - Architecture: Navigation-Compose + ViewModel/StateFlow, manual DI (
ServiceLocatorvia aLocalServicescomposition local), Retrofit + OkHttp + kotlinx.serialization, Coil for covers. All loading/empty/error states handled per screen.
The app is fully localized in 4 languages — Italian, English, French, German — matching the
Pinakes backend locales. It follows the device locale by default and offers an in-app language
switcher in Profile (System default / Italiano / English / Français / Deutsch) via
AppCompatDelegate.setApplicationLocales(...), persisted across restarts (autoStoreLocales).
- Single source of truth = JSON. Translations live in
i18n/{en,it,fr,de}.json(209 keys each, en = default/source). A Gradle task (GenerateI18nResTask) generatesres/values*/strings.xmlfrom those JSONs at build time, so the app uses standard Android string resources but the editable source stays JSON — syncable with the web app'slocale/*.json. - All user-facing strings use
stringResource(...); no hardcoded UI text remains. Server-provided messages (API errors) still pass through verbatim. - Verified live: switching to Deutsch in-app re-localized the entire UI instantly (Profil, bottom nav Suchen/Bibliothek/Merkliste/Profil, all rows), and the choice survived an app restart.
The app built green from day one, but running it on a real emulator against a live instance surfaced two genuine bugs that a build-only check could not have caught:
- Cleartext HTTP was blocked (Android 9+ default). The app could not reach any
http://instance — including a loopback/dev server. Fixed by addingres/xml/network_security_config.xmlwhitelisting cleartext forlocalhost/127.0.0.1/10.0.2.2(mirrors the app's own "HTTPS-except-loopback" onboarding rule) + referencing it in the manifest. Every other host still requires HTTPS. - Blank book titles + wrong availability. The catalog Kotlin models used Italian field names
while the API serializes English snake_case keys (
title,author,cover_url,copies_available,loanable_now, …). kotlinx.serialization left every field empty, so cards showed no title and always read "On loan". Fixed by aligningBookSummary/BookDetail(and the loan / reservation / wishlist / notification item models) to the real API keys, and fixing the availability logic (loanable_now || copies_available > 0). Verified: titles, authors and the green Available / red On loan chips now render correctly.
The optional server-side Book Club plugin is now surfaced in the app. It is
auto-discovered and gated: after login the app probes GET /api/v1/bookclub/health
(public, no token) and only shows the section when the plugin + its mobile module are
active for the instance (a 404 hides it). The flag is cached in an encrypted store and
refreshed alongside /health, so the entry never flickers and a first-run/offline probe
keeps it hidden — same "confirm before showing" rule as public registration.
- Data layer —
BookClubApi(Retrofit) +BookClubRepository, reusing the same base URL and bearer token as the core client; the availability flag lives inFeatureStore(InstanceFeatures.bookClubAvailable) next to the other instance gates. The plugin uses a different envelope ({success, data, error}vs the core{data, meta, error}), handled by a dedicatedbookClubCallthat reuses the core error pipeline (parseErrorBody, status fallbacks,Retry-After) and the sharedApiResult/ErrorCodes. - Screens — a Book Club home (your reading dashboard, your clubs, discover directory,
reached from Profile) and a club detail (reading list with state chips + progress,
polls, meetings). Actions wired end-to-end: join, vote (simple / multi / weighted,
with the ballot pre-seeded from
my_option_ids), RSVP (yes / maybe / no), and reading progress. Guests are read-only. Advanced poll modes and proposing a title deep-link to the web page (per the API contract). - i18n — all new strings added to the 4 locale JSONs (it/en/fr/de), in parity.
- Build —
testDebugUnitTest,assembleDebug,lintDebugandassembleRelease(R8/minify — exercises the keep rules for the new@Serializablemodels) all BUILD SUCCESSFUL.
A full adversarial review against the Book Club plugin + mobile-api PHP sources confirmed the wire contract (paths, regexes, casts, nullability, envelopes, error codes) and fixed every confirmed finding:
- Rejoin parity:
canJoinnow mirrors the server — members with statusleft/suspendedcan re-join (the web shows the join form for them; onlybannedis blocked). - Expired polls: the mobile API never lazy-closes polls (only the web page/cron do), so a
past
closes_atnow renders as Closed instead of a ballot that can only 409poll_closed. - Full meetings: the Going chip disables when
yes_count >= seats(unless already going), mirroring the server's 409no_seatsrule; maybe/no stay enabled. - MySQL DATETIME timestamps: the server emits raw
Y-m-d H:i:sfor reviews, devices and book club dates —DateFormatnow parses wall-clock values, fixing raw strings that previously rendered verbatim on review cards and the device list. - Home "Available now" shelf: restored the server-side
available=truequery (the cached first page is only the offline fallback), so availability beyond the newest 40 titles shows again. - Instance switch hygiene:
forgetInstance()now purges the Room catalog cache + ETag cache (no cross-library leak), and a late availability probe can no longer resurrect the Book Club flag after switching (probe results are guarded by instance URL). - App-access gate: the Book Club section also hides when core
/healthreportsapp_access_enabled=false(the plugin's public health answers 200 regardless). - Startup/login latency: the core health call and the plugin probe now run concurrently.
- Error codes:
ErrorCodesnow matches the server's realapp_access_disabled(the oldapp_disabledconstant matched nothing the server emits). - Partial failures: a dashboard fetch failure on the Book Club home now surfaces a snackbar instead of silently dropping the "Your reading" section.
- Push (UnifiedPush): STUBBED — not wired. The data layer is ready (
/me/push/subscribe,/me/push/prefsinPinakesApi+NotificationsRepository), and/healthexposesvapid_public_key. A UnifiedPush distributor receiver + push-prefs screen were deliberately not added to keep the build minimal and green per the spec's "stub if time-limited" clause. Next step: add theorg.unifiedpush.android:connectordependency, aMessagingReceiver, callsubscribePush()on registration, and build a prefs screen on top ofpushPrefs()/updatePushPrefs(). - Register / forgot-password: API + repository methods exist (
AuthRepository.register/forgotPassword) but no dedicated screens — login is the only auth entry point in this build.
./gradlew assembleDebug→ BUILD SUCCESSFUL;./gradlew lintDebug→ 0 errors.- APK badging verified (
aapt dump badging): correct package, label, launchable activity. - Manual smoke test on an Android 15 / API 35 emulator against a live instance (
http://10.0.2.2:8081): onboarding →/healthdiscovery card → admin login (POST /auth/login→200) → catalogGET /catalog/search→ book cards with real titles/authors + correct availability chips → Profile loaded. Two bugs found and fixed (see Fixes applied); re-verified green afterwards. - i18n verified live: in-app switch to Deutsch re-localized the whole UI immediately and persisted across an app restart.
- Not run: automated instrumented tests /
testDebugUnitTest(no unit tests authored for this build).