EarhornApp.kt
131 lines · 6.0 KB
Changed in: 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 | } |


