import java.util.Properties import org.jetbrains.kotlin.gradle.dsl.JvmTarget import org.jetbrains.kotlin.gradle.tasks.KotlinJvmCompile /** * Gearslip: a phone-side Android Auto client built against the reverse-engineered AA wire * protocol, so a non-Google app can authenticate to and project onto a real car head unit * with no gearhead in the path. No third-party dependencies: the X.509 certificate is * generated by hand (see `SelfSignedCert`) so there is no BouncyCastle version to track and * no second crypto provider to collide with Conscrypt. */ plugins { alias(libs.plugins.android.application) alias(libs.plugins.kotlin.compose) } // The release signing key, read from an untracked keystore.properties beside this file or at the // repository root. Third-party apps allowlist Gearslip by the SHA-256 of this certificate, so the // key must stay the same for the life of the project - and must never be committed. val keystoreProperties = Properties().apply { listOf( rootProject.file("keystore.properties"), project.file("keystore.properties"), ).firstOrNull { it.exists() }?.inputStream()?.use { load(it) } } // The version comes from the newest v* git tag, so a build can never disagree with its tag. A // build of the tagged commit is named after it ("0.4.0-beta"); anything later adds the commits // since and the commit ("0.4.0-beta+3.ba4374e"), so a test build can't pass for a release. // The version code is worked out from the numbers, ((major*100 + minor)*100 + patch)*10 + stage, // where stage is 1 for alpha, 2 for beta and 9 for a finished release. It only ever goes up as // long as minor and patch stay under 100. Changelog entries use the same number. class GitVersion(val name: String, val code: Int) val gitVersion: GitVersion = run { val described = runCatching { providers.exec { commandLine("git", "describe", "--tags", "--match", "v*", "--abbrev=7") isIgnoreExitValue = true }.standardOutput.asText.get().trim() }.getOrNull().orEmpty() val match = Regex("""^v((\d+)\.(\d+)\.(\d+)(?:-([A-Za-z]+))?)(?:-(\d+)-g([0-9a-f]+))?$""").find(described) ?: return@run GitVersion("0.0.0-dev", 1) // no git or no tags, e.g. a source download val (base, major, minor, patch, suffix, since, commit) = match.destructured val stage = when (suffix.lowercase()) { "" -> 9 "beta" -> 2 "alpha" -> 1 else -> 0 } GitVersion( name = if (since.isEmpty()) base else "$base+$since.$commit", code = ((major.toInt() * 100 + minor.toInt()) * 100 + patch.toInt()) * 10 + stage, ) } // Keeps build output out of the module folder: /build/gearslip/... instead of // /gearslip/build/..., so everything this project produces lands under one // root-level build/ directory. Set before android {}, which works out the path to R8's default // rules from it. layout.buildDirectory.set(rootProject.layout.buildDirectory.dir("gearslip")) android { namespace = "app.seb3thehacker.gearslip" compileSdk = 37 defaultConfig { applicationId = "app.seb3thehacker.gearslip" minSdk = 31 targetSdk = 37 versionCode = gitVersion.code // -PgearslipVersionName=0.1.99-test makes a build that thinks it's older, to try the updater. versionName = project.findProperty("gearslipVersionName")?.toString() ?: gitVersion.name manifestPlaceholders["appLabel"] = "Gearslip" // Where opted-in phones send their daily usage note: the stats-worker/ Worker's address, // set as gearslipStatsUrl in gradle.properties. Left empty, nothing is ever sent. buildConfigField("String", "STATS_URL", "\"${project.findProperty("gearslipStatsUrl") ?: ""}\"") } signingConfigs { // Declared only when the properties file is present, so a clone without the key still // builds debug; assembleRelease then falls back to unsigned rather than failing here. if (keystoreProperties.getProperty("storeFile") != null) { create("release") { storeFile = rootProject.file(keystoreProperties.getProperty("storeFile")) storePassword = keystoreProperties.getProperty("storePassword") keyAlias = keystoreProperties.getProperty("keyAlias") keyPassword = keystoreProperties.getProperty("keyPassword") } } } buildTypes { release { signingConfig = signingConfigs.findByName("release") isMinifyEnabled = true isShrinkResources = true proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro") } // Its own package, so a debug build installs beside the release one instead of over it. // Signed with the release key when it's here: both builds declare the car template // permission, and Android refuses a second app declaring it under a different key. debug { applicationIdSuffix = ".dev" signingConfigs.findByName("release")?.let { signingConfig = it } manifestPlaceholders["appLabel"] = "Gearslip Debug" // English (XA) and Arabic (XB) under the phone's languages: stretched, accented and // right-to-left text, which shows what's still hardcoded and what doesn't fit. isPseudoLocalesEnabled = true // Shrunk with R8 like a release, so a debug install behaves like what ships (a keep // rule missing for release shows up here first). -PnoR8 skips it for a quicker build. if (!project.hasProperty("noR8")) { isMinifyEnabled = true isShrinkResources = true proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro") } } } buildFeatures { buildConfig = true } // Lists each language that has a translation in the app's locale config, so Android 13+ // offers it under the system's per-app languages. English is the unqualified default // (res/resources.properties). // // Only languages that are mostly translated ship, so the app never comes up half in English. // Danish and Estonian are still early on Weblate; add them here once they're close to done. androidResources { generateLocaleConfig = true localeFilters += listOf( "en", "ar", "cs", "de", "es", "fr", "it", "ja", "ko", "nl", "pl", "pt-rBR", "ro", "ru", "sv", "tr", "uk", "zh-rCN", ) } lint { // Weblate fills languages in a string at a time; the rest fall back to English. disable += "MissingTranslation" } compileOptions { sourceCompatibility = JavaVersion.VERSION_21 targetCompatibility = JavaVersion.VERSION_21 } defaultConfig { // Speech-to-text (sherpa-onnx 1.13.8, static-link-onnxruntime build, in jniLibs) is the // only native code. One APK for all three, since the updater takes the release's only APK; // 32-bit x86 is left out, nothing that drives a car runs it. ndk { abiFilters += listOf("arm64-v8a", "armeabi-v7a", "x86_64") } } // Compressed, the three copies cost a 33 MB download instead of 75 MB; the phone unpacks // only its own at install. packaging { jniLibs { useLegacyPackaging = true } } testOptions { // GearslipLog calls android.util.Log, which is a stub on the local JVM. unitTests.isReturnDefaultValues = true } } // Copies the produced APK to the root build/ folder (/build/Gearslip-debug.apk) after the // fact rather than fighting AGP's variant-output API across versions - simpler and doesn't break // when that API moves again. A copy, not a move: AGP packages incrementally into the APK it left // last time, and with that file gone a build came out holding nothing but classes.dex. tasks.register("renameApk") { doLast { val rootBuildDir = rootProject.layout.buildDirectory.get().asFile layout.buildDirectory.dir("outputs/apk").get().asFile.walkTopDown() .filter { it.extension == "apk" } .toList() .forEach { apk -> val renamed = File(rootBuildDir, "Gearslip-${apk.parentFile.name}.apk") apk.copyTo(renamed, overwrite = true) } } } tasks.matching { it.name.startsWith("assemble") }.configureEach { finalizedBy("renameApk") } dependencies { // Only for the XML window theme (Theme.Material3.DynamicColors); every screen is Compose. implementation(libs.google.material) implementation(platform(libs.androidx.compose.bom)) implementation(libs.androidx.compose.ui) implementation(libs.androidx.compose.material3) implementation(libs.androidx.compose.material.icons.core) implementation(libs.androidx.activity.compose) implementation(libs.androidx.lifecycle.runtime.compose) // Used from the host side, not the app side: Gearslip renders other apps' templates rather // than publishing its own, so this is here for the AIDL interfaces and the model classes // their Bundleables deserialize into. implementation(libs.androidx.car.app) implementation(libs.androidx.car.app.projected) implementation(libs.androidx.media) // Document-start scripts, so the ad blocker hides ad slots before a page first paints. implementation(libs.androidx.webkit) testImplementation(libs.junit) } tasks.withType().configureEach { compilerOptions { jvmTarget.set(JvmTarget.JVM_21) } }