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
71 changes: 71 additions & 0 deletions .github/workflows/play-bundle.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
name: Play release bundle
on:
push:
branches: [main]
paths: ['app/**', 'i18n/**', 'gradle/**', '*.gradle.kts', 'gradle.properties', '.github/workflows/play-bundle.yml']
workflow_dispatch:
permissions:
contents: read
concurrency:
group: play-bundle
cancel-in-progress: false
jobs:
bundle:
if: github.ref == 'refs/heads/main'
runs-on: ubuntu-latest
environment: play-internal
timeout-minutes: 30
steps:
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262
with:
persist-credentials: false
- uses: actions/setup-java@cf277c60eb25467037889841efdb72551f06f6c3
with:
distribution: temurin
java-version: '21'
- name: Release tests and lint
run: ./gradlew testReleaseUnitTest lintRelease --no-daemon --no-configuration-cache
- name: Sign release bundle
env:
KEY_BASE64: ${{ secrets.PINAKES_KEYSTORE_BASE64 }}
PINAKES_KEYSTORE_PASSWORD: ${{ secrets.PINAKES_KEYSTORE_PASSWORD }}
PINAKES_KEY_ALIAS: ${{ secrets.PINAKES_KEY_ALIAS }}
PINAKES_KEY_PASSWORD: ${{ secrets.PINAKES_KEY_PASSWORD }}
run: |
set -euo pipefail
umask 077
test -n "$KEY_BASE64"
test -n "$PINAKES_KEYSTORE_PASSWORD"
test -n "$PINAKES_KEY_ALIAS"
test -n "$PINAKES_KEY_PASSWORD"
export PINAKES_KEYSTORE="$RUNNER_TEMP/pinakes-upload.jks"
trap 'rm -f "$PINAKES_KEYSTORE"' EXIT
printf '%s' "$KEY_BASE64" | base64 --decode > "$PINAKES_KEYSTORE"
./gradlew bundleRelease --no-daemon --no-configuration-cache
# `jarsigner -verify` exits 0 on an UNSIGNED artifact — it just prints
# "jar is unsigned". Since the build deliberately falls back to an
# unsigned bundle when the credentials do not reach Gradle, an exit
# code alone would let an unsigned bundle pass as verified. Require
# the positive statement instead.
verify_out="$(jarsigner -verify app/build/outputs/bundle/release/app-release.aab)"
printf '%s\n' "$verify_out"
case "$verify_out" in
*"jar verified"*) ;;
*) echo "::error::release bundle is not signed"; exit 1 ;;
esac
sha256sum app/build/outputs/bundle/release/app-release.aab > app/build/outputs/bundle/release/SHA256SUMS.txt
- uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02
with:
name: pinakes-play-${{ github.sha }}
path: |
app/build/outputs/bundle/release/*.aab
app/build/outputs/bundle/release/SHA256SUMS.txt
app/build/outputs/mapping/release/mapping.txt
if-no-files-found: error
retention-days: 30
- uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02
if: always()
with:
name: play-reports-${{ github.sha }}
path: app/build/reports/
retention-days: 14
57 changes: 40 additions & 17 deletions PRIVACY.md
Original file line number Diff line number Diff line change
@@ -1,10 +1,13 @@
# Privacy Policy — Pinakes Android

_Last updated: 2026-06-19_
_Technical privacy description updated: 2026-09-09. The publisher has confirmed
the controller and contact below. Retention details and the remaining release
gates still need verification before this becomes the final Play privacy policy._

Pinakes Android is an open-source client app that connects to a **Pinakes
library instance chosen by you**. The app itself has no backend of its own: all
your data lives on the library server you point it at, operated by that library.
library instance chosen by you**. Library services use that instance's server.
The app also integrates Sentry for crash diagnostics and loads media from URLs
supplied by the library; not all requests are limited to the library's domain.

## What the app stores on your device

Expand All @@ -22,15 +25,22 @@ when you log out or disconnect from the instance.

## What the app sends, and to whom

The app communicates **only** with the Pinakes instance URL you configure. It
For library services, the app communicates with the Pinakes instance you configure. It
sends your email and password (over HTTPS) at login, and your bearer token on
subsequent requests, to authenticate and to perform the actions you initiate
(search, loans/reservations, wishlist, profile edits, contact messages). It also
fetches book cover images from URLs returned by that instance.

The app does **not** include third-party analytics, advertising, or tracking
SDKs, and does not transmit your data to the app's authors or any party other
than your chosen library instance.
The app includes **Sentry crash reporting**, configured during app startup.
Default PII attachment is disabled and performance trace sampling is zero.
These settings do not mean no data is transmitted: error events, technical
device/app information and diagnostic context can reach Sentry. Retention and
the precise diagnostic fields must be verified against the publisher's Sentry
configuration before completing the Play Data Safety declaration.

Catalog and HTTP caches are also stored locally. Signing out or switching
instances clears the relevant caches. Signing out revokes the session; it does
**not** delete the account, loans or other records held by the library.

## Network security

Expand All @@ -40,17 +50,30 @@ required to be HTTPS.

## Push notifications (optional)

When enabled by your library and by you, push delivery uses **UnifiedPush**
through a distributor of your choosing. Registration data (a push endpoint and
WebPush keys) is sent to your library instance only.
The app has a notification feed and UnifiedPush-related API support. A complete
distributor integration has not been verified for this release; do not describe
background push delivery as universally available. Any enabled distributor and
its data handling must be included in the deployment's privacy review.

## Data controller

Your data is controlled by the **library operating the Pinakes instance** you
connect to. For requests about access, correction, or deletion of your account
data, contact that library directly. For questions about the app itself, open an
issue on the project repository.
The publisher and controller for this Pinakes Android distribution and its app
diagnostics is **D'Alessandro Fabio Gaetano**, reachable at
**[info@fabiodalez.it](mailto:info@fabiodalez.it)**. This is also the contact for
the reference library at **https://biblioteca.fabiodalez.it**.

When you connect to another independently operated library, that library
operates its own account and circulation services. Consult its privacy policy
and contact it for access, correction or deletion requests relating to those
records. Do not post credentials, loan history or other private data in public
GitHub issues. Never send your password in a privacy request.

The app supports account registration. A compliant in-app account-deletion
request path and an external request URL remain release gates; logout must not
be presented as account deletion. A library may need to retain specific records
under its obligations, which its policy must explain accurately.

> Libraries publishing their own build: replace this section with your
> organisation's contact details and host this policy at a public URL, then link
> that URL in the Google Play Console (App content → Privacy policy).
For the reference library, account and privacy requests may be sent to
info@fabiodalez.it, identifying the library URL and account email. Identity
verification may be necessary before processing a request. This contact does
not yet replace the in-app and public-web deletion paths listed above.
8 changes: 8 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -183,3 +183,11 @@ distributor receiver is a follow-up. See `STATUS.md` for the full breakdown.
## License

Released under the same license as Pinakes: **AGPL-3.0**.

### Standalone articles in Emeroteca

On compatible servers, **Emeroteca → Articles** searches and displays standalone
newspaper and magazine articles, without requiring ownership of their issues.
Publication screens also link to their associated articles. Public PDFs use the
URL supplied by the server. Older servers retain the existing periodicals browser.
See [the article integration notes](docs/emeroteca-standalone-articles.md).
41 changes: 41 additions & 0 deletions app/src/main/java/com/pinakes/app/data/model/PeriodicalsModels.kt
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import kotlinx.serialization.Serializable
@Serializable
data class PeriodicalsHealth(
val status: String = "",
val capabilities: PeriodicalsCapabilities = PeriodicalsCapabilities(),
)

// ---------- Mastheads list ----------
Expand Down Expand Up @@ -148,3 +149,43 @@ data class IssueArticle(
@SerialName("page_end") val pageEnd: Int? = null,
val type: String? = null,
)

/** Missing capability means an older server, whose existing periodicals remain usable. */
@Serializable
data class PeriodicalsCapabilities(
@SerialName("standalone_articles") val standaloneArticles: Boolean = false,
)

/** Standalone contributions use the server's Italian column names, unlike issue indexes. */
@Serializable
data class StandaloneArticle(
val id: Int = 0,
@SerialName("titolo") val title: String = "",
@SerialName("autori") val authors: String? = null,
@SerialName("tipo_contributo") val contributionType: String? = null,
@SerialName("contenitore_tipo") val containerType: String? = null,
@SerialName("contenitore_titolo") val containerTitle: String? = null,
val issn: String? = null,
@SerialName("data_pubblicazione_testo") val publicationDate: String? = null,
@SerialName("anno_pubblicazione") val publicationYear: Int? = null,
val volume: String? = null,
@SerialName("numero") val number: String? = null,
@SerialName("pagine") val pages: String? = null,
val doi: String? = null,
@SerialName("supporto") val medium: String? = null,
val keywords: String? = null,
@SerialName("abstract") val description: String? = null,
@SerialName("testata_id") val mastheadId: Int? = null,
@SerialName("fascicolo_id") val issueId: Int? = null,
@SerialName("has_public_pdf") val hasPublicPdf: Boolean = false,
@SerialName("pdf_url") val pdfUrl: String? = null,
) {
// Date text and page spans are citations, not ISO dates or page counts.
val dateLabel: String? get() = publicationDate?.takeIf { it.isNotBlank() }
?: publicationYear?.toString()
val canOpenPdf: Boolean get() = hasPublicPdf && publicPdfUrl != null
val publicPdfUrl: String? get() = pdfUrl?.takeIf {
val uri = runCatching { java.net.URI(it) }.getOrNull()
uri?.scheme?.lowercase() in listOf("https", "http") && !uri?.host.isNullOrBlank()
}
}
11 changes: 11 additions & 0 deletions app/src/main/java/com/pinakes/app/data/network/PeriodicalsApi.kt
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import com.pinakes.app.data.model.PeriodicalIssue
import com.pinakes.app.data.model.PeriodicalIssueDetail
import com.pinakes.app.data.model.PeriodicalSummary
import com.pinakes.app.data.model.PeriodicalsHealth
import com.pinakes.app.data.model.StandaloneArticle
import retrofit2.http.GET
import retrofit2.http.Path
import retrofit2.http.Query
Expand Down Expand Up @@ -45,4 +46,14 @@ interface PeriodicalsApi {
/** Issue detail + spoglio articles + public PDF url when available. */
@GET("periodicals/issues/{id}")
suspend fun issue(@Path("id") id: Int): Envelope<PeriodicalIssueDetail>

@GET("periodicals/articles")
suspend fun articles(
@Query("q") query: String? = null,
@Query("testata_id") mastheadId: Int? = null,
@Query("cursor") cursor: String? = null,
): Envelope<List<StandaloneArticle>>

@GET("periodicals/articles/{id}")
suspend fun article(@Path("id") id: Int): Envelope<StandaloneArticle>
}
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
package com.pinakes.app.data.repository

import com.pinakes.app.data.model.StandaloneArticle
import com.pinakes.app.data.model.PeriodicalDetail
import com.pinakes.app.data.model.PeriodicalIssue
import com.pinakes.app.data.model.PeriodicalIssueDetail
Expand Down Expand Up @@ -30,7 +31,7 @@ class PeriodicalsRepository(
private val network: NetworkModule,
private val features: FeatureStore,
private val session: SessionStore,
) {
) : StandaloneArticlesSource {

/**
* Probe `GET /periodicals/health`.
Expand Down Expand Up @@ -84,12 +85,31 @@ class PeriodicalsRepository(
* is confirmed gone, flip the feature flag so every entry point hides immediately.
* Returns true when the plugin is really unavailable (vs a single missing resource).
*/
suspend fun confirmGone(): Boolean {
override suspend fun confirmGone(): Boolean {
val instance = session.instanceUrl
val available = probeAvailability()
// Only treat the plugin as gone when the probe actually applied to the still-current
// instance — a stale 404 from a since-switched instance must not drive pluginGone.
val applied = applyAvailability(available, instance)
return applied && available == false
}

override suspend fun standaloneArticlesSupported(): Boolean? {
val instance = session.instanceUrl
val result = apiCall { network.periodicalsApi().health() }
if (instance != session.instanceUrl) return null
return when (result) {
is ApiResult.Success -> result.data.capabilities.standaloneArticles
is ApiResult.Failure -> if (result.httpStatus == 404) false else null
}
}

override suspend fun articles(query: String?, mastheadId: Int?, cursor: String?): ApiResult<StandaloneArticlesPage> =
when (val result = apiCall { network.periodicalsApi().articles(query, mastheadId, cursor) }) {
is ApiResult.Success -> ApiResult.Success(StandaloneArticlesPage(result.data, result.meta?.nextCursor), result.meta)
is ApiResult.Failure -> result
}

override suspend fun article(id: Int): ApiResult<StandaloneArticle> =
apiCall { network.periodicalsApi().article(id) }
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
package com.pinakes.app.data.repository

import com.pinakes.app.data.model.StandaloneArticle
import com.pinakes.app.data.network.ApiResult

data class StandaloneArticlesPage(
val items: List<StandaloneArticle>,
val nextCursor: String? = null,
)

/** Read-only article source, separately injectable for lifecycle/pagination tests. */
interface StandaloneArticlesSource {
suspend fun standaloneArticlesSupported(): Boolean?
suspend fun articles(query: String? = null, mastheadId: Int? = null, cursor: String? = null): ApiResult<StandaloneArticlesPage>
suspend fun article(id: Int): ApiResult<StandaloneArticle>
suspend fun confirmGone(): Boolean
}
15 changes: 15 additions & 0 deletions app/src/main/java/com/pinakes/app/di/StandaloneArticlesModule.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
package com.pinakes.app.di

import com.pinakes.app.data.repository.PeriodicalsRepository
import com.pinakes.app.data.repository.StandaloneArticlesSource
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent

@Module
@InstallIn(SingletonComponent::class)
object StandaloneArticlesModule {
@Provides
fun source(repository: PeriodicalsRepository): StandaloneArticlesSource = repository
}
26 changes: 26 additions & 0 deletions app/src/main/java/com/pinakes/app/ui/navigation/PinakesNavHost.kt
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,8 @@ import com.pinakes.app.ui.screens.onboarding.OnboardingScreen
import com.pinakes.app.ui.screens.periodicals.IssueDetailScreen
import com.pinakes.app.ui.screens.periodicals.IssueListScreen
import com.pinakes.app.ui.screens.periodicals.PeriodicalDetailScreen
import com.pinakes.app.ui.screens.periodicals.StandaloneArticlesScreen
import com.pinakes.app.ui.screens.periodicals.StandaloneArticleScreen
import com.pinakes.app.ui.screens.periodicals.PeriodicalsScreen
import com.pinakes.app.ui.screens.reviews.MyReviewsScreen

Expand Down Expand Up @@ -169,6 +171,28 @@ fun PinakesNavHost(navController: NavHostController = rememberNavController()) {
ClubDetailScreen(onNavigateUp = { navController.popBackStack() })
}

composable(
Routes.STANDALONE_ARTICLES,
arguments = listOf(navArgument(Routes.ARG_PERIODICAL_ID) { type = NavType.IntType; defaultValue = 0 }),
enterTransition = slideIn, popExitTransition = slideOut,
) {
StandaloneArticlesScreen(
onNavigateUp = { navController.popBackStack() },
onOpenArticle = { id -> navController.navigate(Routes.standaloneArticle(id)) },
)
}
composable(
Routes.STANDALONE_ARTICLE,
arguments = listOf(navArgument(Routes.ARG_ARTICLE_ID) { type = NavType.IntType }),
enterTransition = slideIn, popExitTransition = slideOut,
) {
StandaloneArticleScreen(
onNavigateUp = { navController.popBackStack() },
onOpenPeriodical = { id -> navController.navigate(Routes.periodicalDetail(id)) },
onOpenIssue = { id -> navController.navigate(Routes.periodicalIssue(id)) },
)
}

// ---- Periodicals / Emeroteca (optional plugin) ----
composable(
Routes.PERIODICALS,
Expand All @@ -178,6 +202,7 @@ fun PinakesNavHost(navController: NavHostController = rememberNavController()) {
PeriodicalsScreen(
onNavigateUp = { navController.popBackStack() },
onOpenPeriodical = { id -> navController.navigate(Routes.periodicalDetail(id)) },
onOpenArticles = { navController.navigate(Routes.standaloneArticles()) },
)
}

Expand All @@ -188,6 +213,7 @@ fun PinakesNavHost(navController: NavHostController = rememberNavController()) {
popExitTransition = slideOut,
) {
PeriodicalDetailScreen(
onOpenArticles = { id -> navController.navigate(Routes.standaloneArticles(id)) },
onNavigateUp = { navController.popBackStack() },
onOpenYear = { yearId, year ->
navController.navigate(Routes.periodicalYearIssues(yearId, year))
Expand Down
6 changes: 6 additions & 0 deletions app/src/main/java/com/pinakes/app/ui/navigation/Routes.kt
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,12 @@ object Routes {
fun periodicalDetail(id: Int): String = "periodicals/$id"
const val ARG_PERIODICAL_ID = "periodicalId"

const val STANDALONE_ARTICLES = "periodicals/articles?periodicalId={periodicalId}"
fun standaloneArticles(mastheadId: Int = 0): String = "periodicals/articles?periodicalId=$mastheadId"
const val STANDALONE_ARTICLE = "periodicals/articles/{articleId}"
const val ARG_ARTICLE_ID = "articleId"
fun standaloneArticle(id: Int): String = "periodicals/articles/$id"

// The display year rides along as a nav arg so the issues screen can title itself
// ("Year 1998") without re-fetching the masthead detail.
const val PERIODICAL_YEAR_ISSUES = "periodicals/years/{yearId}/{year}"
Expand Down
Loading
Loading