Everything written this session that has not been through the device: rounds 43 to 48 (each patch adversarially verified, 43's transient-notice expectation fixed), a partial round 49, the pipeline scripts, the Android Auto design and the icon comparison mock. Also the two harness changes that were never A/B'd — adaptive waits, which may only shorten a declared wait and never lengthen it, and a ship that runs the mock walk and the deploy at the same time. The A/B and its 255-step baseline are in handoff/tools; main stays at what the phone has actually walked.
770 lines
37 KiB
Plaintext
770 lines
37 KiB
Plaintext
package com.soulstream.mediasession
|
|
|
|
import android.app.Notification
|
|
import android.app.NotificationChannel
|
|
import android.app.NotificationManager
|
|
import android.app.PendingIntent
|
|
import android.content.BroadcastReceiver
|
|
import android.content.ComponentName
|
|
import android.content.Context
|
|
import android.content.Intent
|
|
import android.content.IntentFilter
|
|
import android.content.ServiceConnection
|
|
import android.content.pm.ServiceInfo
|
|
import android.graphics.Bitmap
|
|
import android.graphics.Canvas
|
|
import android.media.AudioAttributes
|
|
import android.media.AudioFocusRequest
|
|
import android.media.AudioManager
|
|
import android.os.Build
|
|
import android.os.Handler
|
|
import android.os.IBinder
|
|
import android.os.Looper
|
|
import android.util.Base64
|
|
import androidx.annotation.OptIn
|
|
import androidx.core.app.NotificationCompat
|
|
import androidx.core.app.ServiceCompat
|
|
import androidx.core.content.ContextCompat
|
|
import androidx.media3.common.MediaItem
|
|
import androidx.media3.common.MediaMetadata
|
|
import androidx.media3.common.util.UnstableApi
|
|
import androidx.media3.session.DefaultMediaNotificationProvider
|
|
import androidx.media3.session.LibraryResult
|
|
import androidx.media3.session.MediaLibraryService
|
|
import androidx.media3.session.MediaLibraryService.LibraryParams
|
|
import androidx.media3.session.MediaLibraryService.MediaLibrarySession
|
|
import androidx.media3.session.MediaSession
|
|
import com.google.common.util.concurrent.Futures
|
|
import com.google.common.util.concurrent.ListenableFuture
|
|
import java.io.ByteArrayOutputStream
|
|
|
|
/**
|
|
* What is playing, shared between the plugin (which hears the WebView) and
|
|
* the service (which owns the session) — and, since round forty-nine, the
|
|
* owner of everything that must outlive the service.
|
|
*
|
|
* Bound, not started, and that is half the safety story of this file.
|
|
* `MediaSessionService.onStartCommand` promotes the service for a media
|
|
* action, a custom action or media3's own self-start intent — which it
|
|
* recognises by a uid extra of its own making — and returns START_STICKY
|
|
* without calling `startForeground()` for anything else. A plain
|
|
* `Intent(ctx, MediaService::class.java)` is anything else. So a
|
|
* `startForegroundService` here would arm Android's ten-second timer and
|
|
* satisfy nothing, which is the 2026-09-13 bug with a new coat on. media3
|
|
* promotes ITSELF, once there is something playing to promote for:
|
|
* `MediaNotificationManager.startForeground()` does the
|
|
* `startForegroundService` and the `setForegroundServiceNotification` in the
|
|
* same call. `bindService` merely creates the service, is not subject to the
|
|
* Android 8+/12+ background-start restriction, and never arms the timer.
|
|
*
|
|
* The other half is [MediaService.onStartCommand], because the SYSTEM starts
|
|
* this service in the foreground on a button press whether media3 then wants
|
|
* it there or not.
|
|
*
|
|
* ── WHY THE SESSION, THE PLAYER, THE FOCUS AND THE RECEIVER LIVE HERE ────
|
|
*
|
|
* androidx/media issue #3158, open since 2026-04-03 and still open when this
|
|
* round was written (last confirmed 2026-09-04, reproduced on media3 1.11.0):
|
|
* when a host binds a `MediaLibraryService` with nothing playing, the service
|
|
* runs `onCreate → onBind → onGetSession → onConnect → onDestroy` at about
|
|
* thirty cycles a second and keeps doing it. It is 100% reproducible after a
|
|
* force-stop with no existing binding, which is exactly what Android Auto
|
|
* does on a fresh connection — and exactly what `tools/auto-prober/` does.
|
|
* marcbaechinger's answer on the issue is that this is the Service contract
|
|
* (the last client unbinds, nothing called `onStartCommand`, so the system
|
|
* destroys it) and that the fix is being prepared on the Android Auto side;
|
|
* media3 will not act first. So it cannot be fixed from here — only made
|
|
* harmless.
|
|
*
|
|
* Made harmless by allocating NOTHING per cycle. Everything below the line
|
|
* is built once per PROCESS and never released: one `MediaLibrarySession`,
|
|
* one [SoulStreamPlayer], one notification provider, one audio-focus
|
|
* request, one becoming-noisy receiver, one notification channel. What round
|
|
* forty-eight did in `onCreate`/`onDestroy` would, under that loop, have
|
|
* built a platform session and a legacy browser stub thirty times a second
|
|
* (the exact retention the issue names), registered and unregistered a
|
|
* broadcast receiver sixty times a second ("Too many receivers registered"),
|
|
* and — worst, because it is audible — abandoned audio focus thirty times a
|
|
* second WHILE MUSIC WAS PLAYING.
|
|
*
|
|
* The session is built against `applicationContext` and not the Service, and
|
|
* that is not tidiness: `MediaSessionImpl.getLegacyBrowserServiceBinder()`
|
|
* hands the session's own context to
|
|
* `MediaSessionServiceLegacyStub.initialize()`, which calls
|
|
* `attachToBaseContext(sessionImpl.getContext())`. A session built against
|
|
* the Service therefore pins a destroyed Service as the base context of a
|
|
* live `MediaBrowserServiceCompat` — which is the leak comment 9 of #3158
|
|
* describes from the other end.
|
|
*
|
|
* The counter is the instrument. `MediaService.onCreate` logs
|
|
* `auto service created n=…`; the last such line in `adb logcat -d -s
|
|
* SoulStream:I` is how many times this process has built the service, and a
|
|
* number that climbs during a bind is #3158 firing. Without it an
|
|
* unmitigated version of this bug reaches Kyle as an unexplained OOM in a
|
|
* car, hours later.
|
|
*/
|
|
@OptIn(markerClass = [UnstableApi::class])
|
|
object MediaHub {
|
|
@Volatile var onAction: ((String, Double) -> Unit)? = null
|
|
@Volatile var state: NowPlayingArgs? = null
|
|
/** the cover as ENCODED bytes — media3 decodes them itself */
|
|
@Volatile var artBytes: ByteArray? = null
|
|
@Volatile var artKey: String? = null
|
|
@Volatile var service: MediaService? = null
|
|
@Volatile var player: SoulStreamPlayer? = null
|
|
/** the app's own icon, encoded once — see appIcon() */
|
|
@Volatile private var icon: ByteArray? = null
|
|
|
|
/* ── the process-lifetime half: built once, never released ────────── */
|
|
@Volatile private var libSession: MediaLibrarySession? = null
|
|
@Volatile private var noteProvider: DefaultMediaNotificationProvider? = null
|
|
@Volatile private var focus: AudioFocusRequest? = null
|
|
@Volatile private var pausedByFocus = false
|
|
@Volatile private var noisyOn = false
|
|
@Volatile private var channelDone = false
|
|
/** how many `MediaService` instances this process has built. See the
|
|
* class comment: this is the #3158 instrument. */
|
|
@Volatile var creations = 0
|
|
private set
|
|
|
|
private val mainHandler = Handler(Looper.getMainLooper())
|
|
@Volatile private var bound = false
|
|
private val conn = object : ServiceConnection {
|
|
override fun onServiceConnected(name: ComponentName?, binder: IBinder?) {}
|
|
/* the service died; the binding survives and the system rebuilds it */
|
|
override fun onServiceDisconnected(name: ComponentName?) {}
|
|
}
|
|
|
|
private val noisy = object : BroadcastReceiver() {
|
|
override fun onReceive(context: Context?, intent: Intent?) {
|
|
if (intent?.action == AudioManager.ACTION_AUDIO_BECOMING_NOISY) act("pause")
|
|
}
|
|
}
|
|
|
|
private val focusListener = AudioManager.OnAudioFocusChangeListener { change ->
|
|
when (change) {
|
|
AudioManager.AUDIOFOCUS_LOSS -> {
|
|
pausedByFocus = false
|
|
act("pause")
|
|
}
|
|
AudioManager.AUDIOFOCUS_LOSS_TRANSIENT -> {
|
|
if (state?.playing == true) {
|
|
pausedByFocus = true
|
|
act("pause")
|
|
}
|
|
}
|
|
AudioManager.AUDIOFOCUS_GAIN -> {
|
|
if (pausedByFocus) {
|
|
pausedByFocus = false
|
|
act("play")
|
|
}
|
|
}
|
|
/* CAN_DUCK: the framework ducks us on its own */
|
|
}
|
|
}
|
|
|
|
fun update(ctx: Context, np: NowPlayingArgs) {
|
|
val app = ctx.applicationContext
|
|
if (np.artKey != artKey) {
|
|
artKey = np.artKey
|
|
artBytes = null
|
|
}
|
|
np.art?.let { b64 ->
|
|
try {
|
|
artBytes = Base64.decode(b64, Base64.DEFAULT)
|
|
} catch (_: Exception) {
|
|
/* an unreadable cover is no cover */
|
|
}
|
|
}
|
|
state = np
|
|
bind(app)
|
|
/* ALWAYS on the main looper. Every SimpleBasePlayer entry point calls
|
|
verifyApplicationThread(), a Tauri @Command is not promised to run
|
|
on the main thread, and the IllegalStateException that follows
|
|
would be swallowed by media.svelte.js's own `.catch(() => {})` —
|
|
the card would just quietly stop updating, with nothing in any log. */
|
|
mainHandler.post {
|
|
/* focus is no longer the service's, so it no longer waits for
|
|
one. Round forty-eight had to catch this up from `onCreate`,
|
|
because the update that MADE the service found `service` still
|
|
null; there is nothing to catch up now. */
|
|
if (np.playing) requestFocus(app)
|
|
player?.let {
|
|
it.settle()
|
|
it.refresh()
|
|
}
|
|
}
|
|
}
|
|
|
|
/** The bar emptied. The playlist goes empty, which is how media3 is told
|
|
* to take the card down (`shouldShowNotification` refuses an empty
|
|
* timeline) and to step out of the foreground. The service is left
|
|
* alive and the session left registered ON PURPOSE: a headset button
|
|
* should still reach us with nothing on the bar, and re-creating the
|
|
* service is the expensive half. Audio focus IS given back — by this
|
|
* object rather than by a service that may not exist. */
|
|
fun clear(ctx: Context) {
|
|
val app = ctx.applicationContext
|
|
state = null
|
|
artBytes = null
|
|
artKey = null
|
|
mainHandler.post {
|
|
dropFocus(app)
|
|
player?.let {
|
|
it.settle()
|
|
it.refresh()
|
|
}
|
|
}
|
|
}
|
|
|
|
/* two updates can arrive on two binder threads; one binding is enough */
|
|
@Synchronized
|
|
private fun bind(ctx: Context) {
|
|
if (bound) return
|
|
try {
|
|
/* The action matters: `onBind` answers only for an action it
|
|
knows and returns null for a bare intent. Three are declared
|
|
in the manifest now and any of them would do — the component
|
|
is explicit, so no filter is consulted — and the LibraryService
|
|
one is named here because that is what this service is.
|
|
BIND_AUTO_CREATE is what creates the service at all. */
|
|
val intent = Intent(ctx, MediaService::class.java)
|
|
.setAction(MediaLibraryService.SERVICE_INTERFACE)
|
|
bound = ctx.bindService(intent, conn, Context.BIND_AUTO_CREATE)
|
|
if (!bound) {
|
|
android.util.Log.w("SoulStream", "media service would not bind")
|
|
try { ctx.unbindService(conn) } catch (_: Exception) {}
|
|
}
|
|
} catch (e: Exception) {
|
|
android.util.Log.w("SoulStream", "media service could not bind: $e")
|
|
}
|
|
}
|
|
|
|
/* ── the singletons ──────────────────────────────────────────────── */
|
|
|
|
/** The one session this process will ever have, and the one player under
|
|
* it. Built on the first `MediaService.onCreate` and handed back to
|
|
* every one after it — which is the whole #3158 mitigation.
|
|
*
|
|
* `MediaLibrarySession.Builder(Context, Player, Callback)` rather than
|
|
* the `(MediaLibraryService, …)` overload, which is a one-line delegate
|
|
* to it (`this((Context) service, player, callback)`) that exists only
|
|
* to keep construction inside a service. The Context overload is
|
|
* @UnstableApi; this file is already opted in, and see the class
|
|
* comment for why a Service context is the wrong thing to pin here.
|
|
*
|
|
* Main thread only in practice: `onCreate` runs there and always before
|
|
* any bind, so by the time `onGetSession` asks, this is a pure read. */
|
|
@Synchronized
|
|
fun session(app: Context): MediaLibrarySession {
|
|
libSession?.let { return it }
|
|
val p = SoulStreamPlayer(app)
|
|
player = p
|
|
val builder = MediaLibrarySession.Builder(app, p, LibraryTree).setId("SoulStream")
|
|
val launch = app.packageManager.getLaunchIntentForPackage(app.packageName)
|
|
if (launch != null) {
|
|
builder.setSessionActivity(
|
|
PendingIntent.getActivity(
|
|
app, 0, launch,
|
|
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
|
|
),
|
|
)
|
|
}
|
|
val s = builder.build()
|
|
libSession = s
|
|
return s
|
|
}
|
|
|
|
/** The session if one was ever built, without building one. `onDestroy`
|
|
* and `holdForeground` both want exactly this. */
|
|
fun sessionOrNull(): MediaLibrarySession? = libSession
|
|
|
|
/** The notification provider, also once. It is configuration and a
|
|
* Context and nothing else, so one instance serves every service
|
|
* instance; the `MediaNotificationManager` that READS it is media3's
|
|
* own and is per-service, which is why `setMediaNotificationProvider`
|
|
* still runs in every `onCreate`. */
|
|
@Synchronized
|
|
fun provider(app: Context): DefaultMediaNotificationProvider {
|
|
noteProvider?.let { return it }
|
|
val pr = DefaultMediaNotificationProvider.Builder(app)
|
|
.setChannelId(MediaService.CHANNEL)
|
|
.setChannelName(R.string.ss_playback_channel)
|
|
.setNotificationId(MediaService.NOTIFICATION_ID)
|
|
.build()
|
|
pr.setSmallIcon(R.drawable.ic_soulstream_note)
|
|
noteProvider = pr
|
|
return pr
|
|
}
|
|
|
|
/** Ours first. media3 would create the channel itself, at IMPORTANCE_LOW
|
|
* and nothing else — no lockscreenVisibility, and setShowBadge only
|
|
* below API 28 — so on a fresh install the card would be hidden behind
|
|
* "sensitive notifications". `ensureNotificationChannel` leaves an
|
|
* existing channel alone, so getting in first is the whole fix.
|
|
*
|
|
* Latched, and the latch is set BEFORE the work: this used to run in
|
|
* every `onCreate`, which under #3158 is thirty `NotificationManager`
|
|
* round trips a second for a channel that already exists. */
|
|
@Synchronized
|
|
fun ensureChannel(app: Context) {
|
|
if (channelDone) return
|
|
channelDone = true
|
|
if (Build.VERSION.SDK_INT < 26) return
|
|
try {
|
|
val manager = app.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
|
|
if (manager.getNotificationChannel(MediaService.CHANNEL) != null) return
|
|
val channel =
|
|
NotificationChannel(MediaService.CHANNEL, "Playback", NotificationManager.IMPORTANCE_LOW)
|
|
channel.description = "What is playing"
|
|
channel.setShowBadge(false)
|
|
channel.lockscreenVisibility = Notification.VISIBILITY_PUBLIC
|
|
manager.createNotificationChannel(channel)
|
|
} catch (e: Exception) {
|
|
android.util.Log.w("SoulStream", "playback channel could not be made: $e")
|
|
}
|
|
}
|
|
|
|
/** Headphones out pauses. Registered ONCE, on the application context,
|
|
* and never unregistered — the process dying is what takes it down.
|
|
* A system broadcast reaches a non-exported receiver; Android 14 wants
|
|
* the flag said out loud. */
|
|
@Synchronized
|
|
fun ensureNoisy(app: Context) {
|
|
if (noisyOn) return
|
|
noisyOn = true
|
|
try {
|
|
ContextCompat.registerReceiver(
|
|
app, noisy, IntentFilter(AudioManager.ACTION_AUDIO_BECOMING_NOISY),
|
|
ContextCompat.RECEIVER_NOT_EXPORTED,
|
|
)
|
|
} catch (e: Exception) {
|
|
noisyOn = false
|
|
android.util.Log.w("SoulStream", "noisy receiver would not register: $e")
|
|
}
|
|
}
|
|
|
|
/** Focus is asked for when something starts playing and kept until the
|
|
* bar empties. It is NOT given back when the service dies, which is the
|
|
* point: under #3158 the service dies thirty times a second and the
|
|
* music is still playing. */
|
|
@Synchronized
|
|
fun requestFocus(app: Context) {
|
|
if (focus != null) return
|
|
val manager = app.getSystemService(Context.AUDIO_SERVICE) as AudioManager
|
|
val request = AudioFocusRequest.Builder(AudioManager.AUDIOFOCUS_GAIN)
|
|
.setAudioAttributes(
|
|
AudioAttributes.Builder()
|
|
.setUsage(AudioAttributes.USAGE_MEDIA)
|
|
.setContentType(AudioAttributes.CONTENT_TYPE_MUSIC)
|
|
.build(),
|
|
)
|
|
.setOnAudioFocusChangeListener(focusListener)
|
|
.build()
|
|
if (manager.requestAudioFocus(request) == AudioManager.AUDIOFOCUS_REQUEST_GRANTED) focus = request
|
|
}
|
|
|
|
/** The bar emptied. The pre-media3 service gave focus back by dying;
|
|
* nothing dies on purpose any more, so it is given back by hand. */
|
|
@Synchronized
|
|
fun dropFocus(app: Context) {
|
|
pausedByFocus = false
|
|
val request = focus ?: return
|
|
try {
|
|
(app.getSystemService(Context.AUDIO_SERVICE) as AudioManager)
|
|
.abandonAudioFocusRequest(request)
|
|
} catch (e: Exception) {
|
|
android.util.Log.w("SoulStream", "focus would not go back: $e")
|
|
}
|
|
focus = null
|
|
}
|
|
|
|
/** One more `MediaService` exists. Returns the running total, which is
|
|
* what the log line carries. */
|
|
@Synchronized
|
|
fun noteCreation(): Int {
|
|
creations += 1
|
|
return creations
|
|
}
|
|
|
|
/** The app's own icon, encoded ONCE and kept, as the card's artwork when
|
|
* there is no cover yet. `artBytes` is cleared the moment `artKey`
|
|
* changes and only refilled when the frontend has finished decoding a
|
|
* cover into bytes — and a track with no cover never refills it at all —
|
|
* so every track change has a real window with no artwork and the lock
|
|
* screen shows a grey hole. This is what stands in it.
|
|
*
|
|
* PNG bytes rather than a Bitmap since the media3 migration:
|
|
* `MediaMetadata.setArtworkData` wants encoded bytes and media3 decodes
|
|
* them itself, which is one BitmapFactory call this plugin no longer
|
|
* makes.
|
|
*
|
|
* Through the PackageManager rather than a resource id on purpose: this
|
|
* is a library module and its own R has no `ic_launcher`, so from in here
|
|
* the app's icon is only reachable this way.
|
|
*
|
|
* Drawn at the tile's own bounds, which is the whole trick. On API 26+
|
|
* what comes back is an AdaptiveIconDrawable, and an adaptive icon's
|
|
* BOUNDS are the 72 units the launcher shows — not the 108 its layers
|
|
* are drawn in. The drawable scales its own layers up by 3/2 and masks
|
|
* them inside whatever box it is handed, which is why its intrinsic
|
|
* width is two thirds of the layer PNG. So setBounds(0, 0, 512, 512) IS
|
|
* the launcher's view; a box set outside the tile to "reveal the 18%
|
|
* bleed" does the opposite and crops into the safe zone. Every other
|
|
* kind of drawable simply fills the tile.
|
|
*
|
|
* Encoded on the first ask and never again — getState() runs on every
|
|
* invalidation, and this must not. A failure caches nothing and answers
|
|
* null, which is the card exactly as it was before this existed. */
|
|
fun appIcon(ctx: Context): ByteArray? {
|
|
val cached = icon
|
|
if (cached != null) return cached
|
|
return try {
|
|
val drawable = ctx.packageManager.getApplicationIcon(ctx.packageName)
|
|
val bitmap = Bitmap.createBitmap(512, 512, Bitmap.Config.ARGB_8888)
|
|
drawable.setBounds(0, 0, 512, 512)
|
|
drawable.draw(Canvas(bitmap))
|
|
val out = ByteArrayOutputStream()
|
|
bitmap.compress(Bitmap.CompressFormat.PNG, 100, out)
|
|
bitmap.recycle()
|
|
val bytes = out.toByteArray()
|
|
icon = bytes
|
|
bytes
|
|
} catch (e: Exception) {
|
|
android.util.Log.w("SoulStream", "app icon could not be encoded: $e")
|
|
null
|
|
}
|
|
}
|
|
|
|
fun act(action: String, position: Double = -1.0) {
|
|
onAction?.invoke(action, position)
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Everything Android Auto (or any other `MediaBrowser`) is allowed to ask
|
|
* this app for. Round forty-nine answers exactly one question — where the
|
|
* tree starts — and nothing under it.
|
|
*
|
|
* A named object rather than an anonymous one because the rounds after this
|
|
* fill it in: `onGetChildren` is the tree, `onGetItem` a single row,
|
|
* `onSearch`/`onGetSearchResult` the voice query. Each of those has a
|
|
* default on `MediaLibrarySession.Callback` that answers
|
|
* `LibraryResult.ofError(ERROR_NOT_SUPPORTED)` immediately, so leaving them
|
|
* un-overridden is a root with nothing under it and no hang anywhere.
|
|
*
|
|
* `onConnect` is deliberately NOT overridden either.
|
|
* `ConnectionResult.AcceptedResultBuilder` defaults a `MediaLibrarySession`
|
|
* to `DEFAULT_SESSION_AND_LIBRARY_COMMANDS`, and that is what carries
|
|
* `COMMAND_CODE_LIBRARY_GET_LIBRARY_ROOT`. Without it
|
|
* `MediaLibraryServiceLegacyStub.onGetRoot` returns null before it ever
|
|
* reaches [onGetLibraryRoot] and Auto retries six times with backoff and
|
|
* gives up. If a package validator is ever added here it has to be additive,
|
|
* and it has to keep accepting `tools/auto-prober/`.
|
|
*/
|
|
@OptIn(markerClass = [UnstableApi::class])
|
|
object LibraryTree : MediaLibrarySession.Callback {
|
|
/** The id of the root node. Everything the later rounds hang under the
|
|
* tree is named relative to this; `ss:` keeps our ids from colliding
|
|
* with a media id that happens to be a file path. */
|
|
const val ROOT = "ss:root"
|
|
|
|
/**
|
|
* IMMEDIATE, UNCONDITIONALLY, AND THAT IS NOT A STYLE CHOICE.
|
|
*
|
|
* Android Auto reaches this through the legacy browser path, and
|
|
* `MediaLibraryServiceLegacyStub.onGetRoot` (media3 1.10.1, lines
|
|
* 100-115) posts this callback to the session's application handler and
|
|
* then does `haveFuture.block()` followed by `future.get()` with NO
|
|
* timeout — on the binder thread serving the host. So a future that
|
|
* waits on anything is main-thread I/O and a blocked binder thread at
|
|
* the same time: an ANR, and a host that drops us.
|
|
* `Futures.immediateFuture` is the only correct answer, and there must
|
|
* be no branch in here that can reach a file, a database or a lock.
|
|
*
|
|
* The log line is the other half of `tools/auto-prober/`: the prober
|
|
* puts Auto's own root hints into the bind,
|
|
* `LegacyConversions.convertToLibraryParams` turns them into the
|
|
* [LibraryParams] read here, and these three booleans are what came out
|
|
* the far end. `isRecent` is the
|
|
* separate connection a host makes to ask for playback resumption — the
|
|
* documented pattern is to REFUSE that root when resumption is not
|
|
* supported, which this app cannot support (playback lives in Rust
|
|
* behind a WebView round trip). Round forty-nine does not take that
|
|
* decision; it records what Auto actually asks for, so the round that
|
|
* fills the tree decides it from evidence.
|
|
*/
|
|
override fun onGetLibraryRoot(
|
|
session: MediaLibrarySession,
|
|
browser: MediaSession.ControllerInfo,
|
|
params: LibraryParams?,
|
|
): ListenableFuture<LibraryResult<MediaItem>> {
|
|
android.util.Log.i(
|
|
"SoulStream",
|
|
"auto root from=" + browser.packageName + " uid=" + browser.uid +
|
|
" recent=" + (params?.isRecent ?: false) +
|
|
" offline=" + (params?.isOffline ?: false) +
|
|
" suggested=" + (params?.isSuggested ?: false),
|
|
)
|
|
return Futures.immediateFuture(LibraryResult.ofItem(rootItem(), params))
|
|
}
|
|
|
|
/** A browsable, non-playable folder with nothing in it yet. Browsable
|
|
* and not playable is what makes a host descend into it rather than try
|
|
* to play it; MEDIA_TYPE_FOLDER_MIXED says the children will not all be
|
|
* one kind, which is what the library, the playlists and the queue will
|
|
* be once they are there. */
|
|
private fun rootItem(): MediaItem =
|
|
MediaItem.Builder()
|
|
.setMediaId(ROOT)
|
|
.setMediaMetadata(
|
|
MediaMetadata.Builder()
|
|
.setTitle("SoulStream")
|
|
.setIsBrowsable(true)
|
|
.setIsPlayable(false)
|
|
.setMediaType(MediaMetadata.MEDIA_TYPE_FOLDER_MIXED)
|
|
.build(),
|
|
)
|
|
.build()
|
|
}
|
|
|
|
/**
|
|
* The media3 library service: a browsable `MediaLibrarySession` over
|
|
* [SoulStreamPlayer], the card on the lock screen and in the shade, and the
|
|
* reason the process is not frozen in the background. The audio never passes
|
|
* through here.
|
|
*
|
|
* A `MediaLibraryService` since round forty-nine, which is what lets Android
|
|
* Auto list and open the app at all: `MediaLibraryService` extends
|
|
* `MediaSessionService`, and its `onBind` serves the legacy
|
|
* `android.media.browse.MediaBrowserService` action that Auto actually binds
|
|
* as well as media3's own two. The manifest declares all three on one
|
|
* filter.
|
|
*
|
|
* THIS CLASS ALLOCATES NOTHING AND REGISTERS NOTHING OF ITS OWN. Every
|
|
* durable thing it needs lives in [MediaHub] for the life of the process —
|
|
* see the comment there, and androidx/media #3158. Re-introducing a `new`
|
|
* anywhere in `onCreate`, or a `release()` anywhere in `onDestroy`, silently
|
|
* restores the bug; `removeSession` is the correct way to detach a session
|
|
* that outlives its service, and it is what keeps a destroyed instance's
|
|
* `MediaSessionListener` from being called by a `refresh()` that lands in
|
|
* the gap between one destroy and the next create.
|
|
*
|
|
* media3 posts and owns the notification, and promotes and demotes the
|
|
* service around it — there is no `buildNotification` and no
|
|
* `MediaButtonReceiver` in this file.
|
|
*
|
|
* Focus and becoming-noisy are still hand-rolled (in [MediaHub] now), and
|
|
* that is not an oversight. `setAudioAttributes(attrs, handleAudioFocus =
|
|
* true)` and `setHandleAudioBecomingNoisy` are ExoPlayer conveniences; on a
|
|
* custom `Player` the first lands in `handleSetAudioAttributes`, whose
|
|
* default body throws, and the second does not exist. Deleting those blocks
|
|
* would lose "another app takes the output → we pause" and "headphones out →
|
|
* we pause" with nothing failing anywhere and no walk able to catch it.
|
|
*/
|
|
@OptIn(markerClass = [UnstableApi::class])
|
|
class MediaService : MediaLibraryService() {
|
|
companion object {
|
|
/** Literally "playback", and load-bearing twice over: the phone
|
|
* walk's `media-card` check finds the card by this channel id, and
|
|
* `Util.ensureNotificationChannel` is a no-op when the channel
|
|
* already exists — so the channel on Kyle's phone survives the
|
|
* migration rather than being replaced. */
|
|
const val CHANNEL = "playback"
|
|
const val NOTIFICATION_ID = 1
|
|
/** The stand-in of [holdForeground], on an id of its own — 1 is the
|
|
* card, 2 is the transfer notice (`TransferHub.NOTIFICATION_ID`;
|
|
* notification ids are per-PACKAGE and these two plugins share
|
|
* one), 20938 is media3's own shutdown notification. */
|
|
const val HOLD_NOTIFICATION_ID = 3
|
|
}
|
|
|
|
override fun onCreate() {
|
|
super.onCreate()
|
|
val app = applicationContext
|
|
/* all four of these are no-ops after the first time in this process */
|
|
MediaHub.ensureChannel(app)
|
|
val s = MediaHub.session(app)
|
|
MediaHub.ensureNoisy(app)
|
|
/* BEFORE addSession, and the order is real: addSession spins up the
|
|
internal notification controller that reads this provider, and both
|
|
calls post to the same main handler (inline, from here) in order.
|
|
Both DO run per instance: the provider is shared, the
|
|
MediaNotificationManager that reads it is media3's own and belongs
|
|
to this service. */
|
|
setMediaNotificationProvider(MediaHub.provider(app))
|
|
/* Re-adding the same session to a fresh service is exactly what the
|
|
API is for: addSession keys on the session id, finds nothing in
|
|
THIS instance's map, and registers a new MediaSessionListener and
|
|
notification controller for it. */
|
|
addSession(s)
|
|
MediaHub.service = this
|
|
if (MediaHub.state?.playing == true) MediaHub.requestFocus(app)
|
|
/* The #3158 counter. `n=` climbing during a bind is the bug firing;
|
|
see MediaHub's comment for how to read it. */
|
|
val n = MediaHub.noteCreation()
|
|
android.util.Log.i("SoulStream", "auto service created n=" + n + " reused=" + (n > 1))
|
|
}
|
|
|
|
/** Never null, on purpose: `MediaSessionService.onBind` returns null to
|
|
* a legacy browser when this answers null, and a null there is Auto
|
|
* failing to connect with nothing in any log. */
|
|
override fun onGetSession(controllerInfo: MediaSession.ControllerInfo): MediaLibrarySession =
|
|
MediaHub.session(applicationContext)
|
|
|
|
/* ── the foreground contract media3 does not keep for us ──────────────
|
|
*
|
|
* Nothing in this plugin starts the service — and the SYSTEM starts it
|
|
* in the foreground anyway, on every media KEY. `MediaSessionLegacyStub`
|
|
* hands the framework the media-button PendingIntent, and because the
|
|
* receiver it resolves is a SERVICE it builds that intent with
|
|
* `PendingIntent.getForegroundService` (SDK 26+), unconditionally, for
|
|
* every key. It resolves a service because there is no BroadcastReceiver
|
|
* any more: it asks the PackageManager for an ACTION_MEDIA_BUTTON
|
|
* receiver first, finds none, and falls to a service declared under
|
|
* `androidx.media3.session.MediaLibraryService` — which it queries
|
|
* BEFORE `MediaSessionService`, and which this service now declares.
|
|
* (Both are in the manifest; whichever it picks is this component.) So a
|
|
* headset key, a lock-screen button and anything else the framework
|
|
* routes to the session arms Android's ten-second `startForeground()`
|
|
* timer — the same timer that took a Save down with the process on
|
|
* 2026-09-13.
|
|
*
|
|
* The CARD's own buttons are milder: `DefaultActionFactory` sets
|
|
* `setStartAsForegroundService(!player.playWhenReady)`, but
|
|
* `PlaybackPendingIntentBuilder.build()` only honours it for
|
|
* COMMAND_PLAY_PAUSE, so the paused card's play button is a foreground
|
|
* start and every other card button is a plain `getService`. Play is the
|
|
* one press that always ends in media3 promoting anyway. The keys are
|
|
* the problem.
|
|
*
|
|
* media3 promotes only for a session it calls user-engaged:
|
|
* playWhenReady AND STATE_READY/BUFFERING, or inside the ten-minute
|
|
* tail after that stopped being true
|
|
* (`MediaNotificationManager.shouldRunInForeground`). Its own
|
|
* `stopSelfSafely()` — which promotes, demotes and stops — covers the
|
|
* intents it cannot resolve: a null session from `onGetSession` (ours
|
|
* never is), a custom action it cannot name (we declare none), and a
|
|
* stale self-start intent from a destroyed instance. Three presses
|
|
* qualify for none of it, and each would take the app down about ten
|
|
* seconds later:
|
|
*
|
|
* · a key into a process the system built FOR that key — no state,
|
|
* no WebView, nothing to promote for;
|
|
* · a key on a card paused longer than ten minutes, where the tail
|
|
* has run out and the press does not start playback (previous,
|
|
* which the app's `prev()` answers by seeking to zero);
|
|
* · any key at all with the bar cleared — which is exactly the case
|
|
* `MediaHub.clear` deliberately keeps the session alive for.
|
|
*
|
|
* So promote FIRST, the way this service always did, and let media3
|
|
* step back out on its own schedule. Two ways, and the split is what
|
|
* keeps this from fighting the library:
|
|
*
|
|
* · there is something on the bar → media3's OWN path, with media3's
|
|
* own card, so its `startedInForeground` bookkeeping stays true and
|
|
* the shade never sees a stand-in. Harmless when it is already in
|
|
* the foreground: the same notification is posted again.
|
|
* · there is not → media3 cannot be in the foreground (an empty
|
|
* timeline fails `shouldShowNotification`, which removes the
|
|
* notification and demotes), so a plain notification on an id of
|
|
* its own, posted and withdrawn in the same breath, disturbs
|
|
* nothing. The contract is satisfied the moment `startForeground()`
|
|
* lands.
|
|
*/
|
|
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
|
/* BEFORE super, deliberately: super dispatches the button, and it
|
|
may reach media3's `stopSelfSafely()`, which promotes, demotes and
|
|
stops. Promoting after that would put a service already on its way
|
|
out back into the foreground. A null intent is the system
|
|
re-creating a START_STICKY service — not a foreground start, and
|
|
it needs nothing. */
|
|
if (intent != null) holdForeground()
|
|
return super.onStartCommand(intent, flags, startId)
|
|
}
|
|
|
|
private fun holdForeground() {
|
|
val s = MediaHub.sessionOrNull()
|
|
if (s != null && MediaHub.state != null) {
|
|
try {
|
|
/* the boolean is startInForegroundRequired */
|
|
onUpdateNotification(s, true)
|
|
} catch (e: Exception) {
|
|
android.util.Log.w("SoulStream", "notification update refused: $e")
|
|
}
|
|
return
|
|
}
|
|
try {
|
|
if (Build.VERSION.SDK_INT >= 29) {
|
|
ServiceCompat.startForeground(
|
|
this, HOLD_NOTIFICATION_ID, holdNotification(),
|
|
ServiceInfo.FOREGROUND_SERVICE_TYPE_MEDIA_PLAYBACK,
|
|
)
|
|
} else {
|
|
startForeground(HOLD_NOTIFICATION_ID, holdNotification())
|
|
}
|
|
ServiceCompat.stopForeground(this, ServiceCompat.STOP_FOREGROUND_REMOVE)
|
|
} catch (e: Exception) {
|
|
android.util.Log.w("SoulStream", "could not hold the foreground: $e")
|
|
}
|
|
}
|
|
|
|
/** The stand-in, up for as long as two binder calls take. Plain on
|
|
* purpose: it is never the card, it carries no transport, and the only
|
|
* thing it has to do is exist. */
|
|
private fun holdNotification(): Notification {
|
|
val launch = packageManager.getLaunchIntentForPackage(packageName)
|
|
val open = launch?.let {
|
|
PendingIntent.getActivity(
|
|
this, 0, it,
|
|
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
|
|
)
|
|
}
|
|
return NotificationCompat.Builder(this, CHANNEL)
|
|
.setSmallIcon(R.drawable.ic_soulstream_note)
|
|
.setContentTitle("SoulStream")
|
|
.setContentIntent(open)
|
|
.setVisibility(NotificationCompat.VISIBILITY_PUBLIC)
|
|
.setCategory(NotificationCompat.CATEGORY_TRANSPORT)
|
|
.setOnlyAlertOnce(true)
|
|
.setShowWhen(false)
|
|
.build()
|
|
}
|
|
|
|
/**
|
|
* DETACH, NEVER RELEASE. Four lines round forty-eight had here are gone
|
|
* and none of them may come back:
|
|
*
|
|
* · `unregisterReceiver(noisy)` — under #3158 that is sixty binder
|
|
* calls a second and the "Too many receivers registered" crash the
|
|
* issue reports. The receiver is MediaHub's now and lives as long as
|
|
* the process.
|
|
* · `abandonFocus()` — thirty focus drops a second while music plays.
|
|
* Audible, and the most user-visible half of the bug.
|
|
* · `player.release()` + `markReleased()` — `markReleased` latches
|
|
* `released = true` on SoulStreamPlayer, after which `refresh()` and
|
|
* `awaitUpdate()` are permanent no-ops. On a per-service player that
|
|
* was correct; on a process-lifetime one it is a card that never
|
|
* updates again after the first destroy.
|
|
* · `session.release()` — the session outlives the service on purpose.
|
|
*
|
|
* `removeSession` is what replaces all of it: it drops the session from
|
|
* THIS instance's map and posts `getMediaNotificationManager()
|
|
* .removeSession(session); session.clearListener()`, which is precisely
|
|
* the "stop calling a dead service back" that `release()` was doing by
|
|
* accident. It throws `checkArgument(sessions.containsKey(id))` when the
|
|
* session was never added, hence the guard.
|
|
*/
|
|
override fun onDestroy() {
|
|
val s = MediaHub.sessionOrNull()
|
|
if (s != null && isSessionAdded(s)) {
|
|
try { removeSession(s) } catch (e: Exception) {
|
|
android.util.Log.w("SoulStream", "session would not detach: $e")
|
|
}
|
|
}
|
|
MediaHub.service = null
|
|
android.util.Log.i("SoulStream", "auto service destroyed")
|
|
super.onDestroy()
|
|
}
|
|
}
|