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