// :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:// (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: ; // 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 @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) }