34e5cad02a
The reverse proxy closes requests from outside the home LAN that lack X-Kvitt-App: <key>. The key is the build property kvitt.appKey (-Pkvitt.appKey, else android/local.properties, else empty = no header), validated as 16-128 printable ASCII without whitespace, exposed as BuildConfig.APP_KEY and passed via AppBuildInfo.appKey to KvittApi (every call, /health and other unauthenticated calls included) and the Coil picture client (PictureAuth). AppBuildInfo.toString hides the key. Tests use a made-up key; README documents the property. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TQd3pEP9UeN2uifbHknQ7B
160 lines
8.1 KiB
Kotlin
160 lines
8.1 KiB
Kotlin
// :app — MainActivity, Application, Hilt wiring (every :core:model interface bound to :data, D-041 and D-085),
|
|
// navigation and deep links, push handling and notifications, network security and release signing (SPEC §6.2).
|
|
import java.util.Properties
|
|
|
|
plugins {
|
|
id("kvitt.android.application")
|
|
id("kvitt.android.compose")
|
|
id("kvitt.android.hilt")
|
|
id("kvitt.android.screenshots")
|
|
alias(libs.plugins.google.services)
|
|
}
|
|
|
|
// SPEC §10.6: release signing from android/local.properties (git-ignored); without the keys the release build is unsigned.
|
|
val localProperties = Properties().apply {
|
|
val file = rootProject.file("local.properties")
|
|
if (file.isFile) file.inputStream().use { load(it) }
|
|
}
|
|
val releaseStoreFile: String? = localProperties.getProperty("kvitt.release.storeFile")
|
|
|
|
// D-121: Kvitt's public host name (SPEC §8.5, §10.6), as BuildConfig.PUBLIC_HOST, the manifest placeholder publicHost
|
|
// (the https invite links of AC-2.6, default port 443, D-124) and RELEASE_SERVER_URL = https://<host> (AC-2.7). From
|
|
// -Pkvitt.publicHost (infra/publish-apk.sh passes it, from KVITT_PUBLIC_HOST in infra/pi/.env), else the key
|
|
// kvitt.publicHost in android/local.properties (the owner sets it there; never committed), else the placeholder
|
|
// kvitt.example.invalid (RFC 2606: never resolves), so a release built without it cannot talk to a real host. Debug
|
|
// builds pair with the server address typed on S-1 and need none of it.
|
|
val publicHost: String = (
|
|
providers.gradleProperty("kvitt.publicHost").orNull?.trim()?.takeIf { it.isNotEmpty() }
|
|
?: localProperties.getProperty("kvitt.publicHost")?.trim()?.takeIf { it.isNotEmpty() }
|
|
?: "kvitt.example.invalid"
|
|
).lowercase()
|
|
require(Regex("[a-z0-9]([a-z0-9-]*[a-z0-9])?(\\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)+").matches(publicHost)) {
|
|
"kvitt.publicHost must be a host name such as kvitt.duckdns.org (no scheme, port or path), was '$publicHost'"
|
|
}
|
|
|
|
// D-126: the app key for the reverse proxy (SPEC §8.5, §14.3.1). Every request to the server carries X-Kvitt-App: <key>;
|
|
// outside the home LAN the proxy closes any request without it. From -Pkvitt.appKey, else the key kvitt.appKey in
|
|
// android/local.properties (the owner sets it there; never committed), else empty: no header (debug builds against the
|
|
// dev server need none). A secret: never logged, and the failure below names its length only, never its value.
|
|
val appKey: String =
|
|
providers.gradleProperty("kvitt.appKey").orNull?.trim()?.takeIf { it.isNotEmpty() }
|
|
?: localProperties.getProperty("kvitt.appKey")?.trim()?.takeIf { it.isNotEmpty() }
|
|
?: ""
|
|
require(appKey.isEmpty() || Regex("[!-~]{16,128}").matches(appKey)) {
|
|
"kvitt.appKey must be 16 to 128 printable ASCII characters without whitespace (D-126); the value given has " +
|
|
"${appKey.length} characters"
|
|
}
|
|
|
|
// SPEC §10.5.3 (D-082): the pinned NPM root (D-120) replaces the system anchors for kvitt.publicHost only when this is true.
|
|
val pinnedRoot = providers.gradleProperty("kvitt.tls.pinnedRoot").orElse("false").get().toBoolean()
|
|
|
|
/**
|
|
* Writes res/xml/network_security_config.xml from [template] with `@PUBLIC_HOST@` replaced by [host] (D-121). The
|
|
* fallback is not prepared (§10.5.3): without the committed root [rootCa] the build stops here with that message.
|
|
*/
|
|
abstract class PinnedRootConfigTask : DefaultTask() {
|
|
@get:InputFile @get:PathSensitive(PathSensitivity.NONE) abstract val template: RegularFileProperty
|
|
@get:Input abstract val host: Property<String>
|
|
@get:Internal abstract val rootCa: RegularFileProperty
|
|
@get:OutputDirectory abstract val outputDir: DirectoryProperty
|
|
|
|
@TaskAction
|
|
fun write() {
|
|
val ca = rootCa.get().asFile
|
|
if (!ca.isFile) throw GradleException("kvitt.tls.pinnedRoot=true needs the pinned root ${ca.path} (SPEC §10.5.3)")
|
|
val root = outputDir.get().asFile
|
|
root.deleteRecursively()
|
|
val xml = root.resolve("xml/network_security_config.xml")
|
|
xml.parentFile.mkdirs()
|
|
xml.writeText(template.get().asFile.readText().replace("@PUBLIC_HOST@", host.get()))
|
|
}
|
|
}
|
|
|
|
android {
|
|
namespace = "se.hagstedt.kvitt"
|
|
defaultConfig {
|
|
applicationId = "se.hagstedt.kvitt"
|
|
// SPEC §10.6: the release script passes kvitt.versionCode and kvitt.versionName; debug builds use 1 and "dev".
|
|
versionCode = providers.gradleProperty("kvitt.versionCode").orElse("1").get().toInt()
|
|
versionName = providers.gradleProperty("kvitt.versionName").orElse("dev").get()
|
|
buildConfigField("String", "PUBLIC_HOST", "\"$publicHost\"")
|
|
buildConfigField("String", "APP_KEY", "\"${appKey.replace("\\", "\\\\").replace("\"", "\\\"")}\"")
|
|
manifestPlaceholders["publicHost"] = publicHost
|
|
}
|
|
// SPEC G-8 and AC-19.1: the app is English (fallback) and Swedish only; library translations of other languages
|
|
// are left out of the APK.
|
|
androidResources {
|
|
localeFilters += listOf("en", "sv")
|
|
}
|
|
// BuildConfig carries versionCode, versionName, DEBUG and APP_KEY (D-126) into AppBuildInfo (:data, D-041), and
|
|
// PUBLIC_HOST (D-121).
|
|
buildFeatures {
|
|
buildConfig = true
|
|
}
|
|
signingConfigs {
|
|
if (releaseStoreFile != null) {
|
|
create("release") {
|
|
storeFile = file(releaseStoreFile)
|
|
storePassword = localProperties.getProperty("kvitt.release.storePassword")
|
|
keyAlias = localProperties.getProperty("kvitt.release.keyAlias")
|
|
keyPassword = localProperties.getProperty("kvitt.release.keyPassword")
|
|
}
|
|
}
|
|
}
|
|
buildTypes {
|
|
debug {
|
|
applicationIdSuffix = ".debug"
|
|
}
|
|
release {
|
|
if (releaseStoreFile != null) signingConfig = signingConfigs.getByName("release")
|
|
// SPEC §10.6: R8 shrinks, optimises and obfuscates the release APK; the libraries' consumer rules plus
|
|
// proguard-rules.pro. publish-apk.sh keeps build/outputs/mapping/release/mapping.txt next to the APK.
|
|
isMinifyEnabled = true
|
|
isShrinkResources = true
|
|
proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro")
|
|
}
|
|
}
|
|
}
|
|
|
|
// The pinned-root network security config (generated from src/releasePinnedRoot, trusting src/release/res/raw/
|
|
// kvitt_root_ca.crt) only in release builds and only when kvitt.tls.pinnedRoot=true; otherwise src/main's
|
|
// system-anchors config applies.
|
|
if (pinnedRoot) {
|
|
val pinnedRootConfig = tasks.register("generatePinnedRootNetworkSecurityConfig", PinnedRootConfigTask::class.java) {
|
|
description = "Writes the pinned-root network security config for kvitt.publicHost (SPEC §10.5.3)."
|
|
template.set(layout.projectDirectory.file("src/releasePinnedRoot/network_security_config.xml"))
|
|
host.set(publicHost)
|
|
rootCa.set(layout.projectDirectory.file("src/release/res/raw/kvitt_root_ca.crt"))
|
|
}
|
|
androidComponents.onVariants(androidComponents.selector().withBuildType("release")) { variant ->
|
|
variant.sources.res?.addGeneratedSourceDirectory(pinnedRootConfig, PinnedRootConfigTask::outputDir)
|
|
}
|
|
}
|
|
|
|
dependencies {
|
|
implementation(project(":core:model"))
|
|
implementation(project(":core:design"))
|
|
implementation(project(":domain"))
|
|
implementation(project(":data"))
|
|
implementation(project(":adapter:fcm"))
|
|
implementation(project(":feature:onboarding"))
|
|
implementation(project(":feature:home"))
|
|
implementation(project(":feature:account"))
|
|
implementation(project(":feature:expense"))
|
|
implementation(project(":feature:settle"))
|
|
implementation(project(":feature:analytics"))
|
|
implementation(project(":feature:settings"))
|
|
testImplementation(project(":core:fakes"))
|
|
testImplementation(testFixtures(project(":core:design")))
|
|
|
|
implementation(libs.androidx.core.ktx)
|
|
implementation(libs.androidx.activity.compose)
|
|
implementation(libs.androidx.navigation.compose)
|
|
implementation(libs.androidx.lifecycle.runtime.compose)
|
|
implementation(libs.androidx.lifecycle.process)
|
|
implementation(libs.androidx.work.runtime.ktx)
|
|
implementation(libs.kotlinx.coroutines.core)
|
|
implementation(libs.kotlinx.datetime)
|
|
implementation(libs.coil)
|
|
}
|