import java.util.Properties
plugins {
alias(libs.plugins.android.application)
alias(libs.plugins.kotlin.android)
alias(libs.plugins.kotlin.compose)
alias(libs.plugins.kotlin.serialization)
alias(libs.plugins.ksp)
alias(libs.plugins.hilt)
}
// Private, per-machine values, read from earhorn-private.properties next to this
// file. That file is for your own builds only: never commit or publish it.
// Public builds: run Gradle with -Pearhorn.public, which ignores the file and
// leaves both values empty (those two features are then off). Keys:
// earhorn.musicnowStatusUrl=http://<host>:8642/status
// earhorn.domEthaDefaultUrl=http://<host>/mediacontrol.html
val earhornLocal = Properties().also { props ->
if (!project.hasProperty("earhorn.public")) {
project.file("earhorn-private.properties").takeIf { it.isFile }?.inputStream()?.use { props.load(it) }
}
}
fun earhornLocalString(key: String): String =
"\"" + (earhornLocal.getProperty(key) ?: "").trim().replace("\\", "\\\\").replace("\"", "\\\"") + "\""
android {
namespace = "dev.stave"
compileSdk = libs.versions.compileSdk.get().toInt()
installation {
// The kukui Chromebase (MT8183, network ADB) can take minutes to
// complete an install — the ddmlib default gives up long before
// the ChromeOS ARC container finishes. 10 minutes of headroom.
timeOutInMs = 600_000
// -r reinstall keeping data, -d allow version downgrade while
// iterating between zips/builds on multiple test devices.
installOptions("-r", "-d")
}
defaultConfig {
applicationId = "dev.stave"
minSdk = libs.versions.minSdk.get().toInt()
targetSdk = libs.versions.targetSdk.get().toInt()
// 5 = the deployed 0.2.0 build. Never reuse a versionCode: devices
// refuse an update that does not increase it.
versionCode = 51
versionName = "0.5.0"
vectorDrawables { useSupportLibrary = true }
buildConfigField("String", "MUSICNOW_STATUS_URL", earhornLocalString("earhorn.musicnowStatusUrl"))
buildConfigField("String", "DOM_ETHA_DEFAULT_URL", earhornLocalString("earhorn.domEthaDefaultUrl"))
}
flavorDimensions += "distribution"
productFlavors {
create("play") {
dimension = "distribution"
// With Google Cast. Shipped to Play Store for devices with GPS.
}
create("libre") {
dimension = "distribution"
applicationIdSuffix = ".libre"
versionNameSuffix = "-libre"
// No Cast. For Fire TV, GrapheneOS, F-Droid.
}
}
buildTypes {
debug {
isMinifyEnabled = false
}
release {
isMinifyEnabled = true
// Resource shrinking is OFF — even with res/raw/keep.xml listing the
// launcher icons, AGP's resource shrinker mangles manifest-only icon
// references in release builds, replacing the launcher PNG with a
// surviving drawable (in our case, empty_clef → APK shows clef
// icon instead of the EARHORN badge). Code shrinking via R8 (minify)
// remains on for size; only the resource pass is disabled.
isShrinkResources = false
// Sign release builds with the debug keystore so Android Studio's
// run/install flow works for local testing. Without this, the
// release APK comes out as `app-play-release-unsigned.apk` and
// can't be installed — the installer fails (sometimes surfacing
// as INSTALL_BASELINE_PROFILE_FAILED because the install never
// landed and the post-install baseline-profile step then can't
// find the package).
//
// Production / Play Store releases SHOULD override this with a
// real release keystore via an environment-driven config — but
// until we set that up, the debug key keeps local iteration
// unblocked. The debug keystore is auto-generated by Android
// Studio at ~/.android/debug.keystore on first build.
signingConfig = signingConfigs.getByName("debug")
proguardFiles(
getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro"
)
}
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
kotlinOptions {
jvmTarget = "17"
freeCompilerArgs += listOf("-opt-in=kotlin.RequiresOptIn")
}
buildFeatures {
compose = true
// For the two private values above (see earhorn-private.properties).
buildConfig = true
}
packaging {
resources {
excludes += setOf(
"/META-INF/{AL2.0,LGPL2.1}",
"/META-INF/DEPENDENCIES",
"/META-INF/LICENSE*",
"/META-INF/NOTICE*",
"/META-INF/*.kotlin_module"
)
pickFirsts += setOf(
"/META-INF/INDEX.LIST"
)
}
}
}
dependencies {
// AndroidX core
implementation(libs.androidx.core.ktx)
implementation(libs.androidx.appcompat)
implementation(libs.androidx.lifecycle.runtime.ktx)
implementation(libs.androidx.lifecycle.viewmodel.compose)
implementation(libs.androidx.activity.compose)
// Compose BOM governs versions of all compose libs below
implementation(platform(libs.compose.bom))
implementation(libs.compose.ui)
implementation(libs.compose.ui.graphics)
implementation(libs.compose.ui.tooling.preview)
debugImplementation(libs.compose.ui.tooling)
implementation(libs.compose.material3)
implementation(libs.compose.material.icons.extended)
// Compose for TV
implementation(libs.androidx.tv.foundation)
implementation(libs.androidx.tv.material)
// Navigation + Hilt
implementation(libs.androidx.navigation.compose)
implementation(libs.androidx.hilt.navigation.compose)
implementation(libs.hilt.android)
ksp(libs.hilt.compiler)
// Media3
implementation(libs.media3.exoplayer)
implementation(libs.media3.session)
implementation(libs.media3.ui)
implementation(libs.media3.datasource.okhttp)
// Room (for saved servers + preferences that need relations later)
implementation(libs.room.runtime)
implementation(libs.room.ktx)
ksp(libs.room.compiler)
// DataStore (metadata display prefs)
implementation(libs.datastore.preferences)
// UPnP — jUPnP 3.0.3 (maintained fork of abandoned Cling). Published to Maven Central.
// jupnp.android provides AndroidUpnpServiceImpl + AndroidUpnpServiceConfiguration which wires
// up the Jetty-backed transport and Recovering* XML processors jUPnP needs on Android.
implementation(libs.jupnp.core)
implementation(libs.jupnp.support)
implementation(libs.jupnp.android)
// Jetty 9.4 is jUPnP's required transport on Android. The javax.servlet dep bundled with
// jetty-servlet ships an empty package that Android's runtime already partially claims; we
// drop it here and let jetty pull the real javax.servlet-api through jetty-server instead.
implementation(libs.jetty.server)
implementation(libs.jetty.servlet) {
exclude(group = "org.eclipse.jetty.orbit", module = "javax.servlet")
}
implementation(libs.jetty.client)
// jUPnP logs via SLF4J. jdk14 binding routes to java.util.logging → logcat.
implementation(libs.slf4j.api)
implementation(libs.slf4j.jdk14)
// Image loading for album art
implementation(libs.coil.compose)
implementation(libs.coil.network.okhttp)
implementation(libs.coil.gif)
// HTTP
implementation(libs.okhttp)
// Kotlinx
implementation(libs.kotlinx.coroutines.android)
implementation(libs.kotlinx.serialization.json)
// Android Auto (cartemplate module)
implementation(libs.androidx.car.app)
// Legacy MediaSessionCompat — required by Car App Library
// MediaPlaybackManager.registerMediaPlaybackToken(). Media3
// MediaLibrarySession.sessionCompatToken returns this type, but
// the class itself lives in this support library and isn't
// pulled in transitively.
implementation(libs.androidx.media)
// Cast — only for the play flavor
"playImplementation"(libs.play.services.cast.framework)
// MediaRouter — AppCompat-based chooser dialog used by the Cast sender UI.
// Cast SDK normally pulls this transitively but pinning explicitly avoids
// version drift and makes the dependency obvious.
"playImplementation"(libs.androidx.mediarouter)
}