From 09b669920a8e9cb37900b69450c3032d21a07ffa Mon Sep 17 00:00:00 2001 From: fabiodalez-dev Date: Mon, 12 Oct 2026 15:00:01 +0200 Subject: [PATCH 1/2] Fix digital lending, media transport and PDF cleanup --- app/build.gradle.kts | 4 + .../com/pinakes/app/PdfReaderLifecycleTest.kt | 67 +++++++ .../java/com/pinakes/app/RestylingUiTest.kt | 4 +- .../app/data/model/CollectionsModels.kt | 1 + .../java/com/pinakes/app/data/model/Models.kt | 1 + .../app/data/network/AudioDataSource.kt | 19 ++ .../pinakes/app/ui/common/ContributorRole.kt | 17 ++ .../pinakes/app/ui/components/AudioPlayer.kt | 11 +- .../screens/collections/CollectionsScreens.kt | 8 + .../app/ui/screens/detail/BookDetailScreen.kt | 11 +- .../app/ui/screens/detail/PdfReaderDialog.kt | 172 +++++++++++++----- .../com/pinakes/app/AudioTransportTest.kt | 82 +++++++++ .../pinakes/app/DigitalAvailabilityTest.kt | 84 +++++++++ .../java/com/pinakes/app/PdfCleanupTest.kt | 62 +++++++ gradle/libs.versions.toml | 3 + i18n/de.json | 9 +- i18n/en.json | 9 +- i18n/fr.json | 9 +- i18n/it.json | 9 +- 19 files changed, 522 insertions(+), 60 deletions(-) create mode 100644 app/src/androidTest/java/com/pinakes/app/PdfReaderLifecycleTest.kt create mode 100644 app/src/main/java/com/pinakes/app/data/network/AudioDataSource.kt create mode 100644 app/src/main/java/com/pinakes/app/ui/common/ContributorRole.kt create mode 100644 app/src/test/java/com/pinakes/app/AudioTransportTest.kt create mode 100644 app/src/test/java/com/pinakes/app/DigitalAvailabilityTest.kt create mode 100644 app/src/test/java/com/pinakes/app/PdfCleanupTest.kt diff --git a/app/build.gradle.kts b/app/build.gradle.kts index df7f827..5ca1b70 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -286,6 +286,7 @@ dependencies { androidTestImplementation(platform(libs.androidx.compose.bom)) androidTestImplementation(libs.androidx.ui.test.junit4) androidTestImplementation(libs.androidx.test.runner) + androidTestImplementation(libs.okhttp.mockwebserver) implementation(libs.androidx.navigation.compose) @@ -300,6 +301,7 @@ dependencies { // AndroidX Media3 ExoPlayer for streaming audiobooks (in-page player). implementation(libs.androidx.media3.exoplayer) implementation(libs.androidx.media3.ui) + implementation(libs.androidx.media3.datasource.okhttp) implementation(libs.androidx.security.crypto) implementation(libs.androidx.datastore.preferences) @@ -322,6 +324,8 @@ dependencies { // Unit tests (JVM + Robolectric for Room DAO). testImplementation(libs.junit) + testImplementation(libs.okhttp.mockwebserver) + testImplementation(libs.okhttp.tls) testImplementation(libs.kotlinx.coroutines.test) testImplementation(libs.robolectric) testImplementation(libs.androidx.room.testing) diff --git a/app/src/androidTest/java/com/pinakes/app/PdfReaderLifecycleTest.kt b/app/src/androidTest/java/com/pinakes/app/PdfReaderLifecycleTest.kt new file mode 100644 index 0000000..a4be397 --- /dev/null +++ b/app/src/androidTest/java/com/pinakes/app/PdfReaderLifecycleTest.kt @@ -0,0 +1,67 @@ +package com.pinakes.app + +import android.graphics.Paint +import android.graphics.pdf.PdfDocument +import androidx.test.platform.app.InstrumentationRegistry +import com.pinakes.app.ui.screens.detail.CachedPdfDocument +import com.pinakes.app.ui.screens.detail.holdPdfDocument +import kotlinx.coroutines.* +import okhttp3.OkHttpClient +import okhttp3.mockwebserver.MockResponse +import okhttp3.mockwebserver.MockWebServer +import org.junit.Assert.* +import org.junit.Test +import java.io.ByteArrayOutputStream +import java.nio.file.Files + +/** Platform PDF parsing/rendering is tested on Android, not Robolectric's stub PdfRenderer. */ +class PdfReaderLifecycleTest { + private fun cacheDirectory() = Files.createTempDirectory( + InstrumentationRegistry.getInstrumentation().targetContext.cacheDir.toPath(), "pdf-reader-").toFile() + + private fun pdfBytes(): ByteArray = ByteArrayOutputStream().use { sink -> + val pdf = PdfDocument() + try { + val page = pdf.startPage(PdfDocument.PageInfo.Builder(200, 300, 1).create()) + page.canvas.drawText("Pinakes PDF lifecycle", 10f, 30f, Paint().apply { textSize = 12f }) + pdf.finishPage(page) + pdf.writeTo(sink) + } finally { pdf.close() } + sink.toByteArray() + } + + @Test fun malformedPdfRemovesTheDownloadedFile() { + val dir = cacheDirectory() + try { + val file = dir.resolve("invalid.pdf").apply { writeText("not a PDF") } + assertTrue(runCatching { CachedPdfDocument.open(file) }.isFailure) + assertFalse(file.exists()) + } finally { dir.deleteRecursively() } + } + + @Test fun closeRemovesThePdfAndIsRepeatable() { + val dir = cacheDirectory() + try { + val file = dir.resolve("book.pdf").apply { writeBytes(pdfBytes()) } + val document = CachedPdfDocument.open(file) + assertEquals(1, document.pageCount) + document.renderPage(0, 100).recycle() + document.close() + document.close() + assertFalse(file.exists()) + } finally { dir.deleteRecursively() } + } + + @Test fun dismissingTheReaderReleasesItsFile() = runBlocking { + val dir = cacheDirectory() + try { MockWebServer().use { server -> + server.enqueue(MockResponse().setBody(okio.Buffer().write(pdfBytes()))) + val ready = CompletableDeferred() + val reader = launch { holdPdfDocument(server.url("/book.pdf").toString(), dir, OkHttpClient()) { ready.complete(Unit) } } + withTimeout(10_000) { ready.await() } + assertEquals(1, dir.listFiles()!!.size) + reader.cancelAndJoin() + assertEquals(0, dir.listFiles()!!.size) + } } finally { dir.deleteRecursively() } + } +} diff --git a/app/src/androidTest/java/com/pinakes/app/RestylingUiTest.kt b/app/src/androidTest/java/com/pinakes/app/RestylingUiTest.kt index 4cf8960..8e64f4b 100644 --- a/app/src/androidTest/java/com/pinakes/app/RestylingUiTest.kt +++ b/app/src/androidTest/java/com/pinakes/app/RestylingUiTest.kt @@ -13,6 +13,7 @@ import androidx.compose.ui.test.junit4.createComposeRule import androidx.compose.ui.unit.dp import androidx.test.platform.app.InstrumentationRegistry import com.pinakes.app.data.model.BookDetail +import com.pinakes.app.data.model.Availability import com.pinakes.app.ui.components.* import com.pinakes.app.ui.screens.detail.DetailContent import com.pinakes.app.ui.screens.home.HomeContent @@ -104,7 +105,8 @@ class RestylingUiTest { @Test fun detailWithNoDigitalFilesOmitsTheDigitalSectionAndRetainsActions() { var borrowed = false - compose.setContent { PinakesTheme { DetailContent(BookDetail(title = "Blank book"), false, false, + compose.setContent { PinakesTheme { DetailContent(BookDetail(title = "Blank book", + availability = Availability(copiesTotal = 1, copiesAvailable = 0, loanableNow = false, state = "on_loan")), false, false, canBorrow = true, showWishlist = true, showReviews = false, onReserve = { borrowed = true }, onToggleWishlist = {}, onShowMessage = {}) } } compose.onNodeWithText(text(R.string.restyle_digital)).assertDoesNotExist() diff --git a/app/src/main/java/com/pinakes/app/data/model/CollectionsModels.kt b/app/src/main/java/com/pinakes/app/data/model/CollectionsModels.kt index c2a10b8..6833fc7 100644 --- a/app/src/main/java/com/pinakes/app/data/model/CollectionsModels.kt +++ b/app/src/main/java/com/pinakes/app/data/model/CollectionsModels.kt @@ -25,6 +25,7 @@ data class WantedBook( @SerialName("cover_url") val coverUrl: String? = null, @SerialName("web_url") val webUrl: String? = null, val wanted: Boolean = true, + val contributors: List = emptyList(), ) @Serializable diff --git a/app/src/main/java/com/pinakes/app/data/model/Models.kt b/app/src/main/java/com/pinakes/app/data/model/Models.kt index 1821be7..fb99cd8 100644 --- a/app/src/main/java/com/pinakes/app/data/model/Models.kt +++ b/app/src/main/java/com/pinakes/app/data/model/Models.kt @@ -276,6 +276,7 @@ data class BookDetail( val copiesAvailable: Int get() = availability.copiesAvailable val loanableNow: Boolean get() = availability.loanableNow val available: Boolean get() = availability.loanableNow || availability.copiesAvailable > 0 + val digitalOnly: Boolean get() = copiesTotal == 0 && digitalFiles.isNotEmpty() /** Old servers still advertise one audio and one ebook; list-aware servers own the list. */ val digitalFiles: List get() = digitalAttachments.ifEmpty { diff --git a/app/src/main/java/com/pinakes/app/data/network/AudioDataSource.kt b/app/src/main/java/com/pinakes/app/data/network/AudioDataSource.kt new file mode 100644 index 0000000..0663baa --- /dev/null +++ b/app/src/main/java/com/pinakes/app/data/network/AudioDataSource.kt @@ -0,0 +1,19 @@ +@file:androidx.annotation.OptIn(androidx.media3.common.util.UnstableApi::class) + +package com.pinakes.app.data.network + +import android.content.Context +import androidx.media3.datasource.DataSource +import androidx.media3.datasource.DefaultDataSource +import androidx.media3.datasource.okhttp.OkHttpDataSource +import okhttp3.OkHttpClient + +/** No API bearer token: media may be hosted on a different origin. */ +internal fun guardedMediaHttpClient(allowInsecureHttp: () -> Boolean): OkHttpClient = OkHttpClient.Builder() + .addInterceptor(CleartextGuardInterceptor(allowInsecureHttp)) + // Application interceptors run once; this also checks each redirected destination. + .addNetworkInterceptor(CleartextGuardInterceptor(allowInsecureHttp)) + .build() + +internal fun audioDataSourceFactory(context: Context, allowInsecureHttp: () -> Boolean): DataSource.Factory = + DefaultDataSource.Factory(context, OkHttpDataSource.Factory(guardedMediaHttpClient(allowInsecureHttp))) diff --git a/app/src/main/java/com/pinakes/app/ui/common/ContributorRole.kt b/app/src/main/java/com/pinakes/app/ui/common/ContributorRole.kt new file mode 100644 index 0000000..3acf539 --- /dev/null +++ b/app/src/main/java/com/pinakes/app/ui/common/ContributorRole.kt @@ -0,0 +1,17 @@ +package com.pinakes.app.ui.common + +import androidx.compose.runtime.Composable +import androidx.compose.ui.res.stringResource +import com.pinakes.app.R + +/** Contributor entities share the author registry, but keep their bibliographic role (#237). */ +@Composable +fun contributorRoleLabel(role: String?): String = when (role) { + "principale", "autore", "author" -> stringResource(R.string.credit_author) + "co-autore" -> stringResource(R.string.credit_coauthor) + "traduttore" -> stringResource(R.string.credit_translator) + "illustratore" -> stringResource(R.string.credit_illustrator) + "curatore" -> stringResource(R.string.credit_editor) + "colorista" -> stringResource(R.string.credit_colorist) + else -> role?.takeIf(String::isNotBlank)?.replace('_', ' ') ?: stringResource(R.string.credit_contributor) +} diff --git a/app/src/main/java/com/pinakes/app/ui/components/AudioPlayer.kt b/app/src/main/java/com/pinakes/app/ui/components/AudioPlayer.kt index ddd0d9c..a2926b6 100644 --- a/app/src/main/java/com/pinakes/app/ui/components/AudioPlayer.kt +++ b/app/src/main/java/com/pinakes/app/ui/components/AudioPlayer.kt @@ -1,3 +1,5 @@ +@file:androidx.annotation.OptIn(androidx.media3.common.util.UnstableApi::class) + package com.pinakes.app.ui.components import androidx.compose.foundation.clickable @@ -37,6 +39,10 @@ import androidx.media3.common.MediaItem import androidx.media3.common.PlaybackException import androidx.media3.common.Player import androidx.media3.exoplayer.ExoPlayer +import androidx.media3.exoplayer.source.DefaultMediaSourceFactory +import com.pinakes.app.data.network.audioDataSourceFactory +import com.pinakes.app.data.network.NetworkEntryPoint +import dagger.hilt.android.EntryPointAccessors import com.pinakes.app.R import com.pinakes.app.ui.theme.Spacing import kotlinx.coroutines.delay @@ -55,7 +61,10 @@ fun AudioPlayer( val context = LocalContext.current val player = remember(audioUrl) { - ExoPlayer.Builder(context).build().apply { + val session = EntryPointAccessors.fromApplication(context.applicationContext, NetworkEntryPoint::class.java).sessionStore() + val sources = DefaultMediaSourceFactory(context) + .setDataSourceFactory(audioDataSourceFactory(context) { session.allowInsecureHttp }) + ExoPlayer.Builder(context).setMediaSourceFactory(sources).build().apply { setMediaItem(MediaItem.fromUri(audioUrl)) prepare() } diff --git a/app/src/main/java/com/pinakes/app/ui/screens/collections/CollectionsScreens.kt b/app/src/main/java/com/pinakes/app/ui/screens/collections/CollectionsScreens.kt index 5a4e21c..5c0974f 100644 --- a/app/src/main/java/com/pinakes/app/ui/screens/collections/CollectionsScreens.kt +++ b/app/src/main/java/com/pinakes/app/ui/screens/collections/CollectionsScreens.kt @@ -62,6 +62,7 @@ fun WantedBookScreen(onNavigateUp: () -> Unit, onDonate: (Int) -> Unit, vm: Want Text(book.title, style = PublicationTitleStyle) book.subtitle?.let { Text(it, style = MaterialTheme.typography.titleMedium) } Metadata(stringResource(R.string.donation_author), book.author) + WantedContributorCredits(book.contributors) Metadata(stringResource(R.string.book_meta_publisher), book.publisher) Metadata(stringResource(R.string.book_meta_year), book.year?.toString()) Metadata("ISBN", book.isbn) @@ -72,6 +73,13 @@ fun WantedBookScreen(onNavigateUp: () -> Unit, onDonate: (Int) -> Unit, vm: Want } } +@Composable +internal fun WantedContributorCredits(contributors: List) { + contributors.filter { it.role !in listOf("principale", "co-autore") }.forEach { credit -> + Metadata(com.pinakes.app.ui.common.contributorRoleLabel(credit.role), credit.name) + } +} + @Composable fun ArchivesScreen(onNavigateUp: () -> Unit, onOpenArchive: (Int) -> Unit, vm: ArchivesViewModel = hiltViewModel()) { val state by vm.state.collectAsStateWithLifecycle() diff --git a/app/src/main/java/com/pinakes/app/ui/screens/detail/BookDetailScreen.kt b/app/src/main/java/com/pinakes/app/ui/screens/detail/BookDetailScreen.kt index 197e362..964ca50 100644 --- a/app/src/main/java/com/pinakes/app/ui/screens/detail/BookDetailScreen.kt +++ b/app/src/main/java/com/pinakes/app/ui/screens/detail/BookDetailScreen.kt @@ -382,7 +382,8 @@ internal fun DetailContent( Text(initials, fontSize = 11.sp, fontWeight = FontWeight.Bold, color = colors.accentStrong) } Spacer(Modifier.width(8.dp)) - Text(author.name + (author.role?.takeIf { it.isNotBlank() && it != "autore" && it != "author" }?.let { " · $it" } ?: ""), + val roleLabel = author.role?.takeIf(String::isNotBlank)?.let { com.pinakes.app.ui.common.contributorRoleLabel(it) } + Text(author.name + (roleLabel?.let { " · $it" } ?: ""), style = MaterialTheme.typography.bodyMedium) } } @@ -402,7 +403,9 @@ internal fun DetailContent( shadowElevation = 2.dp, modifier = Modifier.fillMaxWidth()) { Column(Modifier.padding(22.dp)) { val availableLabel = stringResource(R.string.book_copies_available_of, book.copiesAvailable, book.copiesTotal) - val (availStatus, availLabel) = when (book.availability.state) { + val (availStatus, availLabel) = if (book.digitalOnly) { + AvailabilityStatus.Digital to stringResource(R.string.restyle_digital_edition) + } else when (book.availability.state) { "available" -> AvailabilityStatus.Available to stringResource(R.string.availability_available) "on_loan" -> AvailabilityStatus.Unavailable to stringResource(R.string.book_on_loan) "reserved" -> AvailabilityStatus.DueSoon to stringResource(R.string.availability_reserved) @@ -414,7 +417,7 @@ internal fun DetailContent( // buttons below it, as on the web book page. AvailabilityChip(availStatus, availLabel, Modifier.testTag("detail-availability"), contentPadding = PaddingValues(vertical = 4.dp)) - Text(availableLabel, style = MaterialTheme.typography.bodyMedium, color = colors.muted, + if (!book.digitalOnly) Text(availableLabel, style = MaterialTheme.typography.bodyMedium, color = colors.muted, modifier = Modifier.testTag("detail-copies")) Spacer(Modifier.height(16.dp)) @@ -447,7 +450,7 @@ internal fun DetailContent( // this button) is the gate — it paints already-borrowed/reserved days red and pre-selects // the first free day, so a request can only target a date when the title is actually free. // The label adapts: "Request loan" when a copy is free now, "Reserve" when it isn't. - val showLoanButton = canBorrow + val showLoanButton = canBorrow && book.copiesTotal > 0 if (showLoanButton || showWishlist) { Column(verticalArrangement = Arrangement.spacedBy(12.dp)) { if (showLoanButton) { diff --git a/app/src/main/java/com/pinakes/app/ui/screens/detail/PdfReaderDialog.kt b/app/src/main/java/com/pinakes/app/ui/screens/detail/PdfReaderDialog.kt index 6ee9354..e7cd1a2 100644 --- a/app/src/main/java/com/pinakes/app/ui/screens/detail/PdfReaderDialog.kt +++ b/app/src/main/java/com/pinakes/app/ui/screens/detail/PdfReaderDialog.kt @@ -38,18 +38,27 @@ import androidx.compose.ui.window.Dialog import androidx.compose.ui.window.DialogProperties import com.pinakes.app.R import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.NonCancellable +import kotlinx.coroutines.awaitCancellation +import kotlinx.coroutines.suspendCancellableCoroutine import kotlinx.coroutines.withContext import android.content.Context -import com.pinakes.app.data.network.CleartextGuardInterceptor +import com.pinakes.app.data.network.guardedMediaHttpClient import com.pinakes.app.data.network.NetworkEntryPoint import dagger.hilt.android.EntryPointAccessors import okhttp3.OkHttpClient import okhttp3.Request +import okhttp3.Call +import okhttp3.Callback +import okhttp3.Response import java.io.File +import java.io.IOException +import kotlin.coroutines.resumeWithException private sealed interface PdfState { data object Loading : PdfState - data class Ready(val pageCount: Int, val renderer: PdfRenderer, val pfd: ParcelFileDescriptor) : PdfState + data class Ready(val document: CachedPdfDocument) : PdfState data object Error : PdfState } @@ -68,24 +77,15 @@ fun PdfReaderDialog( var state by remember { mutableStateOf(PdfState.Loading) } LaunchedEffect(pdfUrl) { - state = withContext(Dispatchers.IO) { - runCatching { - val file = downloadToCache(pdfUrl, context.cacheDir, guardedHttpClient(context)) - val pfd = ParcelFileDescriptor.open(file, ParcelFileDescriptor.MODE_READ_ONLY) - val renderer = PdfRenderer(pfd) - PdfState.Ready(renderer.pageCount, renderer, pfd) as PdfState - }.getOrElse { PdfState.Error } - } - } - - // Hold the resources to release on dispose. - val current = state - androidx.compose.runtime.DisposableEffect(current) { - onDispose { - if (current is PdfState.Ready) { - runCatching { current.renderer.close() } - runCatching { current.pfd.close() } + state = PdfState.Loading + try { + holdPdfDocument(pdfUrl, context.cacheDir, guardedHttpClient(context)) { + state = PdfState.Ready(it) } + } catch (cancelled: CancellationException) { + throw cancelled + } catch (_: Exception) { + state = PdfState.Error } } @@ -121,8 +121,8 @@ fun PdfReaderDialog( .background(MaterialTheme.colorScheme.surface), contentPadding = PaddingValues(vertical = 8.dp), ) { - items((0 until s.pageCount).toList()) { index -> - PdfPage(renderer = s.renderer, index = index, pageCount = s.pageCount) + items((0 until s.document.pageCount).toList()) { index -> + PdfPage(document = s.document, index = index) } } } @@ -145,25 +145,14 @@ fun PdfReaderDialog( } @Composable -private fun PdfPage(renderer: PdfRenderer, index: Int, pageCount: Int) { +private fun PdfPage(document: CachedPdfDocument, index: Int) { val context = LocalContext.current - var bitmap by remember(index) { mutableStateOf(null) } + var bitmap by remember(document, index) { mutableStateOf(null) } - LaunchedEffect(index) { + LaunchedEffect(document, index) { bitmap = withContext(Dispatchers.IO) { runCatching { - // PdfRenderer is single-threaded: serialize page access. - synchronized(renderer) { - val page = renderer.openPage(index) - val targetWidth = context.resources.displayMetrics.widthPixels - val scale = targetWidth.toFloat() / page.width.toFloat() - val targetHeight = (page.height * scale).toInt().coerceAtLeast(1) - val bmp = Bitmap.createBitmap(targetWidth, targetHeight, Bitmap.Config.ARGB_8888) - bmp.eraseColor(AndroidColor.WHITE) - page.render(bmp, null, null, PdfRenderer.Page.RENDER_MODE_FOR_DISPLAY) - page.close() - bmp - } + document.renderPage(index, context.resources.displayMetrics.widthPixels) }.getOrNull() } } @@ -178,7 +167,7 @@ private fun PdfPage(renderer: PdfRenderer, index: Int, pageCount: Int) { if (bmp != null) { Image( bitmap = bmp.asImageBitmap(), - contentDescription = stringResource(R.string.ebook_page_of, index + 1, pageCount), + contentDescription = stringResource(R.string.ebook_page_of, index + 1, document.pageCount), contentScale = ContentScale.FillWidth, modifier = Modifier.fillMaxWidth(), ) @@ -198,22 +187,105 @@ private fun guardedHttpClient(context: Context): OkHttpClient { val session = EntryPointAccessors .fromApplication(context.applicationContext, NetworkEntryPoint::class.java) .sessionStore() - return OkHttpClient.Builder() - .addInterceptor(CleartextGuardInterceptor { session.allowInsecureHttp }) - .build() + return guardedMediaHttpClient { session.allowInsecureHttp } } -/** Streams [url] into a temp file under [cacheDir] and returns it. Throws on a non-2xx response. */ -private fun downloadToCache(url: String, cacheDir: File, client: OkHttpClient): File { - val out = File.createTempFile("pinakes-ebook-", ".pdf", cacheDir) +/** Owns the renderer and file until the dialog's effect is cancelled, including during setup. */ +internal suspend fun holdPdfDocument(url: String, cacheDir: File, client: OkHttpClient, onReady: (CachedPdfDocument) -> Unit): Nothing { + var document: CachedPdfDocument? = null + try { + withContext(Dispatchers.IO) { + document = CachedPdfDocument.open(downloadToCache(url, cacheDir, client)) + } + onReady(checkNotNull(document)) + awaitCancellation() + } finally { + withContext(NonCancellable + Dispatchers.IO) { document?.close() } + } +} + +/** Page rendering and close share one lock: dismissal cannot close an active page. */ +internal class CachedPdfDocument private constructor( + private val file: File, + private val descriptor: ParcelFileDescriptor, + private val renderer: PdfRenderer, +) : AutoCloseable { + val pageCount: Int = renderer.pageCount + private var closed = false + + @Synchronized fun renderPage(index: Int, targetWidth: Int): Bitmap { + check(!closed) { "PDF is closed" } + return renderer.openPage(index).use { page -> + val scale = targetWidth.toFloat() / page.width.toFloat() + val bitmap = Bitmap.createBitmap(targetWidth, (page.height * scale).toInt().coerceAtLeast(1), Bitmap.Config.ARGB_8888) + try { + bitmap.eraseColor(AndroidColor.WHITE) + page.render(bitmap, null, null, PdfRenderer.Page.RENDER_MODE_FOR_DISPLAY) + bitmap + } catch (failure: Throwable) { + bitmap.recycle() + throw failure + } + } + } + + @Synchronized override fun close() { + if (closed) return + closed = true + try { renderer.close() } finally { + try { descriptor.close() } finally { file.delete() } + } + } + + companion object { + fun open(file: File): CachedPdfDocument { + var descriptor: ParcelFileDescriptor? = null + try { + descriptor = ParcelFileDescriptor.open(file, ParcelFileDescriptor.MODE_READ_ONLY) + return CachedPdfDocument(file, descriptor, PdfRenderer(descriptor)) + } catch (failure: Throwable) { + try { descriptor?.close() } finally { file.delete() } + throw failure + } + } + } +} + +/** Cancel the HTTP call and remove partial files; undelivered results are removed too. */ +@OptIn(kotlinx.coroutines.ExperimentalCoroutinesApi::class) +internal suspend fun downloadToCache(url: String, cacheDir: File, client: OkHttpClient): File = suspendCancellableCoroutine { continuation -> val request = Request.Builder().url(url).build() - client.newCall(request).execute().use { response -> - if (!response.isSuccessful) { + val call = client.newCall(request) + val out = File.createTempFile("pinakes-ebook-", ".pdf", cacheDir) + continuation.invokeOnCancellation { call.cancel(); out.delete() } + call.enqueue(object : Callback { + override fun onFailure(call: Call, e: IOException) { out.delete() - error("HTTP ${response.code}") + if (continuation.isActive) continuation.resumeWithException(e) } - val body = response.body ?: run { out.delete(); error("Empty body") } - out.outputStream().use { sink -> body.byteStream().copyTo(sink) } - } - return out + + override fun onResponse(call: Call, response: Response) { + try { + response.use { + check(response.isSuccessful) { "HTTP ${response.code}" } + val body = response.body ?: error("Empty body") + out.outputStream().use { sink -> + body.byteStream().use { source -> + val buffer = ByteArray(DEFAULT_BUFFER_SIZE) + while (true) { + if (!continuation.isActive) throw CancellationException() + val count = source.read(buffer) + if (count < 0) break + sink.write(buffer, 0, count) + } + } + } + } + continuation.resume(out, onCancellation = { out.delete() }) + } catch (failure: Throwable) { + out.delete() + if (continuation.isActive) continuation.resumeWithException(failure) + } + } + }) } diff --git a/app/src/test/java/com/pinakes/app/AudioTransportTest.kt b/app/src/test/java/com/pinakes/app/AudioTransportTest.kt new file mode 100644 index 0000000..d1cb123 --- /dev/null +++ b/app/src/test/java/com/pinakes/app/AudioTransportTest.kt @@ -0,0 +1,82 @@ +@file:androidx.annotation.OptIn(androidx.media3.common.util.UnstableApi::class) + +package com.pinakes.app + +import android.app.Application +import android.net.Uri +import androidx.media3.datasource.DataSpec +import androidx.test.core.app.ApplicationProvider +import com.pinakes.app.data.network.audioDataSourceFactory +import com.pinakes.app.data.network.guardedMediaHttpClient +import okhttp3.Dns +import okhttp3.Request +import okhttp3.mockwebserver.MockResponse +import okhttp3.mockwebserver.MockWebServer +import okhttp3.tls.HandshakeCertificates +import okhttp3.tls.HeldCertificate +import org.junit.Assert.* +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import java.io.IOException +import java.net.InetAddress + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [34], application = Application::class) +class AudioTransportTest { + private val loopbackDns = object : Dns { + override fun lookup(hostname: String): List = listOf(InetAddress.getByName("127.0.0.1")) + } + + @Test fun media3RejectsPublicHttpBeforeConnecting() { + val source = audioDataSourceFactory(ApplicationProvider.getApplicationContext(), { false }).createDataSource() + try { + val failure = runCatching { source.open(DataSpec.Builder().setUri(Uri.parse("http://library.invalid/audio.mp3")).build()) }.exceptionOrNull() + assertNotNull(failure) + assertTrue(generateSequence(failure) { it.cause }.any { it.message.orEmpty().contains("Cleartext HTTP blocked") }) + } finally { source.close() } + } + + @Test fun httpOptInAllowsMediaRequestsWithoutAnApiBearerToken() { + MockWebServer().use { server -> + server.enqueue(MockResponse().setBody("audio")) + val client = guardedMediaHttpClient { true }.newBuilder().dns(loopbackDns).build() + val url = server.url("/audio.mp3").newBuilder().host("library.invalid").build() + client.newCall(Request.Builder().url(url).build()).execute().use { assertEquals(200, it.code) } + assertNull(server.takeRequest().getHeader("Authorization")) + } + } + + @Test fun media3CanReadLoopbackHttpWithoutOptIn() { + MockWebServer().use { server -> + server.enqueue(MockResponse().setBody("audio")) + val source = audioDataSourceFactory(ApplicationProvider.getApplicationContext(), { false }).createDataSource() + try { + assertEquals(5L, source.open(DataSpec.Builder().setUri(server.url("/audio.mp3").toString()).build())) + val buffer = ByteArray(5) + assertEquals(5, source.read(buffer, 0, 5)) + assertEquals("audio", buffer.decodeToString()) + } finally { source.close() } + } + } + + @Test fun httpsRedirectCannotDowngradeToPublicHttpWithoutOptIn() { + val cert = HeldCertificate.Builder().commonName("localhost").addSubjectAlternativeName("localhost").build() + val serverTls = HandshakeCertificates.Builder().heldCertificate(cert).build() + val clientTls = HandshakeCertificates.Builder().addTrustedCertificate(cert.certificate).build() + MockWebServer().use { secure -> MockWebServer().use { plain -> + secure.useHttps(serverTls.sslSocketFactory(), false) + val target = plain.url("/audio.mp3").newBuilder().host("library.invalid").build() + plain.enqueue(MockResponse().setBody("must not be fetched")) + secure.enqueue(MockResponse().setResponseCode(302).setHeader("Location", target)) + val client = guardedMediaHttpClient { false }.newBuilder().dns(loopbackDns) + .sslSocketFactory(clientTls.sslSocketFactory(), clientTls.trustManager).build() + val failure = runCatching { client.newCall(Request.Builder().url(secure.url("/redirect")).build()).execute().close() }.exceptionOrNull() + assertTrue(failure is IOException) + assertTrue(failure!!.message.orEmpty().contains("Cleartext HTTP blocked")) + assertEquals(1, secure.requestCount) + assertEquals(0, plain.requestCount) + } } + } +} diff --git a/app/src/test/java/com/pinakes/app/DigitalAvailabilityTest.kt b/app/src/test/java/com/pinakes/app/DigitalAvailabilityTest.kt new file mode 100644 index 0000000..8327265 --- /dev/null +++ b/app/src/test/java/com/pinakes/app/DigitalAvailabilityTest.kt @@ -0,0 +1,84 @@ +package com.pinakes.app + +import android.app.Application +import android.content.ComponentName +import androidx.activity.ComponentActivity +import androidx.compose.ui.test.* +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.test.core.app.ApplicationProvider +import com.pinakes.app.data.model.* +import com.pinakes.app.ui.screens.detail.DetailContent +import com.pinakes.app.ui.screens.collections.WantedContributorCredits +import com.pinakes.app.ui.theme.PinakesTheme +import org.junit.Assert.assertEquals +import org.junit.Rule +import org.junit.Test +import org.junit.rules.RuleChain +import org.junit.rules.TestWatcher +import org.junit.runner.Description +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.Shadows.shadowOf +import org.robolectric.annotation.Config +import org.robolectric.annotation.GraphicsMode +import kotlinx.serialization.json.Json + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [34], application = Application::class, qualifiers = "w411dp-h891dp-xxhdpi") +@GraphicsMode(GraphicsMode.Mode.NATIVE) +class DigitalAvailabilityTest { + private val compose = createComposeRule() + @get:Rule val rules: RuleChain = RuleChain.outerRule(object : TestWatcher() { + override fun starting(description: Description) { + val app = ApplicationProvider.getApplicationContext() + shadowOf(app.packageManager).addActivityIfNotPresent(ComponentName(app.packageName, ComponentActivity::class.java.name)) + } + }).around(compose) + + private val pdf = DigitalAttachment("https://library.example/book.pdf") + private fun show(book: BookDetail, onReserve: () -> Unit = {}) { + compose.setContent { PinakesTheme { + DetailContent(book, wishlisted = false, reserveBusy = false, canBorrow = true, + showWishlist = false, showReviews = false, onReserve = onReserve, + onToggleWishlist = {}, onShowMessage = {}) + } } + } + + @Test fun digitalOnlyBookShowsDigitalAccessInsteadOfPhysicalReservation() { + show(BookDetail(title = "Digital book", availability = Availability(state = "unavailable"), digitalAttachments = listOf(pdf))) + compose.onNodeWithTag("detail-loan").assertDoesNotExist() + compose.onNodeWithTag("detail-copies").assertDoesNotExist() + compose.onNodeWithText("Digital edition").performScrollTo().assertIsDisplayed() + compose.onNodeWithText("Read").performScrollTo().assertHasClickAction() + } + + @Test fun occupiedPhysicalCopiesRemainReservableWhenDigitalContentExists() { + var clicks = 0 + show(BookDetail(title = "Physical and digital", availability = Availability(copiesTotal = 2, state = "on_loan"), + digitalAttachments = listOf(pdf))) { clicks++ } + compose.onNodeWithTag("detail-loan").performScrollTo().performClick() + assertEquals(1, clicks) + compose.onNodeWithText("Digital edition").assertDoesNotExist() + } + + @Test fun zeroCopiesWithoutDigitalContentDoesNotOfferAReservation() { + show(BookDetail(title = "No holdings", availability = Availability(state = "unavailable"))) + compose.onNodeWithTag("detail-loan").assertDoesNotExist() + compose.onNodeWithText("Unavailable").performScrollTo().assertIsDisplayed() + } + + @Test fun oldServerSingleEbookStillCountsAsDigitalAccess() { + val old = BookDetail(hasEbook = true, ebookUrl = "https://library.example/book.pdf") + org.junit.Assert.assertTrue(old.digitalOnly) + org.junit.Assert.assertFalse(old.copy(ebookUrl = "javascript:bad").digitalOnly) + } + + @Test fun wantedCreditsKeepTranslatorSeparateAndSupportOlderServers() { + val book = Json.decodeFromString("""{"id":133,"title":"Wanted","author":"Tomás Ibáñez","contributors":[{"id":1,"name":"Tomás Ibáñez","role":"principale"},{"id":2,"name":"Alice Zaffaroni","role":"traduttore"}]}""") + compose.setContent { PinakesTheme { WantedContributorCredits(book.contributors) } } + compose.onNodeWithText("Translator").assertIsDisplayed() + compose.onNodeWithText("Alice Zaffaroni").assertIsDisplayed() + compose.onNodeWithText("Tomás Ibáñez").assertDoesNotExist() + assertEquals(emptyList(), Json.decodeFromString("""{"id":1,"title":"Old server"}""").contributors) + } +} diff --git a/app/src/test/java/com/pinakes/app/PdfCleanupTest.kt b/app/src/test/java/com/pinakes/app/PdfCleanupTest.kt new file mode 100644 index 0000000..fc50b22 --- /dev/null +++ b/app/src/test/java/com/pinakes/app/PdfCleanupTest.kt @@ -0,0 +1,62 @@ +package com.pinakes.app + +import android.app.Application +import com.pinakes.app.ui.screens.detail.downloadToCache +import kotlinx.coroutines.* +import okhttp3.OkHttpClient +import okhttp3.mockwebserver.MockResponse +import okhttp3.mockwebserver.MockWebServer +import okhttp3.mockwebserver.Dispatcher +import okhttp3.mockwebserver.RecordedRequest +import java.util.concurrent.CountDownLatch +import org.junit.Assert.* +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import org.robolectric.annotation.GraphicsMode +import java.io.IOException +import java.nio.file.Files +import java.util.concurrent.TimeUnit + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [34], application = Application::class) +@GraphicsMode(GraphicsMode.Mode.NATIVE) +class PdfCleanupTest { + @Test fun transportFailureRemovesThePartialFile() = runBlocking { + val dir = Files.createTempDirectory("pdf-failure-").toFile() + try { + val client = OkHttpClient.Builder().addInterceptor { throw IOException("disconnected") }.build() + assertTrue(runCatching { downloadToCache("https://library.invalid/book.pdf", dir, client) }.exceptionOrNull() is IOException) + assertEquals(0, dir.listFiles()!!.size) + } finally { dir.deleteRecursively() } + } + + @Test fun httpFailureRemovesTheFile() = runBlocking { + val dir = Files.createTempDirectory("pdf-http-").toFile() + try { MockWebServer().use { server -> + server.enqueue(MockResponse().setResponseCode(404)) + assertTrue(runCatching { downloadToCache(server.url("/missing.pdf").toString(), dir, OkHttpClient()) }.isFailure) + assertEquals(0, dir.listFiles()!!.size) + } } finally { dir.deleteRecursively() } + } + + @Test fun cancellingDuringDownloadRemovesThePartialFile() = runBlocking { + val dir = Files.createTempDirectory("pdf-cancel-").toFile() + try { MockWebServer().use { server -> + val release = CountDownLatch(1) + server.dispatcher = object : Dispatcher() { + override fun dispatch(request: RecordedRequest): MockResponse { + release.await(5, TimeUnit.SECONDS) + return MockResponse().setBody("download") + } + } + val loading = async(Dispatchers.IO) { downloadToCache(server.url("/book.pdf").toString(), dir, OkHttpClient()) } + try { + assertNotNull(withContext(Dispatchers.IO) { server.takeRequest(5, TimeUnit.SECONDS) }) + loading.cancelAndJoin() + assertEquals(0, dir.listFiles()!!.size) + } finally { release.countDown() } + } } finally { dir.deleteRecursively() } + } +} diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 071b614..5858ad5 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -51,6 +51,9 @@ androidx-datastore-preferences = { group = "androidx.datastore", name = "datasto androidx-appcompat = { group = "androidx.appcompat", name = "appcompat", version.ref = "appcompat" } androidx-media3-exoplayer = { group = "androidx.media3", name = "media3-exoplayer", version.ref = "media3" } androidx-media3-ui = { group = "androidx.media3", name = "media3-ui", version.ref = "media3" } +androidx-media3-datasource-okhttp = { group = "androidx.media3", name = "media3-datasource-okhttp", version.ref = "media3" } +okhttp-mockwebserver = { group = "com.squareup.okhttp3", name = "mockwebserver", version.ref = "okhttp" } +okhttp-tls = { group = "com.squareup.okhttp3", name = "okhttp-tls", version.ref = "okhttp" } androidx-room-runtime = { group = "androidx.room", name = "room-runtime", version.ref = "room" } androidx-room-ktx = { group = "androidx.room", name = "room-ktx", version.ref = "room" } androidx-room-compiler = { group = "androidx.room", name = "room-compiler", version.ref = "room" } diff --git a/i18n/de.json b/i18n/de.json index 2274438..1ccbd0c 100644 --- a/i18n/de.json +++ b/i18n/de.json @@ -620,5 +620,12 @@ "archives_dimensions": "Abmessungen", "archives_color": "Farbe", "archives_predominant_dates": "Überwiegende Daten", - "archives_date_gaps": "Zeitliche Lücken" + "archives_date_gaps": "Zeitliche Lücken", + "credit_author": "Autor", + "credit_coauthor": "Mitautor", + "credit_translator": "Übersetzer", + "credit_illustrator": "Illustrator", + "credit_editor": "Herausgeber", + "credit_colorist": "Kolorist", + "credit_contributor": "Mitwirkender" } diff --git a/i18n/en.json b/i18n/en.json index 1cee2b8..bf8264c 100644 --- a/i18n/en.json +++ b/i18n/en.json @@ -620,5 +620,12 @@ "archives_dimensions": "Dimensions", "archives_color": "Colour", "archives_predominant_dates": "Predominant dates", - "archives_date_gaps": "Date gaps" + "archives_date_gaps": "Date gaps", + "credit_author": "Author", + "credit_coauthor": "Co-author", + "credit_translator": "Translator", + "credit_illustrator": "Illustrator", + "credit_editor": "Editor", + "credit_colorist": "Colorist", + "credit_contributor": "Contributor" } diff --git a/i18n/fr.json b/i18n/fr.json index 92543e8..ab41aed 100644 --- a/i18n/fr.json +++ b/i18n/fr.json @@ -620,5 +620,12 @@ "archives_dimensions": "Dimensions", "archives_color": "Couleur", "archives_predominant_dates": "Dates prédominantes", - "archives_date_gaps": "Lacunes chronologiques" + "archives_date_gaps": "Lacunes chronologiques", + "credit_author": "Auteur", + "credit_coauthor": "Coauteur", + "credit_translator": "Traducteur", + "credit_illustrator": "Illustrateur", + "credit_editor": "Éditeur scientifique", + "credit_colorist": "Coloriste", + "credit_contributor": "Contributeur" } diff --git a/i18n/it.json b/i18n/it.json index e901b7c..484a345 100644 --- a/i18n/it.json +++ b/i18n/it.json @@ -620,5 +620,12 @@ "archives_dimensions": "Dimensioni", "archives_color": "Colore", "archives_predominant_dates": "Date prevalenti", - "archives_date_gaps": "Lacune cronologiche" + "archives_date_gaps": "Lacune cronologiche", + "credit_author": "Autore", + "credit_coauthor": "Coautore", + "credit_translator": "Traduttore", + "credit_illustrator": "Illustratore", + "credit_editor": "Curatore", + "credit_colorist": "Colorista", + "credit_contributor": "Contributore" } From 8fe5e521985205a4059800ca992e66a279a005b5 Mon Sep 17 00:00:00 2001 From: fabiodalez-dev Date: Mon, 12 Oct 2026 15:17:47 +0200 Subject: [PATCH 2/2] Handle author aliases and exclude supplements from digital editions --- .../java/com/pinakes/app/data/model/Models.kt | 2 +- .../pinakes/app/ui/common/ContributorRole.kt | 2 ++ .../screens/collections/CollectionsScreens.kt | 2 +- .../app/ui/screens/detail/BookDetailScreen.kt | 3 ++- .../pinakes/app/DigitalAvailabilityTest.kt | 27 ++++++++++++++++++- 5 files changed, 32 insertions(+), 4 deletions(-) diff --git a/app/src/main/java/com/pinakes/app/data/model/Models.kt b/app/src/main/java/com/pinakes/app/data/model/Models.kt index fb99cd8..3cdef28 100644 --- a/app/src/main/java/com/pinakes/app/data/model/Models.kt +++ b/app/src/main/java/com/pinakes/app/data/model/Models.kt @@ -276,7 +276,7 @@ data class BookDetail( val copiesAvailable: Int get() = availability.copiesAvailable val loanableNow: Boolean get() = availability.loanableNow val available: Boolean get() = availability.loanableNow || availability.copiesAvailable > 0 - val digitalOnly: Boolean get() = copiesTotal == 0 && digitalFiles.isNotEmpty() + val digitalOnly: Boolean get() = copiesTotal == 0 && digitalFiles.any { it.kind == "ebook" || it.kind == "audio" } /** Old servers still advertise one audio and one ebook; list-aware servers own the list. */ val digitalFiles: List get() = digitalAttachments.ifEmpty { diff --git a/app/src/main/java/com/pinakes/app/ui/common/ContributorRole.kt b/app/src/main/java/com/pinakes/app/ui/common/ContributorRole.kt index 3acf539..e89ca26 100644 --- a/app/src/main/java/com/pinakes/app/ui/common/ContributorRole.kt +++ b/app/src/main/java/com/pinakes/app/ui/common/ContributorRole.kt @@ -4,6 +4,8 @@ import androidx.compose.runtime.Composable import androidx.compose.ui.res.stringResource import com.pinakes.app.R +internal fun isPrincipalAuthorRole(role: String?): Boolean = role in setOf("principale", "autore", "author") + /** Contributor entities share the author registry, but keep their bibliographic role (#237). */ @Composable fun contributorRoleLabel(role: String?): String = when (role) { diff --git a/app/src/main/java/com/pinakes/app/ui/screens/collections/CollectionsScreens.kt b/app/src/main/java/com/pinakes/app/ui/screens/collections/CollectionsScreens.kt index 5c0974f..45c6b2f 100644 --- a/app/src/main/java/com/pinakes/app/ui/screens/collections/CollectionsScreens.kt +++ b/app/src/main/java/com/pinakes/app/ui/screens/collections/CollectionsScreens.kt @@ -75,7 +75,7 @@ fun WantedBookScreen(onNavigateUp: () -> Unit, onDonate: (Int) -> Unit, vm: Want @Composable internal fun WantedContributorCredits(contributors: List) { - contributors.filter { it.role !in listOf("principale", "co-autore") }.forEach { credit -> + contributors.filterNot { com.pinakes.app.ui.common.isPrincipalAuthorRole(it.role) || it.role == "co-autore" }.forEach { credit -> Metadata(com.pinakes.app.ui.common.contributorRoleLabel(credit.role), credit.name) } } diff --git a/app/src/main/java/com/pinakes/app/ui/screens/detail/BookDetailScreen.kt b/app/src/main/java/com/pinakes/app/ui/screens/detail/BookDetailScreen.kt index 964ca50..5be2dab 100644 --- a/app/src/main/java/com/pinakes/app/ui/screens/detail/BookDetailScreen.kt +++ b/app/src/main/java/com/pinakes/app/ui/screens/detail/BookDetailScreen.kt @@ -382,7 +382,8 @@ internal fun DetailContent( Text(initials, fontSize = 11.sp, fontWeight = FontWeight.Bold, color = colors.accentStrong) } Spacer(Modifier.width(8.dp)) - val roleLabel = author.role?.takeIf(String::isNotBlank)?.let { com.pinakes.app.ui.common.contributorRoleLabel(it) } + val roleLabel = author.role?.takeIf { it.isNotBlank() && !com.pinakes.app.ui.common.isPrincipalAuthorRole(it) } + ?.let { com.pinakes.app.ui.common.contributorRoleLabel(it) } Text(author.name + (roleLabel?.let { " · $it" } ?: ""), style = MaterialTheme.typography.bodyMedium) } diff --git a/app/src/test/java/com/pinakes/app/DigitalAvailabilityTest.kt b/app/src/test/java/com/pinakes/app/DigitalAvailabilityTest.kt index 8327265..c00666c 100644 --- a/app/src/test/java/com/pinakes/app/DigitalAvailabilityTest.kt +++ b/app/src/test/java/com/pinakes/app/DigitalAvailabilityTest.kt @@ -67,6 +67,28 @@ class DigitalAvailabilityTest { compose.onNodeWithText("Unavailable").performScrollTo().assertIsDisplayed() } + @Test fun supplementaryPdfDoesNotAdvertiseACompleteDigitalEdition() { + val book = BookDetail(title = "Errata only", availability = Availability(state = "unavailable"), + digitalAttachments = listOf(pdf.copy(kind = "supplement"))) + show(book) + compose.onNodeWithText("Digital edition").assertDoesNotExist() + compose.onNodeWithText("Unavailable").performScrollTo().assertIsDisplayed() + compose.onNodeWithTag("detail-loan").assertDoesNotExist() + org.junit.Assert.assertTrue(book.copy(digitalAttachments = book.digitalAttachments + + pdf.copy(url = "https://library.example/full-book.pdf")).digitalOnly) + } + + @Test fun principalAuthorAliasesDoNotAddARedundantAuthorSuffix() { + show(BookDetail(title = "Credits", authors = listOf( + AuthorRef(1, "Principal", "principale"), AuthorRef(2, "Italian alias", "autore"), + AuthorRef(3, "English alias", "author"), AuthorRef(4, "Translator name", "traduttore")))) + for (name in listOf("Principal", "Italian alias", "English alias")) { + compose.onNodeWithText(name).performScrollTo().assertIsDisplayed() + compose.onNodeWithText("$name · Author").assertDoesNotExist() + } + compose.onNodeWithText("Translator name · Translator").performScrollTo().assertIsDisplayed() + } + @Test fun oldServerSingleEbookStillCountsAsDigitalAccess() { val old = BookDetail(hasEbook = true, ebookUrl = "https://library.example/book.pdf") org.junit.Assert.assertTrue(old.digitalOnly) @@ -75,10 +97,13 @@ class DigitalAvailabilityTest { @Test fun wantedCreditsKeepTranslatorSeparateAndSupportOlderServers() { val book = Json.decodeFromString("""{"id":133,"title":"Wanted","author":"Tomás Ibáñez","contributors":[{"id":1,"name":"Tomás Ibáñez","role":"principale"},{"id":2,"name":"Alice Zaffaroni","role":"traduttore"}]}""") - compose.setContent { PinakesTheme { WantedContributorCredits(book.contributors) } } + val aliases = listOf(AuthorRef(3, "Italian alias", "autore"), AuthorRef(4, "English alias", "author"), + AuthorRef(5, "Coauthor", "co-autore")) + compose.setContent { PinakesTheme { WantedContributorCredits(book.contributors + aliases) } } compose.onNodeWithText("Translator").assertIsDisplayed() compose.onNodeWithText("Alice Zaffaroni").assertIsDisplayed() compose.onNodeWithText("Tomás Ibáñez").assertDoesNotExist() + for (alias in aliases) compose.onNodeWithText(alias.name).assertDoesNotExist() assertEquals(emptyList(), Json.decodeFromString("""{"id":1,"title":"Old server"}""").contributors) } }