EARHORNMUSIC UPnP · Android TV · Phones · Android Auto · GNU/Linux
Release 0.5.0

build.gradle.kts

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)
}