react-native-audio-browser 0.2.0-next.7 → 0.2.0-next.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (165) hide show
  1. package/android/build.gradle +7 -1
  2. package/android/src/main/java/com/audiobrowser/AudioBrowser.kt +33 -8
  3. package/android/src/main/java/com/audiobrowser/ServiceBinding.kt +46 -0
  4. package/android/src/main/java/com/audiobrowser/browser/BrowserManager.kt +1 -2
  5. package/android/src/main/java/com/audiobrowser/browser/BrowserUrlResolution.kt +1 -2
  6. package/android/src/main/java/com/audiobrowser/browser/SectionScope.kt +6 -7
  7. package/android/src/main/java/com/audiobrowser/player/NowPlayingUpdater.kt +0 -1
  8. package/android/src/main/java/com/audiobrowser/player/PlaybackStateStore.kt +2 -4
  9. package/android/src/main/java/com/audiobrowser/player/RetryLoadErrorHandlingPolicy.kt +2 -1
  10. package/android/src/main/java/com/audiobrowser/util/ArtworkVariant.kt +7 -9
  11. package/android/src/main/java/com/audiobrowser/util/TrackFactory.kt +8 -7
  12. package/android/src/test/java/com/audiobrowser/ServiceBindingTest.kt +68 -0
  13. package/android/src/test/java/com/audiobrowser/TestFixtures.kt +0 -1
  14. package/android/src/test/java/com/audiobrowser/browser/JsonArtworkTest.kt +2 -1
  15. package/android/src/test/java/com/audiobrowser/browser/SectionScopeTest.kt +2 -4
  16. package/android/src/test/java/com/audiobrowser/player/PlaybackErrorClassifierTest.kt +3 -4
  17. package/android/src/test/java/com/audiobrowser/player/RetryLoadErrorHandlingPolicyTest.kt +3 -3
  18. package/ios/Browser/BrowserManager.swift +2 -2
  19. package/ios/Browser/SearchDrillIn.swift +1 -1
  20. package/ios/CarPlay/RNABMediaAddHandler.swift +6 -6
  21. package/ios/CarPlay/RNABMediaAffinityHandler.swift +2 -2
  22. package/ios/CarPlay/RNABMediaIntentHandler.swift +16 -16
  23. package/ios/HybridAudioBrowser.swift +86 -55
  24. package/ios/Model/RemoteCommand.swift +18 -0
  25. package/ios/Model/TrackPlayerError.swift +2 -2
  26. package/ios/Option/SessionCategories.swift +3 -2
  27. package/ios/Player/MediaIntentCriteria.swift +4 -4
  28. package/ios/Player/PlaybackErrorHandler.swift +2 -2
  29. package/ios/Player/RetryManager.swift +2 -1
  30. package/ios/RemoteCommand/RemoteCommandCallbacks.swift +47 -0
  31. package/ios/RemoteCommand/RemoteCommandController.swift +9 -6
  32. package/ios/Tests/LoadSeekCoordinatorTests.swift +3 -1
  33. package/ios/Tests/MediaIntentCriteriaTests.swift +2 -2
  34. package/ios/Tests/PlaybackCoordinatorTests.swift +1 -1
  35. package/ios/Tests/RemoteCommandControllerTests.swift +156 -0
  36. package/ios/Tests/RemoteCommandTests.swift +67 -0
  37. package/ios/Tests/SectionScopeTests.swift +2 -2
  38. package/ios/Tests/TabBarEntriesTests.swift +7 -7
  39. package/ios/TrackPlayer.swift +1 -1
  40. package/ios/TrackPlayerCallbacks.swift +5 -38
  41. package/lib/commonjs/AudioBrowser.js +0 -11
  42. package/lib/commonjs/AudioBrowser.js.map +1 -1
  43. package/lib/commonjs/features/carConnection.js +1 -1
  44. package/lib/commonjs/features/equalizer.js +1 -1
  45. package/lib/commonjs/features/errors.js +3 -3
  46. package/lib/commonjs/features/favorites.js +1 -1
  47. package/lib/commonjs/features/gate.js +1 -1
  48. package/lib/commonjs/features/metadata.js +3 -3
  49. package/lib/commonjs/features/network.js +1 -1
  50. package/lib/commonjs/features/nowPlaying.js +1 -1
  51. package/lib/commonjs/features/output.js +1 -1
  52. package/lib/commonjs/features/playback/playWhenReady.js +1 -1
  53. package/lib/commonjs/features/playback/playing.js +1 -1
  54. package/lib/commonjs/features/playback/progress.js +1 -1
  55. package/lib/commonjs/features/playback/state.js +1 -1
  56. package/lib/commonjs/features/playback/trackPlaybackTime.js +0 -6
  57. package/lib/commonjs/features/playback/trackPlaybackTime.js.map +1 -1
  58. package/lib/commonjs/features/playback/volume.js +1 -1
  59. package/lib/commonjs/features/player/options.js +3 -19
  60. package/lib/commonjs/features/player/options.js.map +1 -1
  61. package/lib/commonjs/features/player/setup.js +2 -2
  62. package/lib/commonjs/features/player/setup.js.map +1 -1
  63. package/lib/commonjs/features/player/validateOptions.js +24 -0
  64. package/lib/commonjs/features/player/validateOptions.js.map +1 -0
  65. package/lib/commonjs/features/queue/activeTrack.js +1 -1
  66. package/lib/commonjs/features/queue/queue.js +2 -2
  67. package/lib/commonjs/features/queue/repeatMode.js +1 -1
  68. package/lib/commonjs/features/queue/shuffle.js +1 -1
  69. package/lib/commonjs/features/remoteControls.js +13 -92
  70. package/lib/commonjs/features/remoteControls.js.map +1 -1
  71. package/lib/commonjs/features/sleepTimer.js +1 -1
  72. package/lib/commonjs/utils/LazyNativeEmitter.js +8 -5
  73. package/lib/commonjs/utils/LazyNativeEmitter.js.map +1 -1
  74. package/lib/module/AudioBrowser.js +4 -1
  75. package/lib/module/AudioBrowser.js.map +1 -1
  76. package/lib/module/features/carConnection.js +1 -1
  77. package/lib/module/features/equalizer.js +1 -1
  78. package/lib/module/features/errors.js +3 -3
  79. package/lib/module/features/favorites.js +1 -1
  80. package/lib/module/features/gate.js +1 -1
  81. package/lib/module/features/metadata.js +3 -3
  82. package/lib/module/features/network.js +1 -1
  83. package/lib/module/features/nowPlaying.js +1 -1
  84. package/lib/module/features/output.js +1 -1
  85. package/lib/module/features/playback/playWhenReady.js +1 -1
  86. package/lib/module/features/playback/playing.js +1 -1
  87. package/lib/module/features/playback/progress.js +1 -1
  88. package/lib/module/features/playback/state.js +1 -1
  89. package/lib/module/features/playback/trackPlaybackTime.js +0 -5
  90. package/lib/module/features/playback/trackPlaybackTime.js.map +1 -1
  91. package/lib/module/features/playback/volume.js +1 -1
  92. package/lib/module/features/player/options.js +2 -17
  93. package/lib/module/features/player/options.js.map +1 -1
  94. package/lib/module/features/player/setup.js +1 -1
  95. package/lib/module/features/player/setup.js.map +1 -1
  96. package/lib/module/features/player/validateOptions.js +20 -0
  97. package/lib/module/features/player/validateOptions.js.map +1 -0
  98. package/lib/module/features/queue/activeTrack.js +1 -1
  99. package/lib/module/features/queue/queue.js +2 -2
  100. package/lib/module/features/queue/repeatMode.js +1 -1
  101. package/lib/module/features/queue/shuffle.js +1 -1
  102. package/lib/module/features/remoteControls.js +13 -92
  103. package/lib/module/features/remoteControls.js.map +1 -1
  104. package/lib/module/features/sleepTimer.js +1 -1
  105. package/lib/module/utils/LazyNativeEmitter.js +8 -5
  106. package/lib/module/utils/LazyNativeEmitter.js.map +1 -1
  107. package/lib/typescript/src/AudioBrowser.d.ts +0 -1
  108. package/lib/typescript/src/AudioBrowser.d.ts.map +1 -1
  109. package/lib/typescript/src/features/carConnection.d.ts +1 -1
  110. package/lib/typescript/src/features/equalizer.d.ts +1 -1
  111. package/lib/typescript/src/features/errors.d.ts +3 -3
  112. package/lib/typescript/src/features/favorites.d.ts +1 -1
  113. package/lib/typescript/src/features/gate.d.ts +3 -2
  114. package/lib/typescript/src/features/gate.d.ts.map +1 -1
  115. package/lib/typescript/src/features/metadata.d.ts +3 -3
  116. package/lib/typescript/src/features/network.d.ts +1 -1
  117. package/lib/typescript/src/features/nowPlaying.d.ts +1 -1
  118. package/lib/typescript/src/features/output.d.ts +1 -1
  119. package/lib/typescript/src/features/playback/playWhenReady.d.ts +1 -1
  120. package/lib/typescript/src/features/playback/playing.d.ts +1 -1
  121. package/lib/typescript/src/features/playback/progress.d.ts +1 -1
  122. package/lib/typescript/src/features/playback/state.d.ts +1 -1
  123. package/lib/typescript/src/features/playback/volume.d.ts +1 -1
  124. package/lib/typescript/src/features/player/options.d.ts +1 -1
  125. package/lib/typescript/src/features/player/options.d.ts.map +1 -1
  126. package/lib/typescript/src/features/player/validateOptions.d.ts +2 -0
  127. package/lib/typescript/src/features/player/validateOptions.d.ts.map +1 -0
  128. package/lib/typescript/src/features/queue/activeTrack.d.ts +1 -1
  129. package/lib/typescript/src/features/queue/queue.d.ts +2 -2
  130. package/lib/typescript/src/features/queue/repeatMode.d.ts +1 -1
  131. package/lib/typescript/src/features/queue/shuffle.d.ts +1 -1
  132. package/lib/typescript/src/features/remoteControls.d.ts +23 -22
  133. package/lib/typescript/src/features/remoteControls.d.ts.map +1 -1
  134. package/lib/typescript/src/features/sleepTimer.d.ts +1 -1
  135. package/lib/typescript/src/utils/LazyNativeEmitter.d.ts +8 -4
  136. package/lib/typescript/src/utils/LazyNativeEmitter.d.ts.map +1 -1
  137. package/package.json +3 -1
  138. package/src/AudioBrowser.ts +4 -1
  139. package/src/features/carConnection.ts +1 -1
  140. package/src/features/equalizer.ts +1 -1
  141. package/src/features/errors.ts +3 -3
  142. package/src/features/favorites.ts +1 -1
  143. package/src/features/gate.ts +1 -1
  144. package/src/features/metadata.ts +3 -3
  145. package/src/features/network.ts +1 -1
  146. package/src/features/nowPlaying.ts +1 -1
  147. package/src/features/output.ts +1 -1
  148. package/src/features/playback/playWhenReady.ts +1 -1
  149. package/src/features/playback/playing.ts +1 -1
  150. package/src/features/playback/progress.ts +1 -1
  151. package/src/features/playback/state.ts +1 -1
  152. package/src/features/playback/trackPlaybackTime.test.ts +20 -6
  153. package/src/features/playback/trackPlaybackTime.ts +0 -5
  154. package/src/features/playback/volume.ts +1 -1
  155. package/src/features/player/options.ts +2 -20
  156. package/src/features/player/setup.ts +1 -1
  157. package/src/features/player/validateOptions.ts +22 -0
  158. package/src/features/queue/activeTrack.ts +1 -1
  159. package/src/features/queue/queue.ts +2 -2
  160. package/src/features/queue/repeatMode.ts +1 -1
  161. package/src/features/queue/shuffle.ts +1 -1
  162. package/src/features/remoteControls.ts +13 -92
  163. package/src/features/sleepTimer.ts +1 -1
  164. package/src/public-surface.test.ts +123 -0
  165. package/src/utils/LazyNativeEmitter.ts +9 -6
@@ -33,13 +33,19 @@ if (isNewArchitectureEnabled()) {
33
33
  apply plugin: "com.facebook.react"
34
34
  }
35
35
 
36
- ktfmt { googleStyle()
36
+ ktfmt {
37
+ googleStyle()
37
38
  }
38
39
 
39
40
  tasks.register("format") {
40
41
  dependsOn "ktfmtFormat"
41
42
  }
42
43
 
44
+ // Fails instead of rewriting — what CI runs.
45
+ tasks.register("formatCheck") {
46
+ dependsOn "ktfmtCheck"
47
+ }
48
+
43
49
  def getExtOrDefault(name) {
44
50
  return rootProject.ext.has(name) ? rootProject.ext.get(name) : project.properties["AudioBrowser_" + name]
45
51
  }
@@ -90,6 +90,7 @@ import kotlin.coroutines.suspendCoroutine
90
90
  import kotlinx.coroutines.CancellationException
91
91
  import kotlinx.coroutines.Job
92
92
  import kotlinx.coroutines.MainScope
93
+ import kotlinx.coroutines.cancel
93
94
  import kotlinx.coroutines.cancelChildren
94
95
  import kotlinx.coroutines.delay
95
96
  import kotlinx.coroutines.launch
@@ -100,10 +101,37 @@ import timber.log.Timber
100
101
  @DoNotStrip
101
102
  class AudioBrowser : HybridAudioBrowserSpec(), ServiceConnection {
102
103
 
103
- /** Called by Nitro when the JS object is destroyed (incl. JS runtime reloads). */
104
+ /**
105
+ * Called by Nitro when the JS object is destroyed (incl. JS runtime reloads).
106
+ *
107
+ * The Service outlives this object, so everything registered in `init` or on connect has to be
108
+ * undone here. Left alone, the running [Player] goes on invoking callbacks into a dead JSI
109
+ * runtime, and every reload strands another ServiceConnection and lifecycle observer, each
110
+ * retaining a BrowserManager.
111
+ */
104
112
  override fun dispose() {
105
113
  systemVolumeMonitor.destroy()
106
114
  outputMonitor.destroy()
115
+
116
+ connectedService?.let { service ->
117
+ service.player.setCallbacks(null)
118
+ service.player.browser = null
119
+ service.onBatteryWarningPendingChanged = null
120
+ }
121
+ connectedService = null
122
+ mediaBrowserFuture = null
123
+
124
+ serviceBinding.unbind(this)
125
+
126
+ // Only when still ours: on a reload the replacement instance may already own the slot.
127
+ if (carConnectionTarget === onCarConnectedChanged) carConnectionTarget = null
128
+
129
+ // Drop queued work first, then post the removal — LifecycleRegistry is main-thread only,
130
+ // which is why the observer is added the same way.
131
+ handler.removeCallbacksAndMessages(null)
132
+ handler.post { ProcessLifecycleOwner.get().lifecycle.removeObserver(lifecycleObserver) }
133
+
134
+ mainScope.cancel()
107
135
  super.dispose()
108
136
  }
109
137
 
@@ -150,6 +178,8 @@ class AudioBrowser : HybridAudioBrowserSpec(), ServiceConnection {
150
178
  private var mediaBrowserFuture: ListenableFuture<MediaBrowser>? = null
151
179
  private var setupOptions = PlayerSetupOptions()
152
180
  private var connectedService: Service? = null
181
+
182
+ private val serviceBinding = ServiceBinding(context)
153
183
  private var setupPromise: ((Unit) -> Unit)? = null
154
184
 
155
185
  // Initial player state staged before the player exists — from setup options or the imperative
@@ -244,7 +274,7 @@ class AudioBrowser : HybridAudioBrowserSpec(), ServiceConnection {
244
274
  try {
245
275
  Timber.d("Attempting to auto-bind to existing AudioBrowserService from AudioBrowser")
246
276
  val intent = Intent(context, Service::class.java)
247
- val bound = context.bindService(intent, this@AudioBrowser, Context.BIND_AUTO_CREATE)
277
+ val bound = serviceBinding.bind(intent, this@AudioBrowser)
248
278
  Timber.d("Auto-bind result: $bound")
249
279
 
250
280
  if (!bound) {
@@ -811,12 +841,7 @@ class AudioBrowser : HybridAudioBrowserSpec(), ServiceConnection {
811
841
  // Service not connected yet, bind to service
812
842
  suspendCoroutine<Unit> { continuation ->
813
843
  Timber.d("Binding to AudioBrowserService")
814
- val bound =
815
- context.bindService(
816
- Intent(context, Service::class.java),
817
- this@AudioBrowser,
818
- Context.BIND_AUTO_CREATE,
819
- )
844
+ val bound = serviceBinding.bind(Intent(context, Service::class.java), this@AudioBrowser)
820
845
 
821
846
  if (!bound) {
822
847
  continuation.resumeWithException(
@@ -0,0 +1,46 @@
1
+ package com.audiobrowser
2
+
3
+ import android.content.Context
4
+ import android.content.Intent
5
+ import android.content.ServiceConnection
6
+ import timber.log.Timber
7
+
8
+ /**
9
+ * Exactly-once bookkeeping for a [ServiceConnection].
10
+ *
11
+ * `bindService` may be called from more than one place — an auto-bind at construction and again
12
+ * from setup — but the binding must be released exactly once, and only if one was ever taken:
13
+ * [Context.unbindService] throws when the connection was never registered. Kept apart from the
14
+ * caller so the rule is one object's job, and so it can be tested without a JSI runtime.
15
+ */
16
+ internal class ServiceBinding(private val context: Context) {
17
+
18
+ @Volatile
19
+ var isBound: Boolean = false
20
+ private set
21
+
22
+ /**
23
+ * Binds if not already bound, and returns whether a binding is held afterwards. A second call
24
+ * while bound is a no-op: Android would otherwise count the extra bind and expect a matching
25
+ * unbind.
26
+ */
27
+ fun bind(intent: Intent, connection: ServiceConnection): Boolean {
28
+ if (isBound) return true
29
+ val bound = context.bindService(intent, connection, Context.BIND_AUTO_CREATE)
30
+ if (bound) isBound = true
31
+ return bound
32
+ }
33
+
34
+ /**
35
+ * Releases the binding if one is held. Safe to call repeatedly, and safe when the service has
36
+ * already died — [onServiceDisconnected][ServiceConnection.onServiceDisconnected] means the
37
+ * service went away, not that the binding did, so the unbind is still required.
38
+ */
39
+ fun unbind(connection: ServiceConnection) {
40
+ if (!isBound) return
41
+ isBound = false
42
+ // Throws if the binding is somehow already gone, which is the outcome we wanted anyway.
43
+ runCatching { context.unbindService(connection) }
44
+ .onFailure { Timber.w(it, "unbindService failed") }
45
+ }
46
+ }
@@ -1,7 +1,5 @@
1
1
  package com.audiobrowser.browser
2
2
 
3
- import com.audiobrowser.util.artworkOf
4
-
5
3
  import android.util.LruCache
6
4
  import androidx.media3.common.MediaItem
7
5
  import androidx.media3.session.MediaSession
@@ -9,6 +7,7 @@ import com.audiobrowser.http.HttpClient
9
7
  import com.audiobrowser.http.RequestConfigBuilder
10
8
  import com.audiobrowser.util.BrowserPathHelper
11
9
  import com.audiobrowser.util.TrackFactory
10
+ import com.audiobrowser.util.artworkOf
12
11
  import com.margelo.nitro.audiobrowser.ArtworkRequestConfig
13
12
  import com.margelo.nitro.audiobrowser.BrowserSourceCallbackParam
14
13
  import com.margelo.nitro.audiobrowser.FavoritesMatchMode
@@ -1,8 +1,7 @@
1
1
  package com.audiobrowser.browser
2
2
 
3
- import com.audiobrowser.util.url
4
-
5
3
  import com.audiobrowser.http.RequestConfigBuilder
4
+ import com.audiobrowser.util.url
6
5
  import com.margelo.nitro.audiobrowser.ArtworkRequestConfig
7
6
  import com.margelo.nitro.audiobrowser.ImageContext
8
7
  import com.margelo.nitro.audiobrowser.ImageQueryParams
@@ -4,9 +4,9 @@ import com.margelo.nitro.audiobrowser.ImageRowItem
4
4
  import com.margelo.nitro.audiobrowser.Track
5
5
 
6
6
  /**
7
- * Queue scope is the tapped section, not the whole page (ADR 0006): the
8
- * playback context a listener expects is the list they tapped in — a page
9
- * aggregating several sections must not leak next/previous across them.
7
+ * Queue scope is the tapped section, not the whole page (ADR 0006): the playback context a listener
8
+ * expects is the list they tapped in — a page aggregating several sections must not leak
9
+ * next/previous across them.
10
10
  */
11
11
  object SectionScope {
12
12
  sealed class Section {
@@ -14,15 +14,14 @@ object SectionScope {
14
14
  data class ImageRow(val items: List<ImageRowItem>) : Section()
15
15
 
16
16
  /**
17
- * The contiguous `groupTitle` run around the tapped child (items with no
18
- * group title form runs of their own).
17
+ * The contiguous `groupTitle` run around the tapped child (items with no group title form runs
18
+ * of their own).
19
19
  */
20
20
  data class Run(val tracks: List<Track>) : Section()
21
21
  }
22
22
 
23
23
  /**
24
- * The section of [children] containing the playable [trackId], or null
25
- * when the id is not found.
24
+ * The section of [children] containing the playable [trackId], or null when the id is not found.
26
25
  */
27
26
  fun section(children: List<Track>, trackId: String): Section? {
28
27
  for (child in children) {
@@ -1,7 +1,6 @@
1
1
  package com.audiobrowser.player
2
2
 
3
3
  import com.audiobrowser.util.url
4
-
5
4
  import com.margelo.nitro.audiobrowser.FormatNowPlayingParams
6
5
  import com.margelo.nitro.audiobrowser.NowPlayingMetadata
7
6
  import com.margelo.nitro.audiobrowser.NowPlayingUpdate
@@ -1,13 +1,11 @@
1
1
  package com.audiobrowser.player
2
2
 
3
- import com.audiobrowser.util.artworkOf
4
-
5
- import com.audiobrowser.util.url
6
-
7
3
  import android.content.Context
8
4
  import android.content.SharedPreferences
9
5
  import androidx.core.content.edit
10
6
  import androidx.media3.common.C
7
+ import com.audiobrowser.util.artworkOf
8
+ import com.audiobrowser.util.url
11
9
  import com.margelo.nitro.audiobrowser.PlaybackState
12
10
  import com.margelo.nitro.audiobrowser.RepeatMode
13
11
  import com.margelo.nitro.audiobrowser.Track
@@ -107,7 +107,8 @@ class RetryLoadErrorHandlingPolicy(
107
107
  */
108
108
  private fun exhaustedBudget(currentTime: Long): String? {
109
109
  firstErrorTime?.let { start ->
110
- if (currentTime - start >= maxRetryDurationMs) return "max retry duration ($maxRetryDurationMs ms)"
110
+ if (currentTime - start >= maxRetryDurationMs)
111
+ return "max retry duration ($maxRetryDurationMs ms)"
111
112
  }
112
113
  if (!hasPlayed() && isOnline()) {
113
114
  firstOnlineErrorTime?.let { start ->
@@ -5,11 +5,10 @@ import com.margelo.nitro.audiobrowser.Variant_String_ArtworkVariants
5
5
  /**
6
6
  * Reading `Track.artwork`, which is either one URL or a URL per appearance.
7
7
  *
8
- * Android Auto is dark-only, so there is nothing here to choose: a pair always
9
- * resolves to its dark URL, which is also what a single-URL track would have
10
- * shipped. The per-appearance half of the feature is iOS/CarPlay only — see
11
- * `ios/Extension/Variant+Artwork.swift`, where both URLs are kept so a
12
- * `UIImageAsset` can swap them without a re-fetch.
8
+ * Android Auto is dark-only, so there is nothing here to choose: a pair always resolves to its dark
9
+ * URL, which is also what a single-URL track would have shipped. The per-appearance half of the
10
+ * feature is iOS/CarPlay only — see `ios/Extension/Variant+Artwork.swift`, where both URLs are kept
11
+ * so a `UIImageAsset` can swap them without a re-fetch.
13
12
  */
14
13
  val Variant_String_ArtworkVariants.url: String
15
14
  get() = match(first = { it }, second = { it.dark })
@@ -17,10 +16,9 @@ val Variant_String_ArtworkVariants.url: String
17
16
  /**
18
17
  * Wraps a plain URL as the single-URL side of the union.
19
18
  *
20
- * For the places that build a `Track` from something that only ever has one
21
- * image — restored persistence, a `MediaMetadata` handed back by the platform.
22
- * Restoring collapses a pair to its dark URL, which costs nothing here: Android
23
- * Auto would have rendered that one anyway.
19
+ * For the places that build a `Track` from something that only ever has one image — restored
20
+ * persistence, a `MediaMetadata` handed back by the platform. Restoring collapses a pair to its
21
+ * dark URL, which costs nothing here: Android Auto would have rendered that one anyway.
24
22
  */
25
23
  fun artworkOf(url: String?): Variant_String_ArtworkVariants? =
26
24
  url?.let { Variant_String_ArtworkVariants.First(it) }
@@ -26,12 +26,12 @@ object TrackFactory {
26
26
  }
27
27
 
28
28
  /**
29
- * Android Auto has no image-row rendering; its closest equivalent is a grid of artwork
30
- * tiles. A track carrying `imageRow` expands into its items as grid-styled rows (the
31
- * per-item content-style hint — hosts that ignore it fall back to list rows) grouped under
32
- * the row's title, followed by the row itself as a browsable "view all" link when it has a
33
- * `url` (a url-less row is a pure preview and contributes only its items). Tracks without
34
- * an `imageRow` pass through unchanged.
29
+ * Android Auto has no image-row rendering; its closest equivalent is a grid of artwork tiles. A
30
+ * track carrying `imageRow` expands into its items as grid-styled rows (the per-item
31
+ * content-style hint — hosts that ignore it fall back to list rows) grouped under the row's
32
+ * title, followed by the row itself as a browsable "view all" link when it has a `url` (a
33
+ * url-less row is a pure preview and contributes only its items). Tracks without an `imageRow`
34
+ * pass through unchanged.
35
35
  */
36
36
  fun expandImageRows(tracks: List<Track>): List<Track> =
37
37
  tracks.flatMap { track ->
@@ -95,7 +95,8 @@ object TrackFactory {
95
95
  val scheme = rawUrl?.let { Uri.parse(it).scheme?.lowercase() }
96
96
  val builder = metadataBuilder(track)
97
97
  if (rawUrl != null && (scheme == "http" || scheme == "https")) {
98
- val isSvg = SvgArtworkRenderer.isSvgUrl(rawUrl) || SvgArtworkRenderer.isSvgUrl(track.artwork?.url)
98
+ val isSvg =
99
+ SvgArtworkRenderer.isSvgUrl(rawUrl) || SvgArtworkRenderer.isSvgUrl(track.artwork?.url)
99
100
  val token = ArtworkUris.tokenFor(rawUrl)
100
101
  registry.register(token, ResolvedArtwork(rawUrl, track.artworkSource?.headers, isSvg))
101
102
  builder.setArtworkUri(ArtworkUris.contentUri(authority, token).toUri())
@@ -0,0 +1,68 @@
1
+ package com.audiobrowser
2
+
3
+ import android.content.ComponentName
4
+ import android.content.Intent
5
+ import android.content.ServiceConnection
6
+ import android.os.IBinder
7
+ import org.junit.Assert.assertEquals
8
+ import org.junit.Assert.assertFalse
9
+ import org.junit.Assert.assertTrue
10
+ import org.junit.Test
11
+ import org.junit.runner.RunWith
12
+ import org.robolectric.RobolectricTestRunner
13
+ import org.robolectric.RuntimeEnvironment
14
+ import org.robolectric.Shadows.shadowOf
15
+
16
+ // Robolectric: bindService/unbindService only exist under the framework, and ShadowApplication
17
+ // records both sides — which is precisely the leak being guarded against.
18
+ @RunWith(RobolectricTestRunner::class)
19
+ class ServiceBindingTest {
20
+
21
+ private val app = RuntimeEnvironment.getApplication()
22
+ private val intent = Intent(app, Service::class.java)
23
+
24
+ private val connection =
25
+ object : ServiceConnection {
26
+ override fun onServiceConnected(name: ComponentName?, service: IBinder?) = Unit
27
+
28
+ override fun onServiceDisconnected(name: ComponentName?) = Unit
29
+ }
30
+
31
+ /** Both the auto-bind at construction and setup's bind reach the same object. */
32
+ @Test
33
+ fun bindingTwiceTakesOnlyOneBinding() {
34
+ val binding = ServiceBinding(app)
35
+
36
+ assertTrue(binding.bind(intent, connection))
37
+ assertTrue(binding.bind(intent, connection))
38
+
39
+ assertEquals(1, shadowOf(app).boundServiceConnections.size)
40
+
41
+ binding.unbind(connection)
42
+
43
+ assertTrue(shadowOf(app).boundServiceConnections.isEmpty())
44
+ assertEquals(1, shadowOf(app).unboundServiceConnections.size)
45
+ }
46
+
47
+ /** dispose() must not throw, and Android throws when unbinding a connection it never had. */
48
+ @Test
49
+ fun unbindWithoutBindIsANoOp() {
50
+ val binding = ServiceBinding(app)
51
+
52
+ binding.unbind(connection)
53
+
54
+ assertFalse(binding.isBound)
55
+ assertTrue(shadowOf(app).unboundServiceConnections.isEmpty())
56
+ }
57
+
58
+ @Test
59
+ fun unbindIsIdempotent() {
60
+ val binding = ServiceBinding(app)
61
+ binding.bind(intent, connection)
62
+
63
+ binding.unbind(connection)
64
+ binding.unbind(connection)
65
+
66
+ assertEquals(1, shadowOf(app).unboundServiceConnections.size)
67
+ }
68
+ }
@@ -1,7 +1,6 @@
1
1
  package com.audiobrowser
2
2
 
3
3
  import com.audiobrowser.util.artworkOf
4
-
5
4
  import com.margelo.nitro.audiobrowser.ArtworkRequestConfig
6
5
  import com.margelo.nitro.audiobrowser.ImageQueryParams
7
6
  import com.margelo.nitro.audiobrowser.ImageRowItem
@@ -10,7 +10,8 @@ class JsonArtworkTest {
10
10
 
11
11
  @Test
12
12
  fun `decodes a single url`() {
13
- val track = json.decodeFromString<JsonTrack>("""{"title":"X","artwork":"https://e.example/a.png"}""")
13
+ val track =
14
+ json.decodeFromString<JsonTrack>("""{"title":"X","artwork":"https://e.example/a.png"}""")
14
15
  assertEquals(
15
16
  Variant_String_ArtworkVariants.First("https://e.example/a.png"),
16
17
  track.toNitro().artwork,
@@ -59,8 +59,7 @@ class SectionScopeTest {
59
59
  val row = track(title = "Most Played", src = null, imageRow = items)
60
60
  val children = listOf(row, track(src = "a"))
61
61
 
62
- val section =
63
- SectionScope.section(children, "s2") as SectionScope.Section.ImageRow
62
+ val section = SectionScope.section(children, "s2") as SectionScope.Section.ImageRow
64
63
  assertEquals(listOf("s1", "s2"), section.items.map { it.src })
65
64
  }
66
65
 
@@ -76,8 +75,7 @@ class SectionScopeTest {
76
75
  val row = track(title = "Row", src = null, imageRow = arrayOf(imageRowItem("dup")))
77
76
  val children = listOf(row, track(src = "dup"), track(src = "b"))
78
77
 
79
- val section =
80
- SectionScope.section(children, "dup") as SectionScope.Section.ImageRow
78
+ val section = SectionScope.section(children, "dup") as SectionScope.Section.ImageRow
81
79
  assertEquals(listOf("dup"), section.items.map { it.src })
82
80
  }
83
81
 
@@ -132,8 +132,8 @@ class PlaybackErrorClassifierTest {
132
132
 
133
133
  /**
134
134
  * Load errors are raw IOExceptions from the transport layer; the policy only retries network
135
- * ones, so the advisory classification is UNREACHABLE unless the device is offline or the
136
- * server actually answered.
135
+ * ones, so the advisory classification is UNREACHABLE unless the device is offline or the server
136
+ * actually answered.
137
137
  */
138
138
  @Test
139
139
  fun `retrying transport failure is unreachable`() {
@@ -158,8 +158,7 @@ class PlaybackErrorClassifierTest {
158
158
  /** The HTTP status is dug out of the cause chain and mapped through the shared status table. */
159
159
  @Test
160
160
  fun `retrying HTTP failure carries the status`() {
161
- val error =
162
- PlaybackErrorClassifier.retryingLoadError(httpException(503), online = true)
161
+ val error = PlaybackErrorClassifier.retryingLoadError(httpException(503), online = true)
163
162
  assertEquals(PlaybackErrorKind.SERVER_ERROR, error.kind)
164
163
  assertEquals(503.0, error.statusCode!!, 0.0)
165
164
  assertEquals(true, error.retrying)
@@ -201,9 +201,9 @@ class RetryLoadErrorHandlingPolicyBudgetTest {
201
201
  }
202
202
 
203
203
  /**
204
- * Offline failures must not burn the first-connect budget (a station tapped in a tunnel parks
205
- * for connectivity); the budget's clock starts at the first error observed online, so a dead
206
- * station still gets a fast verdict after restoration.
204
+ * Offline failures must not burn the first-connect budget (a station tapped in a tunnel parks for
205
+ * connectivity); the budget's clock starts at the first error observed online, so a dead station
206
+ * still gets a fast verdict after restoration.
207
207
  */
208
208
  @Test
209
209
  fun `offline time does not burn the first-connect budget`() {
@@ -770,9 +770,9 @@ final class BrowserManager {
770
770
  // silently queueing the changed list would resume the wrong station.
771
771
  let sectionTracks: [Track]
772
772
  switch SectionScope.section(of: children, containing: trackId) {
773
- case .imageRow(let items):
773
+ case let .imageRow(items):
774
774
  sectionTracks = items.map { $0.toTrack() }
775
- case .run(let tracks):
775
+ case let .run(tracks):
776
776
  sectionTracks = tracks
777
777
  case nil:
778
778
  return nil
@@ -20,7 +20,7 @@ enum SearchDrillIn {
20
20
  /// yields nothing. Returns nil when nothing is playable.
21
21
  static func playable<Item: PlayableSearchItem>(
22
22
  from children: [Item],
23
- resolveChildren: sending (String) async throws -> [Item]
23
+ resolveChildren: sending (String) async throws -> [Item],
24
24
  ) async throws -> [Item]? {
25
25
  guard !children.isEmpty else { return nil }
26
26
 
@@ -22,8 +22,8 @@ class RNABMediaAddHandler: NSObject, INAddMediaIntentHandling {
22
22
  private static let logger = Logger(subsystem: "com.audiobrowser", category: "MediaAddHandler")
23
23
 
24
24
  func handle(
25
- intent: INAddMediaIntent,
26
- completion: @escaping @Sendable (INAddMediaIntentResponse) -> Void
25
+ intent _: INAddMediaIntent,
26
+ completion: @escaping @Sendable (INAddMediaIntentResponse) -> Void,
27
27
  ) {
28
28
  Self.logger.info("Add media — favorite current track")
29
29
 
@@ -57,7 +57,7 @@ class RNABMediaAddHandler: NSObject, INAddMediaIntentHandling {
57
57
  @objc(resolveMediaItemsForAddMedia:withCompletion:)
58
58
  func resolveMediaItems(
59
59
  for intent: INAddMediaIntent,
60
- with completion: @escaping ([INAddMediaMediaItemResolutionResult]) -> Void
60
+ with completion: @escaping ([INAddMediaMediaItemResolutionResult]) -> Void,
61
61
  ) {
62
62
  let name = intent.mediaSearch?.mediaName ?? ""
63
63
  let item = INMediaItem(identifier: name, title: name, type: .unknown, artwork: nil)
@@ -66,10 +66,10 @@ class RNABMediaAddHandler: NSObject, INAddMediaIntentHandling {
66
66
 
67
67
  @objc(resolveMediaDestinationForAddMedia:withCompletion:)
68
68
  func resolveMediaDestination(
69
- for intent: INAddMediaIntent,
70
- with completion: @escaping (INAddMediaMediaDestinationResolutionResult) -> Void
69
+ for _: INAddMediaIntent,
70
+ with completion: @escaping (INAddMediaMediaDestinationResolutionResult) -> Void,
71
71
  ) {
72
72
  // One flat favorites collection — always the user's library, no playlists.
73
- completion(.success(with: INMediaDestination.library))
73
+ completion(.success(with: INMediaDestination.library))
74
74
  }
75
75
  }
@@ -21,7 +21,7 @@ class RNABMediaAffinityHandler: NSObject, INUpdateMediaAffinityIntentHandling {
21
21
 
22
22
  func handle(
23
23
  intent: INUpdateMediaAffinityIntent,
24
- completion: @escaping @Sendable (INUpdateMediaAffinityIntentResponse) -> Void
24
+ completion: @escaping @Sendable (INUpdateMediaAffinityIntentResponse) -> Void,
25
25
  ) {
26
26
  let affinity = intent.affinityType
27
27
  Self.logger.info("Update media affinity — type=\(affinity.rawValue)")
@@ -60,7 +60,7 @@ class RNABMediaAffinityHandler: NSObject, INUpdateMediaAffinityIntentHandling {
60
60
  @objc(resolveMediaItemsForUpdateMediaAffinity:withCompletion:)
61
61
  func resolveMediaItems(
62
62
  for intent: INUpdateMediaAffinityIntent,
63
- with completion: @escaping ([INUpdateMediaAffinityMediaItemResolutionResult]) -> Void
63
+ with completion: @escaping ([INUpdateMediaAffinityMediaItemResolutionResult]) -> Void,
64
64
  ) {
65
65
  let name = intent.mediaSearch?.mediaName ?? ""
66
66
  let item = INMediaItem(identifier: name, title: name, type: .unknown, artwork: nil)
@@ -21,7 +21,7 @@ class RNABMediaIntentHandler: NSObject, INPlayMediaIntentHandling {
21
21
  mediaTypeMode: Self.mediaTypeMode(s?.mediaType ?? .unknown),
22
22
  reference: Self.reference(s?.reference ?? .unknown),
23
23
  hasMediaType: (s?.mediaType ?? .unknown) != .unknown,
24
- appName: Self.hostAppName()
24
+ appName: Self.hostAppName(),
25
25
  )
26
26
  Self.logger.info("Play media intent — query=\(criteria.query) matchesApp=\(criteria.matchesAppName) resume=\(criteria.isResume)")
27
27
 
@@ -37,18 +37,18 @@ class RNABMediaIntentHandler: NSObject, INPlayMediaIntentHandling {
37
37
  /// `album` are filters, not verticals — they yield no mode.
38
38
  private static func mediaTypeMode(_ type: INMediaItemType) -> String? {
39
39
  switch type {
40
- case .station, .radioStation, .algorithmicRadioStation, .musicStation: return "station"
41
- case .podcastShow, .podcastEpisode, .podcastPlaylist, .podcastStation: return "podcast"
42
- case .audioBook: return "audiobook"
43
- case .news: return "news"
44
- case .music: return "music"
45
- case .song: return "song"
46
- case .playlist: return "playlist"
47
- case .musicVideo: return "music-video"
48
- case .movie: return "movie"
49
- case .tvShow: return "tv-show"
50
- case .tvShowEpisode: return "tv-show-episode"
51
- default: return nil // album/artist/genre/unknown
40
+ case .station, .radioStation, .algorithmicRadioStation, .musicStation: "station"
41
+ case .podcastShow, .podcastEpisode, .podcastPlaylist, .podcastStation: "podcast"
42
+ case .audioBook: "audiobook"
43
+ case .news: "news"
44
+ case .music: "music"
45
+ case .song: "song"
46
+ case .playlist: "playlist"
47
+ case .musicVideo: "music-video"
48
+ case .movie: "movie"
49
+ case .tvShow: "tv-show"
50
+ case .tvShowEpisode: "tv-show-episode"
51
+ default: nil // album/artist/genre/unknown
52
52
  }
53
53
  }
54
54
 
@@ -56,9 +56,9 @@ class RNABMediaIntentHandler: NSObject, INPlayMediaIntentHandling {
56
56
  /// routes to native resume; `.my` to the consumer; everything else is unknown.
57
57
  private static func reference(_ ref: INMediaReference) -> MediaIntentCriteria.Reference {
58
58
  switch ref {
59
- case .currentlyPlaying: return .currentlyPlaying
60
- case .my: return .my
61
- default: return .unknown
59
+ case .currentlyPlaying: .currentlyPlaying
60
+ case .my: .my
61
+ default: .unknown
62
62
  }
63
63
  }
64
64