The user obtained a restricted Google Books key. Keyless requests 429 for every caller on the internet — all anonymous traffic bills to one shared Google Cloud project whose daily quota is permanently exhausted — so the documented fallback has never once answered. Because MetadataRepository.combine turns "a source failed, none found" into Unavailable, that standing failure meant every Open Library hiccup reached the user as "couldn't be reached". The app has been effectively single-sourced since it was written. Build plumbing reads GOOGLE_BOOKS_API_KEY from local.properties (gitignored), falling back to the environment and then to empty. A blank key is a supported state: a fresh clone still builds a working app that falls back to the keyless endpoint, rather than failing to build. GoogleBooksClient appends the key only when non-blank, building the URL with HttpUrl.Builder in a pure requestUrl() so it is testable without a socket. The key is scrubbed from SourceResult.Failed.reason before that string can reach the scan sheet — it is rendered to the user and is our only diagnostic channel from a real phone, and some okhttp/JDK IOExceptions embed the full request URL in their message. Defensive, not a response to an observed leak. Resolves the RATE_LIMITED decision parked in RetryPolicy's KDoc: a keyed 429 is the short per-user rate limit and gets exactly one retry, honouring Retry-After capped at 2s. A keyless 429 is still the dead daily quota and is still never retried. Verified against the live API, not only offline: both ISBNs that failed on the phone (9781883937386, 9781883937676) plus a control return HTTP 200, in the percent-encoded URL shape HttpUrl actually produces. Both books are in Google Books, so the restored fallback now covers precisely the Open Library TLS-reset failure that broke those scans. 189 unit tests (was 172), 0 failures; Paparazzi unchanged; release APK builds. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01J7WHnTx2Cso4VV245WDAJY
166 lines
5.7 KiB
Kotlin
166 lines
5.7 KiB
Kotlin
import java.util.Properties
|
|
|
|
plugins {
|
|
alias(libs.plugins.android.application)
|
|
// NOTE: no org.jetbrains.kotlin.android plugin — AGP 9's Kotlin support is
|
|
// built in. Only sub-plugins that add compiler extensions are applied here.
|
|
alias(libs.plugins.kotlin.compose)
|
|
alias(libs.plugins.kotlin.serialization)
|
|
alias(libs.plugins.ksp)
|
|
alias(libs.plugins.paparazzi)
|
|
}
|
|
|
|
// Release signing is optional: `keystore.properties` (gitignored, alongside the
|
|
// keystore it points at) only exists on machines that own the release key. Anyone
|
|
// else still gets a working `assembleRelease` — it just comes out debug-signed.
|
|
val keystorePropertiesFile = rootProject.file("keystore.properties")
|
|
val keystoreProperties = Properties().apply {
|
|
if (keystorePropertiesFile.exists()) {
|
|
keystorePropertiesFile.inputStream().use { load(it) }
|
|
}
|
|
}
|
|
val hasReleaseKeystore = keystorePropertiesFile.exists()
|
|
|
|
// Google Books API key. Lives in `local.properties` (gitignored) as
|
|
// GOOGLE_BOOKS_API_KEY=..., or in the environment for CI. Absent is a supported
|
|
// state: the build stays green and GoogleBooksClient falls back to the keyless
|
|
// endpoint, which is what a fresh clone without the key gets.
|
|
// The key is NOT a secret in the usual sense — it ships inside the APK and can be
|
|
// extracted — but it is restricted to the Books API, and it must never be
|
|
// committed. See docs/METADATA-SOURCES.md.
|
|
val localPropertiesFile = rootProject.file("local.properties")
|
|
val localProperties = Properties().apply {
|
|
if (localPropertiesFile.exists()) {
|
|
localPropertiesFile.inputStream().use { load(it) }
|
|
}
|
|
}
|
|
val googleBooksApiKey: String =
|
|
(localProperties["GOOGLE_BOOKS_API_KEY"] as String?)
|
|
?: System.getenv("GOOGLE_BOOKS_API_KEY")
|
|
?: ""
|
|
|
|
android {
|
|
namespace = "org.modg.bookshelf"
|
|
compileSdk = 37
|
|
buildToolsVersion = "37.0.0"
|
|
|
|
defaultConfig {
|
|
applicationId = "org.modg.bookshelf"
|
|
minSdk = 26
|
|
targetSdk = 37
|
|
versionCode = 1
|
|
versionName = "1.0"
|
|
|
|
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
|
|
|
buildConfigField("String", "GOOGLE_BOOKS_API_KEY", "\"$googleBooksApiKey\"")
|
|
}
|
|
|
|
signingConfigs {
|
|
if (hasReleaseKeystore) {
|
|
create("release") {
|
|
storeFile = rootProject.file(keystoreProperties["storeFile"] as String)
|
|
storePassword = keystoreProperties["storePassword"] as String
|
|
keyAlias = keystoreProperties["keyAlias"] as String
|
|
keyPassword = keystoreProperties["keyPassword"] as String
|
|
}
|
|
}
|
|
}
|
|
|
|
buildTypes {
|
|
release {
|
|
isMinifyEnabled = false
|
|
proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro")
|
|
if (hasReleaseKeystore) {
|
|
signingConfig = signingConfigs.getByName("release")
|
|
}
|
|
}
|
|
}
|
|
|
|
compileOptions {
|
|
sourceCompatibility = JavaVersion.VERSION_21
|
|
targetCompatibility = JavaVersion.VERSION_21
|
|
}
|
|
|
|
buildFeatures {
|
|
compose = true
|
|
buildConfig = true
|
|
}
|
|
|
|
testOptions {
|
|
unitTests {
|
|
isIncludeAndroidResources = true
|
|
}
|
|
}
|
|
|
|
packaging {
|
|
resources {
|
|
excludes += "/META-INF/{AL2.0,LGPL2.1}"
|
|
}
|
|
}
|
|
}
|
|
|
|
kotlin {
|
|
compilerOptions {
|
|
jvmTarget.set(org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_21)
|
|
}
|
|
}
|
|
|
|
// Paparazzi's Gradle plugin writes an HTML test report that trips on AGP 9's
|
|
// new DSL (cashapp/paparazzi#2111). Disable it; the PNG snapshots/report task
|
|
// output is what we actually care about here.
|
|
tasks.withType<Test>().configureEach {
|
|
reports.html.required.set(false)
|
|
}
|
|
|
|
dependencies {
|
|
implementation(libs.androidx.core.ktx)
|
|
implementation(libs.androidx.activity.compose)
|
|
implementation(libs.androidx.lifecycle.runtime.ktx)
|
|
implementation(libs.androidx.lifecycle.viewmodel.compose)
|
|
implementation(libs.androidx.navigation.compose)
|
|
|
|
implementation(platform(libs.compose.bom))
|
|
implementation(libs.compose.ui)
|
|
implementation(libs.compose.ui.graphics)
|
|
implementation(libs.compose.ui.tooling.preview)
|
|
implementation(libs.compose.foundation)
|
|
implementation(libs.compose.material3)
|
|
implementation(libs.compose.material.icons.extended)
|
|
debugImplementation(libs.compose.ui.tooling)
|
|
debugImplementation(libs.compose.ui.test.manifest)
|
|
|
|
implementation(libs.androidx.room.runtime)
|
|
implementation(libs.androidx.room.ktx)
|
|
ksp(libs.androidx.room.compiler)
|
|
|
|
implementation(libs.retrofit.core)
|
|
implementation(libs.retrofit.kotlinx.serialization.converter)
|
|
implementation(libs.okhttp.logging.interceptor)
|
|
implementation(libs.kotlinx.serialization.json)
|
|
implementation(libs.kotlinx.coroutines.core)
|
|
implementation(libs.kotlinx.coroutines.android)
|
|
|
|
implementation(libs.coil.compose)
|
|
implementation(libs.coil.network.okhttp)
|
|
|
|
implementation(libs.androidx.camera.core)
|
|
implementation(libs.androidx.camera.camera2)
|
|
implementation(libs.androidx.camera.lifecycle)
|
|
implementation(libs.androidx.camera.view)
|
|
implementation(libs.mlkit.barcode.scanning)
|
|
|
|
implementation(libs.androidx.work.runtime.ktx)
|
|
implementation(libs.androidx.datastore.preferences)
|
|
implementation(libs.accompanist.permissions)
|
|
|
|
testImplementation(libs.junit)
|
|
testImplementation(libs.robolectric)
|
|
testImplementation(libs.androidx.test.core)
|
|
testImplementation(libs.androidx.test.ext.junit)
|
|
testImplementation(libs.androidx.room.testing)
|
|
testImplementation(libs.kotlinx.coroutines.test)
|
|
testImplementation(platform(libs.compose.bom))
|
|
testImplementation(libs.compose.ui.test.manifest)
|
|
}
|