whiskers.git / android / app / build.gradle.kts
build.gradle.kts157 lines · 7.9 KB · raw
1plugins {
2    alias(libs.plugins.android.application)
3    alias(libs.plugins.kotlin.compose)
4    alias(libs.plugins.roborazzi)
5}
6
7// The address of the machine running `whiskersd` is a RUNTIME setting: a grown-up types it into the app (behind the
8// grown-up lock) and it is kept on the device. -Pwhiskers.host=<host[:port]> (or whiskers.host=... in
9// ~/.gradle/gradle.properties) is only the DEFAULT for that setting, so an owner's own builds, and installs that
10// were made with it, keep working; an address saved in the app wins over it. A release APK is built without it.
11val defaultService = (project.findProperty("whiskers.host") as String?)?.trim().orEmpty()
12
13// Release version: -Pwhiskers.versionName=2026.10.5 (calendar version, no zero padding on month or day) and
14// -Pwhiskers.versionCode=2026100501 (yyyymmdd then a two-digit build of the day, so an update installs over the
15// last). Dev builds keep 1 / "0.1".
16val releaseVersionName = (project.findProperty("whiskers.versionName") as String?)?.trim()?.takeIf { it.isNotEmpty() } ?: "0.1"
17val releaseVersionCode = (project.findProperty("whiskers.versionCode") as String?)?.trim()?.takeIf { it.isNotEmpty() }
18    ?.let { it.toIntOrNull()?.takeIf { n -> n > 0 } ?: throw GradleException("whiskers.versionCode must be a positive integer, such as 2026100501; got \"$it\"") } ?: 1
19
20android {
21    namespace = "app.whiskers"
22    compileSdk = 37
23    // Must equal android_build_tools in mise.toml (tools/check-versions.sh checks it).
24    buildToolsVersion = "36.0.0"
25
26    // Which key signs the APK. By default a debug key committed beside the project, as in ~/dashboard/android: a nix
27    // build has no $HOME, so AGP would invent a new random key each time, and a phone refuses to update an app signed by
28    // a different key. Its password is the well-known "android"; it is a fixture for continuity, not a secret, and
29    // anyone who clones can build with it.
30    //
31    // To sign with your own key, set all four (the values never go in a file here):
32    //   WHISKERS_KEYSTORE            path to your keystore (.jks / .p12)
33    //   WHISKERS_KEYSTORE_PASSWORD   its password
34    //   WHISKERS_KEY_ALIAS           the key's alias in it
35    //   WHISKERS_KEY_PASSWORD        the key's password (the same as the keystore's for a .p12)
36    // Some but not all of them is an error, so a build never falls back to the fixture by a typo. An app signed with
37    // one key cannot be updated in place by one signed with another: uninstall first (the device's own data goes; what
38    // the service keeps comes back with the next sync).
39    val ownKey = listOf("WHISKERS_KEYSTORE", "WHISKERS_KEYSTORE_PASSWORD", "WHISKERS_KEY_ALIAS", "WHISKERS_KEY_PASSWORD")
40        .associateWith { System.getenv(it)?.takeIf { v -> v.isNotEmpty() } }
41    if (ownKey.values.any { it != null } && ownKey.values.any { it == null }) {
42        throw GradleException("Set all of ${ownKey.keys.joinToString()} to sign with your own key, or none of them; missing: ${ownKey.filterValues { it == null }.keys.joinToString()}")
43    }
44    signingConfigs {
45        getByName("debug") {
46            if (ownKey.values.all { it != null }) {
47                storeFile = file(ownKey.getValue("WHISKERS_KEYSTORE")!!)
48                storePassword = ownKey.getValue("WHISKERS_KEYSTORE_PASSWORD")
49                keyAlias = ownKey.getValue("WHISKERS_KEY_ALIAS")
50                keyPassword = ownKey.getValue("WHISKERS_KEY_PASSWORD")
51            } else {
52                storeFile = rootProject.file("debug.keystore")
53                storePassword = "android"
54                keyAlias = "androiddebugkey"
55                keyPassword = "android"
56            }
57        }
58    }
59
60    defaultConfig {
61        // Override with -Pwhiskers.applicationId=... to install over (or beside) another build of the
62        // app; the default is the application's own id.
63        applicationId = (project.findProperty("whiskers.applicationId") as String?) ?: "app.whiskers"
64        // The target is a Fire HD Kids tablet on Fire OS 8, which is Android 11
65        // (API 30): the minimum is that, not the newest SDK as in the dashboard.
66        minSdk = 30
67        targetSdk = 34
68        versionCode = releaseVersionCode
69        versionName = releaseVersionName
70
71        // The tablet and the phones are 64-bit ARM, and the tablet has little room to spare. Other
72        // CPU types only cost space: add them for an emulator with -Pwhiskers.abis=arm64-v8a,x86_64.
73        ndk { abiFilters += ((project.findProperty("whiskers.abis") as String?) ?: "arm64-v8a").split(",") }
74
75        // The service is reached over plain http on a private, already encrypted network (a VPN); TLS would pull a C
76        // crypto backend into the Rust core. Its address is a runtime setting: this is only the default ("" for none).
77        buildConfigField("String", "DEFAULT_SERVICE", "\"${defaultService.replace("\\", "\\\\").replace("\"", "\\\"")}\"")
78        buildConfigField("String", "MODEL", "\"claude-haiku-4-5-20251001\"")
79    }
80
81    // The voice model is read by sherpa-onnx; stored uncompressed it is not inflated on first use.
82    androidResources { noCompress += "onnx" }
83
84    buildFeatures {
85        compose = true
86        buildConfig = true
87    }
88
89    compileOptions {
90        sourceCompatibility = JavaVersion.VERSION_21
91        targetCompatibility = JavaVersion.VERSION_21
92    }
93
94    testOptions {
95        unitTests {
96            isIncludeAndroidResources = true
97            // Robolectric's Android 17 boot needs jdk.internal.access on JDK 21.
98            all { it.jvmArgs("--add-exports=java.base/jdk.internal.access=ALL-UNNAMED", "--add-opens=java.base/jdk.internal.access=ALL-UNNAMED") }
99        }
100    }
101}
102
103kotlin {
104    jvmToolchain(21)
105}
106
107// The Rust core and its Kotlin bindings, built before everything else. It runs inside
108// mise (or any shell with cargo-ndk and the NDK); see tools/build-core.sh.
109val buildCore by tasks.registering(Exec::class) {
110    workingDir = rootProject.projectDir.parentFile
111    commandLine("tools/build-core.sh")
112}
113// The offline speech model (about 70 MB unpacked, gitignored), for tablets with no recogniser.
114val fetchSpeechModel by tasks.registering(Exec::class) {
115    workingDir = rootProject.projectDir.parentFile
116    commandLine("tools/fetch-speech-model.sh")
117}
118// The offline neural voice (sherpa-onnx + a Piper voice, about 50 MB, gitignored). Unlike the speech
119// model it is optional: if the fetch fails (no network) the build goes on without it and the
120// app does not offer the voice.
121val fetchOfflineVoice by tasks.registering(Exec::class) {
122    workingDir = rootProject.projectDir.parentFile
123    commandLine("tools/fetch-offline-voice.sh")
124    isIgnoreExitValue = true
125    // -Pwhiskers.offlineVoice=false builds the lite variant: nothing is fetched, so a tree without the voice's files
126    // (the release script moves them aside) packs none of it. Anything else, or nothing, fetches as before.
127    onlyIf { (project.findProperty("whiskers.offlineVoice") as String?)?.trim() != "false" }
128}
129tasks.named("preBuild") { dependsOn(buildCore, fetchSpeechModel, fetchOfflineVoice) }
130
131dependencies {
132    implementation(libs.jna) { artifact { type = "aar" } }
133    implementation(libs.vosk.android)
134    implementation(libs.kotlinx.coroutines.android)
135    implementation(libs.androidx.core.ktx)
136    implementation(libs.androidx.activity.compose)
137    implementation(libs.androidx.lifecycle.runtime.ktx)
138    implementation(platform(libs.compose.bom))
139    implementation(libs.compose.ui)
140    implementation(libs.compose.foundation)
141
142    testImplementation(libs.junit)
143    testImplementation(libs.kotlinx.coroutines.test)
144    testImplementation(libs.robolectric)
145    testImplementation(libs.roborazzi)
146    testImplementation(libs.roborazzi.compose)
147    testImplementation(libs.roborazzi.junit.rule)
148    testImplementation(platform(libs.compose.bom))
149    testImplementation(libs.compose.ui.test.junit4)
150    testImplementation(libs.espresso.core)
151    testImplementation(libs.androidx.test.core)
152    debugImplementation(libs.compose.ui.test.manifest)
153}
154
155roborazzi {
156    outputDir.set(file("screenshots"))
157}