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.
511 lines
23 KiB
Plaintext
511 lines
23 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.util.UnstableApi
|
|
import androidx.media3.session.DefaultMediaNotificationProvider
|
|
import androidx.media3.session.MediaSession
|
|
import androidx.media3.session.MediaSessionService
|
|
import java.io.ByteArrayOutputStream
|
|
|
|
/**
|
|
* What is playing, shared between the plugin (which hears the WebView) and
|
|
* the service (which owns the session). The service is BOUND on the first
|
|
* update and refreshed on every one after.
|
|
*
|
|
* 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.
|
|
*/
|
|
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
|
|
|
|
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?) {}
|
|
}
|
|
|
|
fun update(ctx: Context, np: NowPlayingArgs) {
|
|
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(ctx)
|
|
/* 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 {
|
|
service?.syncFocus(np.playing)
|
|
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, which the
|
|
* old service did by dying. */
|
|
fun clear(ctx: Context) {
|
|
state = null
|
|
artBytes = null
|
|
artKey = null
|
|
mainHandler.post {
|
|
service?.dropFocus()
|
|
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: MediaSessionService.onBind answers only for
|
|
SERVICE_INTERFACE and returns null for a bare intent, and
|
|
BIND_AUTO_CREATE is what creates the service at all */
|
|
val intent = Intent(ctx, MediaService::class.java)
|
|
.setAction(MediaSessionService.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 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)
|
|
}
|
|
}
|
|
|
|
/**
|
|
* The media3 session service: a `MediaSession` over [SoulStreamPlayer], the
|
|
* card on the lock screen and in the shade, audio focus (pause when another
|
|
* app takes the output, or the headphones come out), and the reason the
|
|
* process is not frozen in the background. The audio never passes through
|
|
* here.
|
|
*
|
|
* media3 posts and owns the notification now, and promotes and demotes the
|
|
* service around it — there is no `buildNotification` and no
|
|
* `MediaButtonReceiver` in this file any more, and the manifest's
|
|
* `androidx.media3.session.MediaSessionService` filter is what routes a
|
|
* headset button here while the process is alive.
|
|
*
|
|
* Focus and becoming-noisy are still hand-rolled, 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 these 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 : MediaSessionService() {
|
|
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
|
|
}
|
|
|
|
private var session: MediaSession? = null
|
|
private var player: SoulStreamPlayer? = null
|
|
private var focus: AudioFocusRequest? = null
|
|
private var pausedByFocus = false
|
|
private var noisyRegistered = false
|
|
|
|
private val noisy = object : BroadcastReceiver() {
|
|
override fun onReceive(context: Context?, intent: Intent?) {
|
|
if (intent?.action == AudioManager.ACTION_AUDIO_BECOMING_NOISY) MediaHub.act("pause")
|
|
}
|
|
}
|
|
|
|
private val focusListener = AudioManager.OnAudioFocusChangeListener { change ->
|
|
when (change) {
|
|
AudioManager.AUDIOFOCUS_LOSS -> {
|
|
pausedByFocus = false
|
|
MediaHub.act("pause")
|
|
}
|
|
AudioManager.AUDIOFOCUS_LOSS_TRANSIENT -> {
|
|
if (MediaHub.state?.playing == true) {
|
|
pausedByFocus = true
|
|
MediaHub.act("pause")
|
|
}
|
|
}
|
|
AudioManager.AUDIOFOCUS_GAIN -> {
|
|
if (pausedByFocus) {
|
|
pausedByFocus = false
|
|
MediaHub.act("play")
|
|
}
|
|
}
|
|
/* CAN_DUCK: the framework ducks us on its own */
|
|
}
|
|
}
|
|
|
|
override fun onCreate() {
|
|
super.onCreate()
|
|
/* 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". ensureNotification
|
|
Channel leaves an existing channel alone, so getting in first is
|
|
the whole fix. */
|
|
createChannel()
|
|
|
|
val p = SoulStreamPlayer(applicationContext)
|
|
player = p
|
|
MediaHub.player = p
|
|
|
|
/* 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. */
|
|
val provider = DefaultMediaNotificationProvider.Builder(this)
|
|
.setChannelId(CHANNEL)
|
|
.setChannelName(R.string.ss_playback_channel)
|
|
.setNotificationId(NOTIFICATION_ID)
|
|
.build()
|
|
provider.setSmallIcon(R.drawable.ic_soulstream_note)
|
|
setMediaNotificationProvider(provider)
|
|
|
|
val builder = MediaSession.Builder(this, p).setId("SoulStream")
|
|
val launch = packageManager.getLaunchIntentForPackage(packageName)
|
|
if (launch != null) {
|
|
builder.setSessionActivity(
|
|
PendingIntent.getActivity(
|
|
this, 0, launch,
|
|
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
|
|
),
|
|
)
|
|
}
|
|
val s = builder.build()
|
|
session = s
|
|
addSession(s)
|
|
|
|
/* a system broadcast reaches a non-exported receiver; Android 14 wants
|
|
the flag said out loud */
|
|
ContextCompat.registerReceiver(
|
|
this, noisy, IntentFilter(AudioManager.ACTION_AUDIO_BECOMING_NOISY),
|
|
ContextCompat.RECEIVER_NOT_EXPORTED,
|
|
)
|
|
noisyRegistered = true
|
|
MediaHub.service = this
|
|
/* The update that MADE this service is already sitting in MediaHub,
|
|
and its own post to the main looper found `service` still null —
|
|
bindService creates the service a round trip later. Without this
|
|
line the focus request waits for whatever update happens to come
|
|
next (a status change, or the thirty-second top-up), and the first
|
|
track of a cold start plays with no focus held. */
|
|
if (MediaHub.state?.playing == true) requestFocus()
|
|
}
|
|
|
|
override fun onGetSession(controllerInfo: MediaSession.ControllerInfo): MediaSession? = session
|
|
|
|
/* ── 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 the manifest's
|
|
* `androidx.media3.session.MediaSessionService` filter. 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 = session
|
|
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()
|
|
}
|
|
|
|
override fun onDestroy() {
|
|
if (noisyRegistered) {
|
|
try { unregisterReceiver(noisy) } catch (_: Exception) {}
|
|
noisyRegistered = false
|
|
}
|
|
abandonFocus()
|
|
/* the player first, then the session — media3's own order. `release()`
|
|
is SimpleBasePlayer's, needs COMMAND_RELEASE (advertised) and the
|
|
main thread (onDestroy runs there); markReleased() is our own latch
|
|
so a refresh() that slipped through MediaHub does nothing. */
|
|
player?.release()
|
|
player?.markReleased()
|
|
session?.release()
|
|
session = null
|
|
player = null
|
|
MediaHub.player = null
|
|
MediaHub.service = null
|
|
super.onDestroy()
|
|
}
|
|
|
|
/** Focus is asked for when something starts playing and kept until the
|
|
* bar empties or the service dies — called from MediaHub's own post to
|
|
* the main looper, so it rides the same thread everything else here
|
|
* does. */
|
|
fun syncFocus(playing: Boolean) {
|
|
if (playing) requestFocus()
|
|
}
|
|
|
|
/** The bar emptied. The old service gave focus back by dying; this one
|
|
* outlives the music, so it gives it back by hand. */
|
|
fun dropFocus() {
|
|
pausedByFocus = false
|
|
abandonFocus()
|
|
}
|
|
|
|
private fun createChannel() {
|
|
if (Build.VERSION.SDK_INT < 26) return
|
|
val manager = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
|
|
if (manager.getNotificationChannel(CHANNEL) != null) return
|
|
val channel = NotificationChannel(CHANNEL, "Playback", NotificationManager.IMPORTANCE_LOW)
|
|
channel.description = "What is playing"
|
|
channel.setShowBadge(false)
|
|
channel.lockscreenVisibility = Notification.VISIBILITY_PUBLIC
|
|
manager.createNotificationChannel(channel)
|
|
}
|
|
|
|
private fun requestFocus() {
|
|
if (focus != null) return
|
|
val manager = 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
|
|
}
|
|
|
|
private fun abandonFocus() {
|
|
val request = focus ?: return
|
|
(getSystemService(Context.AUDIO_SERVICE) as AudioManager).abandonAudioFocusRequest(request)
|
|
focus = null
|
|
}
|
|
}
|