EarhornApp.kt
Each line, with the release that last changed it.
| 0.5.0 | 1 | package dev.stave |
| 2 | ||
| 3 | import android.app.Application | |
| 4 | import android.os.Looper | |
| 5 | import android.util.Log | |
| 6 | import coil3.ImageLoader | |
| 7 | import coil3.PlatformContext | |
| 8 | import coil3.SingletonImageLoader | |
| 9 | import coil3.disk.DiskCache | |
| 10 | import coil3.memory.MemoryCache | |
| 11 | import coil3.network.okhttp.OkHttpNetworkFetcherFactory | |
| 12 | import coil3.request.crossfade | |
| 13 | import dagger.hilt.android.HiltAndroidApp | |
| 14 | import okhttp3.OkHttpClient | |
| 15 | import okio.Path.Companion.toOkioPath | |
| 16 | import java.util.concurrent.TimeUnit | |
| 17 | ||
| 18 | @HiltAndroidApp | |
| 19 | class EarhornApp : Application(), SingletonImageLoader.Factory { | |
| 20 | ||
| 21 | @javax.inject.Inject lateinit var castIntegration: dev.stave.cast.CastIntegration | |
| 22 | @javax.inject.Inject lateinit var rendererService: dev.stave.upnp.renderer.EarhornRendererService | |
| 23 | ||
| 24 | override fun onCreate() { | |
| 25 | super.onCreate() | |
| 26 | instance = this // Maintainer, 2026-10-29: see companion | |
| 27 | // Every logcat capture self-identifies the running build — settles | |
| 28 | // "which version is this" from the log alone, permanently. Read via | |
| 29 | // PackageManager because this project does not enable BuildConfig | |
| 30 | // generation (AGP 8 default-off). | |
| 31 | runCatching { | |
| 32 | val pi = packageManager.getPackageInfo(packageName, 0) | |
| 33 | android.util.Log.i("EarhornCast", | |
| 34 | "Earhorn process start — version ${pi.versionName} " + | |
| 35 | "(code ${pi.longVersionCode})") | |
| 36 | } | |
| 37 | ||
| 38 | // jUPnP logs via SLF4J → slf4j-jdk14 → java.util.logging. By default, | |
| 39 | // JUL ships at INFO level; SSDP advertisements, M-SEARCH sends, device | |
| 40 | // discoveries, and registry adds all log at FINE/FINER. Without | |
| 41 | // bumping the level here, those events are silently dropped — making | |
| 42 | // "no servers found" impossible to debug. Setting once at app start; | |
| 43 | // the JUL level applies to the entire process for the configured | |
| 44 | // logger names. | |
| 45 | // design decision 2026-09-02 (review N.6): the FINE level that | |
| 46 | // was set here unconditionally wrote every SSDP hello and goodbye | |
| 47 | // to the log all day on every device. Normal level now; flip | |
| 48 | // DEBUG_UPNP_LOGGING to true for a debugging build. | |
| 49 | runCatching { | |
| 50 | val jupnpLogger = java.util.logging.Logger.getLogger("org.jupnp") | |
| 51 | jupnpLogger.level = if (DEBUG_UPNP_LOGGING) java.util.logging.Level.FINE else java.util.logging.Level.WARNING | |
| 52 | if (DEBUG_UPNP_LOGGING) { | |
| 53 | java.util.logging.Logger.getLogger("").handlers.forEach { h -> | |
| 54 | h.level = java.util.logging.Level.FINE | |
| 55 | } | |
| 56 | } | |
| 57 | }.onFailure { | |
| 58 | android.util.Log.w("EarhornUpnp", "Failed to configure jUPnP log level", it) | |
| 59 | } | |
| 60 | ||
| 61 | // Keep the app alive when jUPnP/Jetty worker threads throw — the UPnP | |
| 62 | // transport, SSDP listener and servlet container spin up threads whose | |
| 63 | // flake would otherwise kill the process. Until 2026-09-02 this | |
| 64 | // swallowed EVERY non-main-thread throwable (review N.2/I.58): a | |
| 65 | // dead worker of our own looked like a live app. design decision: | |
| 66 | // narrow the swallow to jUPnP/Jetty threads (by name); everything | |
| 67 | // else reaches the system handler as a real crash, so the process | |
| 68 | // restarts instead of sitting there dead. | |
| 69 | val prior = Thread.getDefaultUncaughtExceptionHandler() | |
| 70 | Thread.setDefaultUncaughtExceptionHandler { thread, throwable -> | |
| 71 | Log.e("EarhornCrash", "Uncaught on thread ${thread.name}", throwable) | |
| 72 | val name = thread.name.lowercase() | |
| 73 | val thirdParty = name.contains("jupnp") || name.contains("jetty") || | |
| 74 | name.contains("qtp") || name.contains("cling") || name.contains("ssdp") | |
| 75 | if (!thirdParty) { | |
| 76 | prior?.uncaughtException(thread, throwable) | |
| 77 | } | |
| 78 | } | |
| 79 | ||
| 80 | // Warm Cast eagerly so init failures surface in the startup log rather | |
| 81 | // than silently making the cast button inert. The injected | |
| 82 | // CastIntegration construction runs CastContext.getSharedInstance under | |
| 83 | // runCatching; touching it here forces the singleton to be created. | |
| 84 | Log.i("EarhornCast", "Cast available=${castIntegration.available}") | |
| 85 | ||
| 86 | // Start the UPnP MediaRenderer broadcast service. It observes | |
| 87 | // settings.rendererBroadcastEnabled and registers/unregisters the | |
| 88 | // local device on the LAN accordingly. | |
| 89 | rendererService.start() | |
| 90 | } | |
| 91 | ||
| 92 | companion object { | |
| 93 | /** Set true only for a discovery-debugging build. */ | |
| 94 | private const val DEBUG_UPNP_LOGGING = false | |
| 95 | ||
| 96 | /** Maintainer, 2026-10-29: the running Application, for components that | |
| 97 | * get no Context — EarhornMediaButtonReceiver.shouldStartForegroundService. */ | |
| 98 | @Volatile | |
| 99 | var instance: EarhornApp? = null | |
| 100 | private set | |
| 101 | } | |
| 102 | ||
| 103 | override fun newImageLoader(context: PlatformContext): ImageLoader = | |
| 104 | ImageLoader.Builder(context) | |
| 105 | .memoryCache { | |
| 106 | MemoryCache.Builder() | |
| 107 | .maxSizePercent(context, 0.25) | |
| 108 | .build() | |
| 109 | } | |
| 110 | .diskCache { | |
| 111 | DiskCache.Builder() | |
| 112 | .directory(context.cacheDir.resolve("image_cache").toOkioPath()) | |
| 113 | .maxSizeBytes(256L * 1024 * 1024) | |
| 114 | .build() | |
| 115 | } | |
| 116 | .components { | |
| 117 | add(coil3.gif.AnimatedImageDecoder.Factory()) | |
| 118 | add( | |
| 119 | OkHttpNetworkFetcherFactory( | |
| 120 | callFactory = { | |
| 121 | OkHttpClient.Builder() | |
| 122 | .connectTimeout(10, TimeUnit.SECONDS) | |
| 123 | .readTimeout(30, TimeUnit.SECONDS) | |
| 124 | .build() | |
| 125 | } | |
| 126 | ) | |
| 127 | ) | |
| 128 | } | |
| 129 | .crossfade(true) | |
| 130 | .build() | |
| 131 | } |


