Skip to content

Commit 1284f5a

Browse files
authored
Book scan: camera/ISBN capture → reader-affinity → optional insight (Android) (#71)
* build(app): add ZXing + CameraX for book scan * feat(core): Android ISBN-13 canonicalization (mirrors server) * feat(opds): on-device OpenLibrary ISBN metadata lookup * feat(library): affinity API client method + DTOs * feat(app): ScanViewModel state machine for book scan * fix(app): address adversarial review of ScanViewModel - Run lookup() on Dispatchers.IO; the OpenLibrary client does a blocking OkHttp call without hopping dispatchers, so the VM must. Correct the KDoc that wrongly claimed it hopped internally. - Replace the 401 dead-spinner (re-emitting Working) with a recoverable ScanUiState.ReauthRequired state after firing onReauth(). - Guard every post-suspension state write with a per-scan generation check and rethrow CancellationException, so a slow/superseded scan can never clobber a newer scan's result (matches LibraryStatsViewModel's pattern). Add regression tests: 401 -> ReauthRequired (not stuck Working), stale scan does not clobber a newer result, and lookup runs off the main thread. * feat(app): book-scan UI (scan + result screens), nav + DI wiring * fix(app): address adversarial review of book-scan UI * fix(app): robust EAN-13 camera decode (rotation + YUV stride) + graceful no-server affinity * fix(app): address adversarial review of camera decode * chore(app): drop debug-coexistence config (lands in its own PR)
1 parent 00697fa commit 1284f5a

22 files changed

Lines changed: 1655 additions & 0 deletions

File tree

app/build.gradle.kts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -110,6 +110,11 @@ dependencies {
110110
implementation(libs.compose.material.icons.extended)
111111
implementation(libs.coil.compose)
112112
implementation(libs.aboutlibraries.compose)
113+
implementation(libs.zxing.core)
114+
implementation(libs.androidx.camera.core)
115+
implementation(libs.androidx.camera.camera2)
116+
implementation(libs.androidx.camera.lifecycle)
117+
implementation(libs.androidx.camera.view)
113118
debugImplementation(libs.compose.ui.tooling)
114119

115120
coreLibraryDesugaring("com.android.tools:desugar_jdk_libs:2.1.2")

app/src/main/AndroidManifest.xml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@
44
Component android:name attributes must be fully-qualified (io.theficos.ereader.Foo),
55
not relative (.Foo) — relative names resolve against the namespace and won't match. -->
66
<uses-permission android:name="android.permission.INTERNET" />
7+
<uses-permission android:name="android.permission.CAMERA" />
8+
<uses-feature android:name="android.hardware.camera.any" android:required="false" />
79

810
<application
911
android:name="io.theficos.ereader.EReaderApp"

app/src/main/java/io/theficos/ereader/di/AppContainer.kt

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ import io.theficos.ereader.data.local.db.ProgressDao
2222
import io.theficos.ereader.data.opds.BookDownloader
2323
import io.theficos.ereader.data.opds.OpdsClient
2424
import io.theficos.ereader.data.opds.OpdsHttpClient
25+
import io.theficos.ereader.data.opds.OpenLibraryClient
2526
import io.theficos.ereader.data.sync.SyncClient
2627
import io.theficos.ereader.data.sync.SyncDependencies
2728
import io.theficos.ereader.data.sync.SyncEnqueuer
@@ -43,6 +44,7 @@ import io.theficos.ereader.ui.library.LibraryPreferencesStore
4344
import io.theficos.ereader.ui.library.LibraryStatsCache
4445
import io.theficos.ereader.ui.library.LibraryStatsViewModel
4546
import io.theficos.ereader.ui.onboarding.WelcomePreferencesStore
47+
import io.theficos.ereader.ui.scan.ScanResultRegistry
4648
import java.io.File
4749
import kotlinx.coroutines.CoroutineScope
4850
import kotlinx.coroutines.Dispatchers
@@ -133,6 +135,21 @@ class AppContainer(context: Context) {
133135
http = opdsHttp.okHttp,
134136
)
135137

138+
/**
139+
* Book-scan: on-device ISBN -> metadata lookup against OpenLibrary. Plain
140+
* OkHttp, NO account auth — it talks to a third-party host, so it must not
141+
* reuse the account-authed [opdsHttp] client. Uses the client's own default
142+
* timeouts.
143+
*/
144+
val openLibraryClient: OpenLibraryClient = OpenLibraryClient()
145+
146+
/**
147+
* Book-scan: process-local handoff of a scanned book's metadata + affinity
148+
* verdict from the scan screen to the result screen. Mirrors
149+
* [catalogDetailRegistry]; resets on process death.
150+
*/
151+
val scanResultRegistry: ScanResultRegistry = ScanResultRegistry()
152+
136153
/**
137154
* Process-lifetime scope for fire-and-forget library upload work. A
138155
* SupervisorJob means a single PUT failure won't cancel sibling

app/src/main/java/io/theficos/ereader/ui/AppNavGraph.kt

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,10 @@ import io.theficos.ereader.ui.onboarding.WelcomeScreen
4444
import io.theficos.ereader.ui.onboarding.pickStartDestination
4545
import io.theficos.ereader.ui.reader.ReaderScreen
4646
import io.theficos.ereader.ui.reader.ReaderViewModel
47+
import io.theficos.ereader.ui.scan.ScanResultScreen
48+
import io.theficos.ereader.ui.scan.ScanResultViewModel
49+
import io.theficos.ereader.ui.scan.ScanScreen
50+
import io.theficos.ereader.ui.scan.ScanViewModel
4751
import io.theficos.ereader.ui.settings.LicensesScreen
4852
import io.theficos.ereader.ui.settings.SettingsScreen
4953
import io.theficos.ereader.ui.settings.SettingsViewModel
@@ -165,6 +169,7 @@ fun AppNavGraph(container: AppContainer) {
165169
val key = container.catalogDetailRegistry.put(pub)
166170
nav.navigate("catalog-detail/$key")
167171
},
172+
onScan = { nav.navigate("scan") },
168173
)
169174
Tab.SETTINGS -> SettingsScreen(
170175
viewModel = setVm,
@@ -248,6 +253,38 @@ fun AppNavGraph(container: AppContainer) {
248253
CatalogDetailScreen(viewModel = vm, onBack = { nav.popBackStack() })
249254
}
250255
}
256+
composable("scan") {
257+
val vm = remember {
258+
ScanViewModel(
259+
lookup = { isbn -> container.openLibraryClient.lookupByIsbn(isbn) },
260+
runAffinity = { body -> container.libraryClient.affinity(body) },
261+
onReauth = { container.credentialStore.notifyUnauthorized() },
262+
)
263+
}
264+
ScanScreen(
265+
viewModel = vm,
266+
onResult = { data ->
267+
val key = container.scanResultRegistry.put(data)
268+
nav.navigate("scan-result/$key") { launchSingleTop = true }
269+
},
270+
onBack = { nav.popBackStack() },
271+
)
272+
}
273+
composable(
274+
"scan-result/{key}",
275+
arguments = listOf(navArgument("key") { type = NavType.StringType }),
276+
) { backStack ->
277+
val key = backStack.arguments!!.getString("key")!!
278+
val data = remember(key) { container.scanResultRegistry.get(key) }
279+
if (data == null) {
280+
CatalogDetailUnavailable(onBack = { nav.popBackStack() })
281+
} else {
282+
val vm = remember(key) {
283+
ScanResultViewModel(data = data, ai = container.aiRepository)
284+
}
285+
ScanResultScreen(viewModel = vm, onBack = { nav.popBackStack() })
286+
}
287+
}
251288
composable("licenses") {
252289
LicensesScreen(onBack = { nav.popBackStack() })
253290
}

app/src/main/java/io/theficos/ereader/ui/catalog/CatalogScreen.kt

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,13 +29,15 @@ import androidx.compose.material.icons.filled.Check
2929
import androidx.compose.material.icons.filled.ChevronRight
3030
import androidx.compose.material.icons.filled.Close
3131
import androidx.compose.material.icons.filled.FileDownload
32+
import androidx.compose.material.icons.filled.QrCodeScanner
3233
import androidx.compose.material.icons.filled.Search
3334
import androidx.compose.material.icons.filled.Sort
3435
import androidx.compose.material.icons.outlined.Info
3536
import androidx.compose.material3.CircularProgressIndicator
3637
import androidx.compose.material3.DropdownMenu
3738
import androidx.compose.material3.DropdownMenuItem
3839
import androidx.compose.material3.ExperimentalMaterial3Api
40+
import androidx.compose.material3.FloatingActionButton
3941
import androidx.compose.material3.HorizontalDivider
4042
import androidx.compose.material3.Icon
4143
import androidx.compose.material3.IconButton
@@ -67,6 +69,7 @@ fun CatalogScreen(
6769
viewModel: CatalogViewModel,
6870
contentPadding: PaddingValues,
6971
onShowDetails: (OpdsPublication) -> Unit = {},
72+
onScan: () -> Unit = {},
7073
) {
7174
val state by viewModel.state.collectAsState()
7275
val downloadedUrls by viewModel.downloadedUrls.collectAsState()
@@ -107,6 +110,18 @@ fun CatalogScreen(
107110
}
108111
}
109112
}
113+
114+
// Scan a book: reachable from any catalog state. Overlaid as a FAB so
115+
// it survives Loading/Error states (the Loaded grid has no Scaffold
116+
// slot of its own).
117+
FloatingActionButton(
118+
onClick = onScan,
119+
modifier = Modifier
120+
.align(Alignment.BottomEnd)
121+
.padding(16.dp),
122+
) {
123+
Icon(Icons.Default.QrCodeScanner, contentDescription = "Scan a book")
124+
}
110125
}
111126
}
112127

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
package io.theficos.ereader.ui.scan
2+
3+
import io.theficos.ereader.core.metadata.MetadataBundle
4+
import io.theficos.ereader.data.library.AffinityResponse
5+
import java.util.UUID
6+
7+
/**
8+
* The data a successful scan hands off to the result screen: the canonical
9+
* ISBN-13, the resolved metadata, and the affinity verdict (null + the
10+
* `affinityUnavailable` flag when the server has no affinity backend).
11+
*/
12+
data class ScanResultData(
13+
val isbn13: String,
14+
val bundle: MetadataBundle,
15+
val affinity: AffinityResponse?,
16+
val affinityUnavailable: Boolean,
17+
)
18+
19+
/**
20+
* Transient in-memory map keyed by a short UUID, used to pass a
21+
* [ScanResultData] from `ScanScreen` to `ScanResultScreen` without encoding
22+
* the whole metadata bundle + affinity payload into a nav route argument.
23+
*
24+
* Mirrors `CatalogDetailRegistry`: lifetime is bound to the process; on
25+
* process death the registry resets and a restored result screen gets `null`
26+
* and shows a fallback. That's acceptable — a scan is a transient action the
27+
* user can simply repeat.
28+
*/
29+
class ScanResultRegistry {
30+
private val map = mutableMapOf<String, ScanResultData>()
31+
32+
fun put(data: ScanResultData): String {
33+
val key = UUID.randomUUID().toString()
34+
synchronized(map) { map[key] = data }
35+
return key
36+
}
37+
38+
fun get(key: String): ScanResultData? = synchronized(map) { map[key] }
39+
}
Lines changed: 198 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,198 @@
1+
package io.theficos.ereader.ui.scan
2+
3+
import androidx.compose.foundation.layout.Arrangement
4+
import androidx.compose.foundation.layout.Column
5+
import androidx.compose.foundation.layout.Row
6+
import androidx.compose.foundation.layout.Spacer
7+
import androidx.compose.foundation.layout.fillMaxSize
8+
import androidx.compose.foundation.layout.fillMaxWidth
9+
import androidx.compose.foundation.layout.height
10+
import androidx.compose.foundation.layout.padding
11+
import androidx.compose.foundation.layout.width
12+
import androidx.compose.foundation.rememberScrollState
13+
import androidx.compose.foundation.verticalScroll
14+
import androidx.compose.material.icons.Icons
15+
import androidx.compose.material.icons.automirrored.filled.ArrowBack
16+
import androidx.compose.material3.Button
17+
import androidx.compose.material3.ExperimentalMaterial3Api
18+
import androidx.compose.material3.Icon
19+
import androidx.compose.material3.IconButton
20+
import androidx.compose.material3.MaterialTheme
21+
import androidx.compose.material3.Scaffold
22+
import androidx.compose.material3.Text
23+
import androidx.compose.material3.TopAppBar
24+
import androidx.compose.runtime.Composable
25+
import androidx.compose.runtime.collectAsState
26+
import androidx.compose.runtime.getValue
27+
import androidx.compose.ui.Modifier
28+
import androidx.compose.ui.unit.dp
29+
import io.theficos.ereader.data.library.AffinityResponse
30+
import io.theficos.ereader.ui.bookdetail.InsightSection
31+
import io.theficos.ereader.ui.bookdetail.InsightUiState
32+
import io.theficos.ereader.ui.components.CoverImage
33+
import io.theficos.ereader.ui.components.QuireCard
34+
35+
/**
36+
* Scan-result screen.
37+
*
38+
* Layout, top to bottom:
39+
* - Header: cover (OpenLibrary cover-by-ISBN), title, author.
40+
* - Owned banner (leads when the book is already in the user's library).
41+
* - Affinity card: score + band + reason bullets, OR "not enough history yet"
42+
* for the `unknown` band, OR an "affinity unavailable on this server" note
43+
* when the affinity backend is mode-gated off.
44+
* - "Get full insight" → triggers [ScanResultViewModel.loadInsight] and
45+
* renders the shared [InsightSection] below.
46+
*/
47+
@OptIn(ExperimentalMaterial3Api::class)
48+
@Composable
49+
fun ScanResultScreen(
50+
viewModel: ScanResultViewModel,
51+
onBack: () -> Unit,
52+
) {
53+
val data = viewModel.data
54+
val bundle = data.bundle
55+
val insight by viewModel.insight.collectAsState()
56+
val coverUrl = data.isbn13.takeIf { it.isNotBlank() }
57+
?.let { "https://covers.openlibrary.org/b/isbn/$it-L.jpg" }
58+
59+
Scaffold(
60+
topBar = {
61+
TopAppBar(
62+
title = { Text("Scan result") },
63+
navigationIcon = {
64+
IconButton(onClick = onBack) {
65+
Icon(
66+
Icons.AutoMirrored.Filled.ArrowBack,
67+
contentDescription = "Back",
68+
)
69+
}
70+
},
71+
)
72+
},
73+
) { padding ->
74+
Column(
75+
modifier = Modifier
76+
.padding(padding)
77+
.fillMaxSize()
78+
.verticalScroll(rememberScrollState())
79+
.padding(16.dp),
80+
verticalArrangement = Arrangement.spacedBy(12.dp),
81+
) {
82+
Row(verticalAlignment = androidx.compose.ui.Alignment.Top) {
83+
CoverImage(
84+
source = coverUrl,
85+
title = bundle.title,
86+
author = bundle.author,
87+
modifier = Modifier
88+
.width(96.dp)
89+
.height(144.dp),
90+
)
91+
Spacer(Modifier.width(16.dp))
92+
Column(modifier = Modifier.weight(1f)) {
93+
Text(
94+
bundle.title,
95+
style = MaterialTheme.typography.headlineSmall,
96+
)
97+
bundle.author?.let {
98+
Spacer(Modifier.height(4.dp))
99+
Text(
100+
it,
101+
style = MaterialTheme.typography.titleMedium,
102+
color = MaterialTheme.colorScheme.onSurfaceVariant,
103+
)
104+
}
105+
}
106+
}
107+
108+
val owned = data.affinity?.owned
109+
if (owned != null) {
110+
OwnedBanner(readingStatus = owned.readingStatus)
111+
}
112+
113+
when {
114+
data.affinityUnavailable -> AffinityUnavailableCard()
115+
data.affinity != null -> AffinityCard(data.affinity)
116+
}
117+
118+
Button(
119+
onClick = { viewModel.loadInsight() },
120+
enabled = viewModel.insightAvailable() && insight !is InsightUiState.Loading,
121+
modifier = Modifier.fillMaxWidth(),
122+
) {
123+
Text("Get full insight")
124+
}
125+
126+
InsightSection(insight, onRetry = { viewModel.retryInsight() })
127+
128+
Spacer(Modifier.height(24.dp))
129+
}
130+
}
131+
}
132+
133+
@Composable
134+
private fun OwnedBanner(readingStatus: String) {
135+
QuireCard(modifier = Modifier.fillMaxWidth().padding(horizontal = 8.dp)) {
136+
Column {
137+
Text("Already in your library", style = MaterialTheme.typography.titleSmall)
138+
Spacer(Modifier.height(4.dp))
139+
Text(
140+
"Status: ${readingStatus.replace('_', ' ')}",
141+
style = MaterialTheme.typography.bodyMedium,
142+
)
143+
}
144+
}
145+
}
146+
147+
@Composable
148+
private fun AffinityCard(affinity: AffinityResponse) {
149+
QuireCard(modifier = Modifier.fillMaxWidth().padding(horizontal = 8.dp)) {
150+
Column {
151+
if (affinity.band == "unknown") {
152+
Text("Affinity", style = MaterialTheme.typography.titleSmall)
153+
Spacer(Modifier.height(4.dp))
154+
Text(
155+
"Not enough history yet to score this for you.",
156+
style = MaterialTheme.typography.bodyMedium,
157+
)
158+
return@Column
159+
}
160+
161+
val header = buildString {
162+
append(affinity.band.replaceFirstChar { it.uppercase() })
163+
affinity.score?.let { append(" · $it") }
164+
}
165+
Text("Affinity", style = MaterialTheme.typography.titleSmall)
166+
Spacer(Modifier.height(4.dp))
167+
Text(header, style = MaterialTheme.typography.titleMedium)
168+
if (affinity.reasons.isNotEmpty()) {
169+
Spacer(Modifier.height(8.dp))
170+
affinity.reasons.forEach { reason ->
171+
val marker = when (reason.polarity) {
172+
"positive" -> "+"
173+
"negative" -> ""
174+
else -> ""
175+
}
176+
Text(
177+
"$marker ${reason.message}",
178+
style = MaterialTheme.typography.bodyMedium,
179+
)
180+
}
181+
}
182+
}
183+
}
184+
}
185+
186+
@Composable
187+
private fun AffinityUnavailableCard() {
188+
QuireCard(modifier = Modifier.fillMaxWidth().padding(horizontal = 8.dp)) {
189+
Column {
190+
Text("Affinity", style = MaterialTheme.typography.titleSmall)
191+
Spacer(Modifier.height(4.dp))
192+
Text(
193+
"Affinity scoring isn't available on this server.",
194+
style = MaterialTheme.typography.bodyMedium,
195+
)
196+
}
197+
}
198+
}

0 commit comments

Comments
 (0)