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/src/main/java/com/pinakes/app/data/model/Models.kt
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,10 @@ data class Meta(
@SerialName("total_count") val totalCount: Int? = null,
val https: Boolean? = null,
val warning: String? = null, // "insecure_transport" on /health
// Set by endpoints that cap their result set (periodicals year issues: 400 max) to say
// the list was cut short. Nullable with a null default on purpose: servers older than
// the field simply omit it, and absent must read as "not truncated", never as "true".
val truncated: Boolean? = null,
)

@Serializable
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,8 @@ data class PeriodicalIssueDetail(
val pages: Int? = null,
val status: String = "",
@SerialName("cover_url") val coverUrl: String? = null,
/** Free-text note on inserts bound with this issue ("Supplemento letterario", …). */
val supplements: String? = null,
/** Only present when the digitised PDF is public; null → hide the "Open PDF" action. */
@SerialName("pdf_url") val pdfUrl: String? = null,
val masthead: IssueMasthead? = null,
Expand Down
51 changes: 50 additions & 1 deletion app/src/main/java/com/pinakes/app/data/network/NetworkModule.kt
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,15 @@ package com.pinakes.app.data.network
import com.jakewharton.retrofit2.converter.kotlinx.serialization.asConverterFactory
import com.pinakes.app.BuildConfig
import com.pinakes.app.data.store.SessionStore
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import kotlinx.serialization.json.Json
import okhttp3.Cache
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.OkHttpClient
import okhttp3.logging.HttpLoggingInterceptor
import retrofit2.Retrofit
import java.io.File
import java.util.concurrent.TimeUnit

/**
Expand All @@ -16,8 +20,21 @@ import java.util.concurrent.TimeUnit
* The base URL is per-instance and only known after onboarding, so the Retrofit instance is
* (re)created whenever the instance URL changes. The bearer token is read live from
* [SessionStore] by the [AuthInterceptor], so the same client survives login/logout.
*
* [cacheDir] (the app's cache directory) enables a disk HTTP cache. The server tags its
* cacheable GETs — the whole periodicals surface, for one — with an ETag and
* `Cache-Control: private, max-age=0, must-revalidate`, but without a cache OkHttp has no
* stored validator to send, so `If-None-Match` never goes out and every request pays for a
* full body. With the cache wired in, revalidation becomes transparent: OkHttp attaches the
* stored ETag and a 304 replays the cached body instead of downloading it again.
* `max-age=0` means nothing is ever served without asking the server first, so this saves
* bandwidth without ever serving stale data.
*
* The cache is keyed by URL only. Two accounts, or two instances that share a path, would
* otherwise collide — so it is purged on logout and on instance switch (see
* [clearHttpCache]) and no response body outlives the session that fetched it.
*/
class NetworkModule(private val session: SessionStore) {
class NetworkModule(private val session: SessionStore, cacheDir: File? = null) {

val json: Json = Json {
ignoreUnknownKeys = true
Expand All @@ -26,6 +43,15 @@ class NetworkModule(private val session: SessionStore) {
coerceInputValues = true
}

/**
* Disk cache for conditional GETs, or null when no cache directory was supplied (unit
* tests build the module without an Android context). Constructing a [Cache] only records
* the directory and the size budget — the on-disk journal is opened lazily on first use —
* so this is safe to do off the IO dispatcher.
*/
private val httpCache: Cache? =
cacheDir?.let { Cache(File(it, HTTP_CACHE_DIR), HTTP_CACHE_MAX_BYTES) }

private val okHttpClient: OkHttpClient by lazy {
val builder = OkHttpClient.Builder()
// Also honour the transient onboarding opt-in: during discovery the instance isn't
Expand All @@ -36,6 +62,7 @@ class NetworkModule(private val session: SessionStore) {
.connectTimeout(20, TimeUnit.SECONDS)
.readTimeout(30, TimeUnit.SECONDS)
.writeTimeout(30, TimeUnit.SECONDS)
httpCache?.let { builder.cache(it) }
// Complete incomplete server certificate chains via AIA, like a browser does. Self-hosted
// instances behind QNAP/Synology proxies often serve a chain missing its intermediate, which
// the default Android TLS stack rejects ("Trust anchor for certification path not found")
Expand Down Expand Up @@ -133,7 +160,29 @@ class NetworkModule(private val session: SessionStore) {
cachedPeriodicalsApi = null
}

/**
* Evict every stored response. Called on logout and on instance switch: cache entries are
* keyed by URL alone, so a body fetched for one account or one library must never be
* revalidated — let alone replayed — under the next one.
*
* Runs on the IO dispatcher (evicting walks the on-disk journal) and swallows failures:
* a cache that cannot be purged is a bandwidth problem, and must not be allowed to turn
* signing out into an error the user has to fight.
*/
suspend fun clearHttpCache() {
val cache = httpCache ?: return
withContext(Dispatchers.IO) {
runCatching { cache.evictAll() }
}
}

companion object {
/** Subdirectory of the app cache dir holding the OkHttp response cache. */
private const val HTTP_CACHE_DIR = "http"

/** ~10 MB: the cached surface is JSON, and OkHttp prunes the directory to fit. */
private const val HTTP_CACHE_MAX_BYTES = 10L * 1024 * 1024

/**
* Derive the API base URL from a user-entered instance URL.
* - when no scheme is given, prepends `https://` — or `http://` when the user has
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -173,6 +173,9 @@ class AuthRepository(
val api = network.api()
val result = apiCall { api.logout() }
session.clearToken()
// Drop the cached HTTP responses of the session that just ended: they were fetched
// with a bearer token that no longer exists, and the cache is keyed by URL only.
network.clearHttpCache()
return result
}

Expand All @@ -184,6 +187,9 @@ class AuthRepository(
// instance and must never surface under the next library's name.
catalog.clearCache()
network.invalidate()
// Same reasoning as the Room purge: a URL-keyed HTTP cache entry from the old
// instance could otherwise be revalidated against the next library's server.
network.clearHttpCache()
}

private fun deviceName(): String {
Expand Down
6 changes: 5 additions & 1 deletion app/src/main/java/com/pinakes/app/di/AppModule.kt
Original file line number Diff line number Diff line change
Expand Up @@ -41,8 +41,12 @@ object AppModule {
@Provides @Singleton
fun features(@ApplicationContext context: Context): FeatureStore = FeatureStore(context)

// The app cache dir backs the OkHttp response cache, so the server's ETags actually turn
// into conditional requests instead of full re-downloads. Android reclaims this directory
// under storage pressure, which is exactly the right lifetime for it.
@Provides @Singleton
fun network(session: SessionStore): NetworkModule = NetworkModule(session)
fun network(@ApplicationContext context: Context, session: SessionStore): NetworkModule =
NetworkModule(session, context.cacheDir)

@Provides @Singleton
fun database(@ApplicationContext context: Context): AppDatabase = AppDatabase.get(context)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ import com.pinakes.app.ui.common.DateFormat
import com.pinakes.app.ui.common.UiState
import com.pinakes.app.ui.common.resolvedMessage
import com.pinakes.app.ui.components.AvailabilityChip
import com.pinakes.app.ui.components.EmptyState
import com.pinakes.app.ui.components.ErrorState
import com.pinakes.app.ui.components.LoadingState
import com.pinakes.app.ui.components.PinakesTopBar
Expand Down Expand Up @@ -69,7 +70,13 @@ fun IssueDetailScreen(onNavigateUp: () -> Unit) {
) {
when (val content = state.content) {
is UiState.Loading -> LoadingState(label = stringResource(R.string.periodicals_issue_loading))
is UiState.Error -> ErrorState(message = content.resolvedMessage(), onRetry = vm::refresh)
is UiState.Error ->
// Plugin deactivated server-side: a terminal state, not a retryable
// error — retrying can only 404 again (see periodicalsFailureKind).
if (state.pluginGone) EmptyState(
title = stringResource(R.string.periodicals_gone_title),
subtitle = stringResource(R.string.periodicals_gone_subtitle),
) else ErrorState(message = content.resolvedMessage(), onRetry = vm::refresh)
is UiState.Success -> {
val issue = content.data
LazyColumn(
Expand Down Expand Up @@ -163,6 +170,12 @@ private fun IssueHeader(issue: PeriodicalIssueDetail) {
status = issueStatusBadge(issue.status),
label = stringResource(issueStatusLabelRes(issue.status)),
)
// Free text and often absent — shown only when the server actually has something
// to say, using the same label/value row as the masthead detail.
issue.supplements?.takeIf { it.isNotBlank() }?.let {
Spacer(Modifier.height(Spacing.md))
InfoRow(stringResource(R.string.periodicals_label_supplements), it)
}
}
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,8 @@ import kotlinx.coroutines.launch
data class IssueDetailUiState(
val content: UiState<PeriodicalIssueDetail> = UiState.Loading,
val refreshing: Boolean = false,
/** The plugin was deactivated server-side (confirmed via health re-probe). */
val pluginGone: Boolean = false,
)

@HiltViewModel
Expand All @@ -45,12 +47,26 @@ class IssueDetailViewModel @Inject constructor(
is ApiResult.Success -> _state.update {
it.copy(content = UiState.Success(res.data), refreshing = false)
}
is ApiResult.Failure -> _state.update {
it.copy(
content = if (it.content is UiState.Success) it.content
else UiState.Error(res.message, res.code, R.string.periodicals_issue_error),
refreshing = false,
is ApiResult.Failure -> {
// A 404 here is either a deleted fascicolo or a deactivated plugin:
// health decides which (see periodicalsFailureKind).
val kind = periodicalsFailureKind(
res,
goneConfirmed = isNotFoundFailure(res) && repo.confirmGone(),
)
_state.update {
it.copy(
content = if (it.content is UiState.Success) it.content
else periodicalsErrorState(
failure = res,
kind = kind,
genericRes = R.string.periodicals_issue_error,
notFoundRes = R.string.periodicals_issue_not_found,
),
refreshing = false,
pluginGone = kind == PeriodicalsFailure.Gone,
)
}
}
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,10 @@ import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.Info
import androidx.compose.material.icons.outlined.Newspaper
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Surface
Expand Down Expand Up @@ -68,7 +70,13 @@ fun IssueListScreen(
) {
when (val content = state.content) {
is UiState.Loading -> LoadingState(label = stringResource(R.string.periodicals_issues_loading))
is UiState.Error -> ErrorState(message = content.resolvedMessage(), onRetry = vm::refresh)
is UiState.Error ->
// Plugin deactivated server-side: a terminal state, not a retryable
// error — retrying can only 404 again (see periodicalsFailureKind).
if (state.pluginGone) EmptyState(
title = stringResource(R.string.periodicals_gone_title),
subtitle = stringResource(R.string.periodicals_gone_subtitle),
) else ErrorState(message = content.resolvedMessage(), onRetry = vm::refresh)
is UiState.Success ->
if (content.data.isEmpty()) {
EmptyState(
Expand All @@ -82,6 +90,11 @@ fun IssueListScreen(
contentPadding = PaddingValues(Spacing.lg),
verticalArrangement = Arrangement.spacedBy(Spacing.md),
) {
// Informational, never blocking: the issues below are real and
// browsable, there are simply more of them on the server.
if (state.truncated) {
item { TruncatedNotice(shown = content.data.size) }
}
items(content.data, key = { it.id }) { issue ->
IssueRow(issue = issue, onClick = { onOpenIssue(issue.id) })
}
Expand All @@ -92,6 +105,47 @@ fun IssueListScreen(
}
}

/**
* Partial-list notice for a year the server capped. Styled as an informational banner
* (secondary container, like the book detail's status banners) rather than an error: nothing
* failed and there is nothing to retry.
*/
@Composable
private fun TruncatedNotice(shown: Int) {
Surface(
modifier = Modifier.fillMaxWidth(),
shape = MaterialTheme.shapes.medium,
color = MaterialTheme.colorScheme.secondaryContainer,
contentColor = MaterialTheme.colorScheme.onSecondaryContainer,
) {
Row(
modifier = Modifier.padding(horizontal = Spacing.lg, vertical = Spacing.md),
verticalAlignment = Alignment.CenterVertically,
) {
Icon(
Icons.Outlined.Info,
contentDescription = null,
tint = MaterialTheme.colorScheme.onSecondaryContainer,
modifier = Modifier.size(24.dp),
)
Spacer(Modifier.width(Spacing.md))
Column(Modifier.weight(1f)) {
Text(
stringResource(R.string.periodicals_issues_truncated_title),
style = MaterialTheme.typography.titleSmall,
color = MaterialTheme.colorScheme.onSecondaryContainer,
)
Spacer(Modifier.height(Spacing.xs))
Text(
stringResource(R.string.periodicals_issues_truncated_subtitle, shown),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSecondaryContainer,
)
}
}
}
}

/** "No. 12 · Title" heading, or just the localized fallback when both are absent. */
@Composable
internal fun issueHeading(number: String?, title: String?): String {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,10 @@ import kotlinx.coroutines.launch
data class IssueListUiState(
val content: UiState<List<PeriodicalIssue>> = UiState.Loading,
val refreshing: Boolean = false,
/** The plugin was deactivated server-side (confirmed via health re-probe). */
val pluginGone: Boolean = false,
/** The server capped this year's issues and said so via `meta.truncated`. */
val truncated: Boolean = false,
)

@HiltViewModel
Expand All @@ -46,14 +50,32 @@ class IssueListViewModel @Inject constructor(
viewModelScope.launch {
when (val res = repo.yearIssues(yearId)) {
is ApiResult.Success -> _state.update {
it.copy(content = UiState.Success(res.data), refreshing = false)
}
is ApiResult.Failure -> _state.update {
it.copy(
content = if (it.content is UiState.Success) it.content
else UiState.Error(res.message, res.code, R.string.periodicals_issues_error),
content = UiState.Success(res.data),
refreshing = false,
truncated = isTruncatedList(res.meta),
)
}
is ApiResult.Failure -> {
// A 404 is either a year that no longer exists or a deactivated plugin:
// health decides which (see periodicalsFailureKind).
val kind = periodicalsFailureKind(
res,
goneConfirmed = isNotFoundFailure(res) && repo.confirmGone(),
)
_state.update {
it.copy(
content = if (it.content is UiState.Success) it.content
else periodicalsErrorState(
failure = res,
kind = kind,
genericRes = R.string.periodicals_issues_error,
notFoundRes = R.string.periodicals_issues_not_found,
),
refreshing = false,
pluginGone = kind == PeriodicalsFailure.Gone,
)
}
}
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ import com.pinakes.app.data.model.PeriodicalDetail
import com.pinakes.app.data.model.PeriodicalYear
import com.pinakes.app.ui.common.UiState
import com.pinakes.app.ui.common.resolvedMessage
import com.pinakes.app.ui.components.EmptyState
import com.pinakes.app.ui.components.ErrorState
import com.pinakes.app.ui.components.LoadingState
import com.pinakes.app.ui.components.PinakesTopBar
Expand Down Expand Up @@ -65,7 +66,13 @@ fun PeriodicalDetailScreen(
) {
when (val content = state.content) {
is UiState.Loading -> LoadingState(label = stringResource(R.string.periodicals_detail_loading))
is UiState.Error -> ErrorState(message = content.resolvedMessage(), onRetry = vm::refresh)
is UiState.Error ->
// Plugin deactivated server-side: a terminal state, not a retryable
// error — retrying can only 404 again (see periodicalsFailureKind).
if (state.pluginGone) EmptyState(
title = stringResource(R.string.periodicals_gone_title),
subtitle = stringResource(R.string.periodicals_gone_subtitle),
) else ErrorState(message = content.resolvedMessage(), onRetry = vm::refresh)
is UiState.Success -> {
val detail = content.data
LazyColumn(
Expand Down Expand Up @@ -174,8 +181,9 @@ private fun coverageLabel(start: Int?, end: Int?): String? = when {
else -> null
}

/** Label/value detail row, shared with the issue detail header so both read identically. */
@Composable
private fun InfoRow(label: String, value: String) {
internal fun InfoRow(label: String, value: String) {
Row(Modifier.fillMaxWidth().padding(vertical = Spacing.xxs)) {
Text(
label,
Expand Down
Loading
Loading