Skip to content
Merged
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
4 changes: 4 additions & 0 deletions app/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand All @@ -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)
Expand All @@ -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)
Expand Down
67 changes: 67 additions & 0 deletions app/src/androidTest/java/com/pinakes/app/PdfReaderLifecycleTest.kt
Original file line number Diff line number Diff line change
@@ -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<Unit>()
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() }
}
}
4 changes: 3 additions & 1 deletion app/src/androidTest/java/com/pinakes/app/RestylingUiTest.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<AuthorRef> = emptyList(),
)

@Serializable
Expand Down
1 change: 1 addition & 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 @@ -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.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<DigitalAttachment> get() = digitalAttachments.ifEmpty {
Expand Down
19 changes: 19 additions & 0 deletions app/src/main/java/com/pinakes/app/data/network/AudioDataSource.kt
Original file line number Diff line number Diff line change
@@ -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)))
19 changes: 19 additions & 0 deletions app/src/main/java/com/pinakes/app/ui/common/ContributorRole.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
package com.pinakes.app.ui.common

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) {
"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)
}
11 changes: 10 additions & 1 deletion app/src/main/java/com/pinakes/app/ui/components/AudioPlayer.kt
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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()
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -72,6 +73,13 @@ fun WantedBookScreen(onNavigateUp: () -> Unit, onDonate: (Int) -> Unit, vm: Want
}
}

@Composable
internal fun WantedContributorCredits(contributors: List<AuthorRef>) {
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)
}
}

@Composable
fun ArchivesScreen(onNavigateUp: () -> Unit, onOpenArchive: (Int) -> Unit, vm: ArchivesViewModel = hiltViewModel()) {
val state by vm.state.collectAsStateWithLifecycle()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -382,7 +382,9 @@ 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 { 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)
}
}
Expand All @@ -402,7 +404,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)
Expand All @@ -414,7 +418,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))

Expand Down Expand Up @@ -447,7 +451,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) {
Expand Down
Loading
Loading