`./gradlew :androidApp:lintDebug` had apparently never been run. It reported 11 errors, 8 warnings and 3 hints, and one of the errors was a crash: UsageScreen formats its reset countdown with java.time -- OffsetDateTime and Duration, both API 26 -- while minSdk is 24 and core library desugaring was off. On 24 and 25 that is a NoClassDefFoundError, and the `catch (_: Exception)` around the code does not stop an Error, so the usage screen would have died rather than degraded. Core library desugaring is now on, with desugar_jdk_libs 2.1.5. Verified by looking in the built APK rather than trusting the flag: it now carries Lj$/time/Duration and Lj$/time/OffsetDateTime, the backported classes the call sites are rewritten against. The rest: the two KTX suggestions taken (SharedPreferences.edit's block form, which cannot forget its apply(), and String.toUri), the three autoboxing hints taken (mutableIntStateOf/mutableLongStateOf), and androidx.core:core-ktx declared at 1.19.0 rather than inherited through activity-compose, since this code now calls its extensions directly. Two are suppressed with their reasons, both scoped to the one element. DiscouragedApi on the scanner's screenOrientation, which is not a pin but the removal of the library's landscape lock. MissingApplicationIcon, because there is no icon yet and that is a decision to make later, not an oversight -- an app with no icon is obvious to anyone who opens a launcher, so the warning tells nobody here anything. 0 errors now. The 4 warnings left are one thing: Compose Multiplatform 1.12.0 is out and this is on 1.11.1. That is an upgrade to decide on, not a defect, so it is left for its own change. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017xn8nHw1tw1R6PtiY1eEtw
141 lines
5.0 KiB
Kotlin
141 lines
5.0 KiB
Kotlin
plugins {
|
|
alias(libs.plugins.androidApplication)
|
|
alias(libs.plugins.composeMultiplatform)
|
|
alias(libs.plugins.composeCompiler)
|
|
}
|
|
|
|
// The CA this app pins is baked in at build time from the certificates on
|
|
// the machine doing the build -- `$XDG_CONFIG_HOME/ai-app/certs/ca.pem`,
|
|
// which the server generates on first start. AI_APP_CA overrides the path.
|
|
//
|
|
// Reading it rather than keeping a pasted copy in the source is what makes
|
|
// the trust boundary follow the build: an APK built on the backend host
|
|
// pins the host's CA and never sees any other, while one built in the dev
|
|
// VM pins that VM's throwaway CA and is only good for its emulator. There
|
|
// is no second trust anchor to get wrong, and no stale paste to notice
|
|
// three days later. It also means the private key never has to exist
|
|
// anywhere near this repo.
|
|
val pinnedCaPath: String = System.getenv("AI_APP_CA")
|
|
?: "${System.getenv("XDG_CONFIG_HOME") ?: "${System.getProperty("user.home")}/.config"}" +
|
|
"/ai-app/certs/ca.pem"
|
|
|
|
abstract class GeneratePinnedCert : DefaultTask() {
|
|
/** Where the certificate is looked for, reported in failures. */
|
|
@get:Input
|
|
abstract val caPath: Property<String>
|
|
|
|
/**
|
|
* The certificate itself, set only when it exists -- so a missing one
|
|
* produces this task's own instructions rather than Gradle's "no such
|
|
* input file", which doesn't say what to run.
|
|
*/
|
|
@get:InputFile
|
|
@get:Optional
|
|
@get:PathSensitive(PathSensitivity.NONE)
|
|
abstract val caCertificate: RegularFileProperty
|
|
|
|
/** Wired by AGP through `addGeneratedSourceDirectory`. */
|
|
@get:OutputDirectory
|
|
abstract val outputDir: DirectoryProperty
|
|
|
|
@TaskAction
|
|
fun generate() {
|
|
val path = caPath.get()
|
|
val ca = File(path)
|
|
if (!ca.isFile) {
|
|
throw GradleException(
|
|
"No CA certificate at $path.\n" +
|
|
"Start ai-server once on this machine first -- it generates the CA the " +
|
|
"app pins, and the certificate has to exist before an APK can embed it.\n" +
|
|
"Set AI_APP_CA=/path/to/ca.pem to build against a different one.",
|
|
)
|
|
}
|
|
val pem = ca.readText().trim()
|
|
if (!pem.startsWith("-----BEGIN CERTIFICATE-----")) {
|
|
throw GradleException("$path is not a PEM certificate.")
|
|
}
|
|
val file = outputDir.get().file("PinnedCaCertificate.kt").asFile
|
|
file.parentFile.mkdirs()
|
|
// The PEM must start immediately after the opening quotes: a
|
|
// leading newline makes Android's CertificateFactory stop
|
|
// recognising the "-----BEGIN" preamble and try to parse the whole
|
|
// thing as DER, which fails with an ASN.1 decode error at runtime
|
|
// rather than anywhere near this file.
|
|
file.writeText(
|
|
"""
|
|
|// Generated from $path by the generatePinnedCert task. Do not edit.
|
|
|package com.example.aiapp
|
|
|
|
|
|const val PINNED_CA_PEM = ""${'"'}$pem
|
|
|""${'"'}
|
|
|
|
|
""".trimMargin(),
|
|
)
|
|
}
|
|
}
|
|
|
|
val generatePinnedCert = tasks.register<GeneratePinnedCert>("generatePinnedCert") {
|
|
val ca = file(pinnedCaPath)
|
|
caPath.set(pinnedCaPath)
|
|
if (ca.isFile) {
|
|
caCertificate.set(ca)
|
|
}
|
|
}
|
|
|
|
android {
|
|
namespace = "com.example.aiapp"
|
|
compileSdk = 37
|
|
|
|
defaultConfig {
|
|
applicationId = "com.example.aiapp"
|
|
minSdk = 24
|
|
targetSdk = 37
|
|
versionCode = 1
|
|
versionName = "1.0"
|
|
}
|
|
packaging {
|
|
resources {
|
|
excludes += "/META-INF/{AL2.0,LGPL2.1}"
|
|
}
|
|
}
|
|
buildTypes {
|
|
getByName("release") {
|
|
isMinifyEnabled = false
|
|
}
|
|
}
|
|
compileOptions {
|
|
sourceCompatibility = JavaVersion.VERSION_21
|
|
targetCompatibility = JavaVersion.VERSION_21
|
|
// minSdk is 24 and UsageScreen formats its countdown with
|
|
// java.time, which the platform only has from 26. Without this it
|
|
// is a NoClassDefFoundError on 24 and 25 -- an Error, so the
|
|
// catch around that code does not stop it.
|
|
isCoreLibraryDesugaringEnabled = true
|
|
}
|
|
}
|
|
|
|
// AGP 9 wants generated sources registered through the variant API rather
|
|
// than added to a source set, so the task dependency is carried properly.
|
|
androidComponents {
|
|
onVariants { variant ->
|
|
variant.sources.java?.addGeneratedSourceDirectory(
|
|
generatePinnedCert,
|
|
GeneratePinnedCert::outputDir,
|
|
)
|
|
}
|
|
}
|
|
|
|
dependencies {
|
|
// Not a library this code calls: it is what `isCoreLibraryDesugaring
|
|
// Enabled` above rewrites java.time against, so API 24 and 25 have it.
|
|
coreLibraryDesugaring(libs.desugar.jdk.libs)
|
|
|
|
implementation(libs.compose.runtime)
|
|
implementation(libs.compose.foundation)
|
|
implementation(libs.compose.material3)
|
|
implementation(libs.compose.ui)
|
|
implementation(libs.androidx.activity.compose)
|
|
implementation(libs.androidx.core.ktx)
|
|
implementation(libs.zxing.embedded)
|
|
}
|