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