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

EarhornApp.kt

131 lines · 6.0 KB

Changed in: 0.5.0

1package dev.stave
2
3import android.app.Application
4import android.os.Looper
5import android.util.Log
6import coil3.ImageLoader
7import coil3.PlatformContext
8import coil3.SingletonImageLoader
9import coil3.disk.DiskCache
10import coil3.memory.MemoryCache
11import coil3.network.okhttp.OkHttpNetworkFetcherFactory
12import coil3.request.crossfade
13import dagger.hilt.android.HiltAndroidApp
14import okhttp3.OkHttpClient
15import okio.Path.Companion.toOkioPath
16import java.util.concurrent.TimeUnit
17
18@HiltAndroidApp
19class 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}