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

EarhornApp.kt

package dev.stave

import android.app.Application
import android.os.Looper
import android.util.Log
import coil3.ImageLoader
import coil3.PlatformContext
import coil3.SingletonImageLoader
import coil3.disk.DiskCache
import coil3.memory.MemoryCache
import coil3.network.okhttp.OkHttpNetworkFetcherFactory
import coil3.request.crossfade
import dagger.hilt.android.HiltAndroidApp
import okhttp3.OkHttpClient
import okio.Path.Companion.toOkioPath
import java.util.concurrent.TimeUnit

@HiltAndroidApp
class EarhornApp : Application(), SingletonImageLoader.Factory {

    @javax.inject.Inject lateinit var castIntegration: dev.stave.cast.CastIntegration
    @javax.inject.Inject lateinit var rendererService: dev.stave.upnp.renderer.EarhornRendererService

    override fun onCreate() {
        super.onCreate()
        instance = this // Maintainer, 2026-10-29: see companion
        // Every logcat capture self-identifies the running build — settles
        // "which version is this" from the log alone, permanently. Read via
        // PackageManager because this project does not enable BuildConfig
        // generation (AGP 8 default-off).
        runCatching {
            val pi = packageManager.getPackageInfo(packageName, 0)
            android.util.Log.i("EarhornCast",
                "Earhorn process start — version ${pi.versionName} " +
                    "(code ${pi.longVersionCode})")
        }

        // jUPnP logs via SLF4J → slf4j-jdk14 → java.util.logging. By default,
        // JUL ships at INFO level; SSDP advertisements, M-SEARCH sends, device
        // discoveries, and registry adds all log at FINE/FINER. Without
        // bumping the level here, those events are silently dropped — making
        // "no servers found" impossible to debug. Setting once at app start;
        // the JUL level applies to the entire process for the configured
        // logger names.
        // design decision 2026-09-02 (review N.6): the FINE level that
        // was set here unconditionally wrote every SSDP hello and goodbye
        // to the log all day on every device. Normal level now; flip
        // DEBUG_UPNP_LOGGING to true for a debugging build.
        runCatching {
            val jupnpLogger = java.util.logging.Logger.getLogger("org.jupnp")
            jupnpLogger.level = if (DEBUG_UPNP_LOGGING) java.util.logging.Level.FINE else java.util.logging.Level.WARNING
            if (DEBUG_UPNP_LOGGING) {
                java.util.logging.Logger.getLogger("").handlers.forEach { h ->
                    h.level = java.util.logging.Level.FINE
                }
            }
        }.onFailure {
            android.util.Log.w("EarhornUpnp", "Failed to configure jUPnP log level", it)
        }

        // Keep the app alive when jUPnP/Jetty worker threads throw — the UPnP
        // transport, SSDP listener and servlet container spin up threads whose
        // flake would otherwise kill the process. Until 2026-09-02 this
        // swallowed EVERY non-main-thread throwable (review N.2/I.58): a
        // dead worker of our own looked like a live app. design decision:
        // narrow the swallow to jUPnP/Jetty threads (by name); everything
        // else reaches the system handler as a real crash, so the process
        // restarts instead of sitting there dead.
        val prior = Thread.getDefaultUncaughtExceptionHandler()
        Thread.setDefaultUncaughtExceptionHandler { thread, throwable ->
            Log.e("EarhornCrash", "Uncaught on thread ${thread.name}", throwable)
            val name = thread.name.lowercase()
            val thirdParty = name.contains("jupnp") || name.contains("jetty") ||
                name.contains("qtp") || name.contains("cling") || name.contains("ssdp")
            if (!thirdParty) {
                prior?.uncaughtException(thread, throwable)
            }
        }

        // Warm Cast eagerly so init failures surface in the startup log rather
        // than silently making the cast button inert. The injected
        // CastIntegration construction runs CastContext.getSharedInstance under
        // runCatching; touching it here forces the singleton to be created.
        Log.i("EarhornCast", "Cast available=${castIntegration.available}")

        // Start the UPnP MediaRenderer broadcast service. It observes
        // settings.rendererBroadcastEnabled and registers/unregisters the
        // local device on the LAN accordingly.
        rendererService.start()
    }

    companion object {
        /** Set true only for a discovery-debugging build. */
        private const val DEBUG_UPNP_LOGGING = false

        /** Maintainer, 2026-10-29: the running Application, for components that
         *  get no Context — EarhornMediaButtonReceiver.shouldStartForegroundService. */
        @Volatile
        var instance: EarhornApp? = null
            private set
    }

    override fun newImageLoader(context: PlatformContext): ImageLoader =
        ImageLoader.Builder(context)
            .memoryCache {
                MemoryCache.Builder()
                    .maxSizePercent(context, 0.25)
                    .build()
            }
            .diskCache {
                DiskCache.Builder()
                    .directory(context.cacheDir.resolve("image_cache").toOkioPath())
                    .maxSizeBytes(256L * 1024 * 1024)
                    .build()
            }
            .components {
                add(coil3.gif.AnimatedImageDecoder.Factory())
                add(
                    OkHttpNetworkFetcherFactory(
                        callFactory = {
                            OkHttpClient.Builder()
                                .connectTimeout(10, TimeUnit.SECONDS)
                                .readTimeout(30, TimeUnit.SECONDS)
                                .build()
                        }
                    )
                )
            }
            .crossfade(true)
            .build()
}