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}