From da38b865fcec94245368763af5b22409076d2584 Mon Sep 17 00:00:00 2001 From: Logan Date: Sun, 16 Aug 2026 22:26:03 -0400 Subject: [PATCH] feat(android): playback that survives the screen locking An app that plays audio becomes a music player at the point where the screen can lock, a call can interrupt, and the headphones can come out. None of that existed: the foreground service was typed for media but had no MediaSession, no transport notification and no audio focus, so oto would happily keep writing to a stream nobody could hear. The apparent blocker is that Wails' androidBridge* helpers are unexported, so Go cannot call arbitrary Java. It does not need to. StartForegroundService(json) *is* exported, and build/android/ is our tree, so widening the JSON WailsBridge already accepts is a local edit; coming back, WailsBridge.emitEvent lands on the application event bus, which Go subscribes to with app.Event.On. One document out, one command event back, and no new JNI. No new Gradle dependency either: minSdk is 21, which is exactly when android.media.session.MediaSession and Notification.MediaStyle arrived, so androidx.media buys two Build.VERSION branches' worth of nothing. Four things in it are load-bearing. **A duck is not a volume change.** Player.SetDuck holds the attenuation as an offset and re-applies the user's level through setVolumeLocked, so it cannot accumulate across repeated ducks and getUserVolume -- which feeds the event, the persisted state and every relative change -- still reports what the user chose. Writing through to the volume would let one notification tone permanently turn the music down. **The duck path is pre-Oreo only.** From API 26 the framework ducks the app itself and sends no CAN_DUCK focus change; asking to be told instead (setWillPauseWhenDucked) would mean pausing for every notification tone, and doing both would attenuate twice. **An unchanged payload is not an event**, the rule emitStatus already states one package over: every push crosses JNI and re-delivers an Intent, and the player pushes state on several paths that can agree. **After the first start, an update is startService.** From Android 12 a background app may not *start* a foreground service but may keep feeding one it already has, which is every track change with the screen off. Relatedly, every path through onStartCommand calls startForeground -- one that returns without it is killed. The contract with Java lives in androidpayload.go *without* the android build tag, and is tested. Everything left in android.go is untested by construction: make lint and make test are three tag sets on linux/amd64, so the only thing that compiles it is the cross-compiler in make android, and the only thing that can run it is a phone. None of the behaviour above has been observed on a device. The APK builds and both halves compile; that is the whole of what is verified. --- backend/app.go | 15 +- backend/mediacontrols/android.go | 215 +++++++ backend/mediacontrols/androidpayload.go | 85 +++ backend/mediacontrols/androidpayload_test.go | 165 ++++++ backend/mediacontrols/mediacontrols.go | 7 + backend/mediacontrols/stub.go | 11 +- backend/player/player.go | 41 +- backend/player/volume.go | 6 + backend/player/volume_test.go | 60 ++ .../main/java/com/wails/app/WailsBridge.java | 28 +- .../com/wails/app/WailsForegroundService.java | 548 ++++++++++++++++-- 11 files changed, 1130 insertions(+), 51 deletions(-) create mode 100644 backend/mediacontrols/android.go create mode 100644 backend/mediacontrols/androidpayload.go create mode 100644 backend/mediacontrols/androidpayload_test.go diff --git a/backend/app.go b/backend/app.go index 1e3e8bc..9a25996 100644 --- a/backend/app.go +++ b/backend/app.go @@ -484,20 +484,24 @@ func (yj *YellowJacketApp) OnStartup(ctx context.Context) { // Register playback finished handler to drive queue auto-advance. yj.player.SetPlaybackFinishedHandler(yj.queue.OnPlaybackFinished) - // Initialize OS media controls (MPRIS on Linux, no-op elsewhere). + // Initialize OS media controls (MPRIS on desktop Linux, a + // MediaSession on Android, no-op elsewhere). The callbacks are the + // same on every platform; only what delivers them differs. yj.mediaControls = mediacontrols.NewHandler(yj.logger) if err := yj.mediaControls.Init(mediacontrols.Callbacks{ OnPlay: yj.queue.Play, OnPause: func() { if err := yj.player.Pause(); err != nil { - yj.logger.Warn("MPRIS Pause failed", "err", err) + yj.logger.Warn("Media controls Pause failed", "err", err) } }, OnPlayPause: func() { if yj.player.IsPlaying() { if err := yj.player.Pause(); err != nil { - yj.logger.Warn("MPRIS PlayPause(pause) failed", "err", err) + yj.logger.Warn( + "Media controls PlayPause(pause) failed", "err", err, + ) } } else { yj.queue.Play() @@ -505,14 +509,14 @@ func (yj *YellowJacketApp) OnStartup(ctx context.Context) { }, OnStop: func() { if err := yj.player.Pause(); err != nil { - yj.logger.Warn("MPRIS Stop failed", "err", err) + yj.logger.Warn("Media controls Stop failed", "err", err) } }, OnNext: yj.queue.Next, OnPrevious: yj.queue.Previous, OnSeek: func(positionSec int) { if err := yj.player.Seek(positionSec); err != nil { - yj.logger.Warn("MPRIS Seek failed", "err", err) + yj.logger.Warn("Media controls Seek failed", "err", err) } }, OnVolume: func(vol float64) { @@ -522,6 +526,7 @@ func (yj *YellowJacketApp) OnStartup(ctx context.Context) { ), ) }, + OnDuck: yj.player.SetDuck, }); err != nil { yj.logger.Error( "Failed to initialize media controls", diff --git a/backend/mediacontrols/android.go b/backend/mediacontrols/android.go new file mode 100644 index 0000000..a19c2e0 --- /dev/null +++ b/backend/mediacontrols/android.go @@ -0,0 +1,215 @@ +//go:build android + +// Android's answer to MPRIS is a MediaSession, and reaching it needs no +// new JNI: Wails exports application.Android.StartForegroundService(json) +// going out, and Java's WailsBridge.emitEvent lands on the application +// event bus coming back. So this handler is one JSON payload pushed to +// the foreground service and one command event read from it. The Java +// half is +// build/android/app/src/main/java/com/wails/app/WailsForegroundService.java +// and the payload keys below are its contract. + +package mediacontrols + +import ( + "errors" + "log/slog" + "sync" + + "github.com/wailsapp/wails/v3/pkg/application" +) + +// commandEvent is the event name the Java side emits transport +// commands on. It is a plain string on both sides; changing it means +// changing WailsForegroundService too. +const commandEvent = "yj:media:command" + +var errNoApplication = errors.New( + "no running application to attach media controls to", +) + +// androidHandler drives the media notification, the lock-screen +// transport and audio focus through the foreground service. +type androidHandler struct { + logger *slog.Logger + + mu sync.Mutex + callbacks Callbacks + meta Metadata + state PlaybackState + positionSec int + + // running tracks whether the foreground service has been started. + // Android 12+ forbids starting one from the background, so it is + // started when playback starts -- a user action, in a visible app + // -- and stopped only when playback stops, which is what keeps + // queue auto-advance working with the screen off. + running bool + + // lastPayload is the last JSON sent. An unchanged payload is not + // an event here either: every push crosses JNI and re-delivers an + // Intent, and the player pushes state on several paths that can + // agree. + lastPayload string + + unsubscribe func() +} + +// NewHandler returns the Android media-session handler. +func NewHandler(logger *slog.Logger) Handler { + return &androidHandler{logger: logger, state: StateStopped} +} + +// Init subscribes to the transport commands the Java side emits. +func (a *androidHandler) Init(callbacks Callbacks) error { + app := application.Get() + if app == nil { + return errNoApplication + } + + a.mu.Lock() + a.callbacks = callbacks + a.mu.Unlock() + + a.unsubscribe = app.Event.On(commandEvent, a.onCommand) + + return nil +} + +// onCommand dispatches one transport command from the notification, +// the lock screen, a headset button or an audio-focus change. +// +// Every callback runs on its own goroutine, for the reason the MPRIS +// handler does the same: they take the player and queue mutexes, and +// this runs on the event processor's dispatch goroutine. +func (a *androidHandler) onCommand(event *application.CustomEvent) { + data, ok := event.Data.(map[string]any) + if !ok { + return + } + + command := parseMediaCommand(data) + + a.mu.Lock() + cb := a.callbacks + a.mu.Unlock() + + switch command.name { + case cmdPlay: + run(cb.OnPlay) + case cmdPause: + run(cb.OnPause) + case cmdPlayPause: + run(cb.OnPlayPause) + case cmdStop: + run(cb.OnStop) + case cmdNext: + run(cb.OnNext) + case cmdPrevious: + run(cb.OnPrevious) + case cmdSeek: + if cb.OnSeek != nil { + go cb.OnSeek(command.positionSec) + } + case cmdDuck: + if cb.OnDuck != nil { + go cb.OnDuck(command.duck) + } + default: + a.logger.Warn("Unknown media command", "command", command.name) + } +} + +// run invokes a callback on its own goroutine, tolerating a nil one. +func run(fn func()) { + if fn != nil { + go fn() + } +} + +// UpdateMetadata pushes new track details to the notification. +func (a *androidHandler) UpdateMetadata(meta Metadata) { + a.mu.Lock() + defer a.mu.Unlock() + + a.meta = meta + a.push() +} + +// UpdatePlaybackState pushes the state and a fresh position anchor; +// the MediaSession interpolates from there while playing. +func (a *androidHandler) UpdatePlaybackState( + state PlaybackState, + positionSec int, +) { + a.mu.Lock() + defer a.mu.Unlock() + + a.state = state + a.positionSec = positionSec + a.push() +} + +// NotifySeek re-anchors the position. Unlike MPRIS, a MediaSession has +// no separate seeked signal -- a new state with a new position is the +// whole mechanism. +func (a *androidHandler) NotifySeek(positionSec int) { + a.mu.Lock() + defer a.mu.Unlock() + + a.positionSec = positionSec + a.push() +} + +// UpdateVolume is deliberately a no-op. Android's volume keys act on +// the media stream, which the OS owns; an app that also moved its own +// volume in response would move it twice. +func (a *androidHandler) UpdateVolume(_ float64) {} + +// Close stops the service and drops the command subscription. +func (a *androidHandler) Close() { + a.mu.Lock() + defer a.mu.Unlock() + + if a.unsubscribe != nil { + a.unsubscribe() + a.unsubscribe = nil + } + + if a.running { + application.Android.StopForegroundService() + a.running = false + } +} + +// push sends the current state to the Java side, if it has changed. +// The caller holds a.mu. +func (a *androidHandler) push() { + if a.state == StateStopped { + // Nothing is playing, so nothing justifies an ongoing + // notification or the process staying alive. + if a.running { + application.Android.StopForegroundService() + a.running = false + a.lastPayload = "" + } + + return + } + + payload, err := mediaPayload(a.meta, a.state, a.positionSec) + if err != nil { + a.logger.Error("Failed to encode media payload", "err", err) + + return + } + + if payload == a.lastPayload { + return + } + + a.lastPayload = payload + a.running = true + + application.Android.StartForegroundService(payload) +} diff --git a/backend/mediacontrols/androidpayload.go b/backend/mediacontrols/androidpayload.go new file mode 100644 index 0000000..8985772 --- /dev/null +++ b/backend/mediacontrols/androidpayload.go @@ -0,0 +1,85 @@ +// The contract between the Android handler and the Java +// WailsForegroundService is two JSON documents -- one pushed out with +// the track and the state, one read back with a transport command -- +// and neither side can check the other. +// +// It lives here, *without* the android build tag, so that `go test` on +// any platform exercises it. android.go itself can only be compiled by +// a cross-compiler and only be run by a phone, so anything left in it +// is untested by construction; this is the half worth not leaving +// there. + +package mediacontrols + +import "encoding/json" + +// Media command names, as the Java side spells them. +const ( + cmdPlay = "play" + cmdPause = "pause" + cmdPlayPause = "playpause" + cmdStop = "stop" + cmdNext = "next" + cmdPrevious = "previous" + cmdSeek = "seek" + cmdDuck = "duck" +) + +// stateNames are what the payload's "state" key carries. Words rather +// than the PlaybackState integers, because the Java side reads them as +// JSON and a renumbered constant would silently mean something else +// there. +var stateNames = map[PlaybackState]string{ + StateStopped: "stopped", + StatePlaying: "playing", + StatePaused: "paused", +} + +// mediaCommand is one transport command from the notification, the +// lock screen, a headset button or an audio-focus change. +type mediaCommand struct { + name string + positionSec int + duck bool +} + +// mediaPayload encodes the state the notification and MediaSession +// render. +func mediaPayload( + meta Metadata, + state PlaybackState, + positionSec int, +) (string, error) { + payload, err := json.Marshal(map[string]any{ + "title": meta.Title, + "artist": meta.Artist, + "album": meta.Album, + "artPath": meta.ArtFilePath, + "durationSec": meta.DurationSec, + "positionSec": positionSec, + "state": stateNames[state], + }) + if err != nil { + return "", err + } + + return string(payload), nil +} + +// parseMediaCommand reads one command out of the event payload. +// +// The numbers arrive as float64 because they came through +// encoding/json as an untyped document -- asserting int here is the +// way a seek silently becomes a seek to zero. +func parseMediaCommand(data map[string]any) mediaCommand { + cmd := mediaCommand{} + cmd.name, _ = data["command"].(string) + + if position, ok := data["positionSec"].(float64); ok { + cmd.positionSec = int(position) + } + + cmd.duck, _ = data["on"].(bool) + + return cmd +} diff --git a/backend/mediacontrols/androidpayload_test.go b/backend/mediacontrols/androidpayload_test.go new file mode 100644 index 0000000..314a154 --- /dev/null +++ b/backend/mediacontrols/androidpayload_test.go @@ -0,0 +1,165 @@ +package mediacontrols + +import ( + "encoding/json" + "testing" +) + +// TestMediaPayloadKeys pins the document the Java side parses. The +// keys are the contract: a rename here is silently a track with no +// title on the lock screen, because WailsForegroundService reads them +// with optString and a missing key is simply "". +func TestMediaPayloadKeys(t *testing.T) { + t.Parallel() + + payload, err := mediaPayload(Metadata{ + Title: "Tideline", + Artist: "Sea Change", + Album: "Ebb", + ArtFilePath: "/covers/ebb_lg.jpg", + DurationSec: 245, + }, StatePlaying, 30) + if err != nil { + t.Fatalf("mediaPayload: %v", err) + } + + var got map[string]any + if err := json.Unmarshal([]byte(payload), &got); err != nil { + t.Fatalf("payload is not JSON: %v", err) + } + + want := map[string]any{ + "title": "Tideline", + "artist": "Sea Change", + "album": "Ebb", + "artPath": "/covers/ebb_lg.jpg", + "durationSec": float64(245), + "positionSec": float64(30), + "state": "playing", + } + + if len(got) != len(want) { + t.Errorf("payload has %d keys, want %d: %s", len(got), len(want), payload) + } + + for key, expected := range want { + if got[key] != expected { + t.Errorf("payload[%q] = %v, want %v", key, got[key], expected) + } + } +} + +// TestMediaPayloadStateNames covers the one value the Java side +// compares against a literal. +func TestMediaPayloadStateNames(t *testing.T) { + t.Parallel() + + tests := []struct { + state PlaybackState + want string + }{ + {StatePlaying, "playing"}, + {StatePaused, "paused"}, + {StateStopped, "stopped"}, + } + + for _, tt := range tests { + payload, err := mediaPayload(Metadata{}, tt.state, 0) + if err != nil { + t.Fatalf("mediaPayload: %v", err) + } + + var got struct { + State string `json:"state"` + } + + if err := json.Unmarshal([]byte(payload), &got); err != nil { + t.Fatalf("payload is not JSON: %v", err) + } + + if got.State != tt.want { + t.Errorf("state %d encoded as %q, want %q", tt.state, got.State, tt.want) + } + } +} + +// TestParseMediaCommand covers the direction that arrives untyped. +// The seek case is the one with teeth: the position crosses as a JSON +// number, so it is a float64 in the map and an int assertion would +// make every seek a seek to zero. +func TestParseMediaCommand(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + data map[string]any + want mediaCommand + }{ + { + name: "play", + data: map[string]any{"command": "play"}, + want: mediaCommand{name: cmdPlay}, + }, + { + name: "seek carries a position", + data: map[string]any{"command": "seek", "positionSec": float64(93)}, + want: mediaCommand{name: cmdSeek, positionSec: 93}, + }, + { + name: "duck carries a flag", + data: map[string]any{"command": "duck", "on": true}, + want: mediaCommand{name: cmdDuck, duck: true}, + }, + { + name: "unduck", + data: map[string]any{"command": "duck", "on": false}, + want: mediaCommand{name: cmdDuck}, + }, + { + name: "a command with nothing in it is not a panic", + data: map[string]any{}, + want: mediaCommand{}, + }, + { + name: "wrongly typed fields fall back to zero", + data: map[string]any{"command": "seek", "positionSec": "93"}, + want: mediaCommand{name: cmdSeek}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + if got := parseMediaCommand(tt.data); got != tt.want { + t.Errorf("parseMediaCommand(%v) = %+v, want %+v", tt.data, got, tt.want) + } + }) + } +} + +// TestMediaCommandNamesAreWhatJavaSends is a spelling check against +// the Java side, which builds these strings by hand. It is a list, not +// a mechanism: nothing can reach across into the .java file, so the +// point is that changing one of these constants fails a test that +// names the file to change with it. +// +// See build/android/app/src/main/java/com/wails/app/WailsForegroundService.java. +func TestMediaCommandNamesAreWhatJavaSends(t *testing.T) { + t.Parallel() + + want := []string{ + "play", "pause", "playpause", "stop", + "next", "previous", "seek", "duck", + } + got := []string{ + cmdPlay, cmdPause, cmdPlayPause, cmdStop, + cmdNext, cmdPrevious, cmdSeek, cmdDuck, + } + + for i, name := range want { + if got[i] != name { + t.Errorf("command %d = %q, want %q", i, got[i], name) + } + } +} diff --git a/backend/mediacontrols/mediacontrols.go b/backend/mediacontrols/mediacontrols.go index ae7f5d6..7df9c8a 100644 --- a/backend/mediacontrols/mediacontrols.go +++ b/backend/mediacontrols/mediacontrols.go @@ -34,6 +34,13 @@ type Callbacks struct { OnPrevious func() OnSeek func(positionSec int) OnVolume func(volume float64) // 0.0–1.0 linear scale. + + // OnDuck asks for playback to be attenuated (true) or restored + // (false) without changing the user's volume. Android alone sends + // it, and only below API 26 -- from Oreo the audio framework ducks + // the app itself and reports no such focus change, so doing both + // would attenuate twice. + OnDuck func(ducked bool) } // Handler manages the OS media control integration. diff --git a/backend/mediacontrols/stub.go b/backend/mediacontrols/stub.go index d79c69f..d1739a0 100644 --- a/backend/mediacontrols/stub.go +++ b/backend/mediacontrols/stub.go @@ -1,10 +1,9 @@ -//go:build !linux || android +//go:build !linux -// Android is covered here rather than by mpris_linux.go: it satisfies -// the `linux` tag but has no D-Bus session bus. Its real equivalent is -// a MediaSession, which is Java-side work and not yet built -- so for -// now the app simply has no lock-screen transport there, which is a -// missing feature rather than a broken one. +// Windows and macOS have no media-control integration yet. `!linux` +// covers Android too without naming it, since `android` implies the +// `linux` tag -- android.go claims it, mpris_linux.go excludes it, and +// this file is left with the platforms neither wants. package mediacontrols diff --git a/backend/player/player.go b/backend/player/player.go index d35e14b..b141a6d 100644 --- a/backend/player/player.go +++ b/backend/player/player.go @@ -56,6 +56,13 @@ type Player struct { trackChangeID uint64 mediaControls mediacontrols.Handler + // duckAmount is the attenuation currently applied on top of the + // user's volume, in the same base-2 exponent effects.Volume uses. + // It is deliberately not persisted and emits no VolumeChanged: a + // duck is something the OS did for the length of a notification, + // not something the user chose. + duckAmount float64 + // trackLengthMs holds the authoritative track duration in // milliseconds, sourced from the database (which uses the // custom header parser). The go-mp3 decoder's Len() can be @@ -793,12 +800,40 @@ func (p *Player) setVolumeLocked(desiredVolume UserVolume) { speaker.Lock() volume := clampVolume(desiredVolume) - p.volume.Volume = float64(volume.ToVolume()) + p.volume.Volume = float64(volume.ToVolume()) - p.duckAmount p.volume.Silent = volume == MinUserVol speaker.Unlock() } +// SetDuck attenuates playback (or restores it) without changing the +// user's volume, for an OS that has asked us to get out of the way of +// something short -- a navigation prompt, a notification tone. +// +// It re-applies the *user's* level through setVolumeLocked rather than +// nudging the effect directly, so the offset cannot accumulate across +// repeated ducks, and it neither emits nor persists: the level the user +// set has not changed and the UI must not claim it has. +// +//wails:ignore // driven by OS audio focus, not by the frontend. +func (p *Player) SetDuck(ducked bool) { + p.mu.Lock() + defer p.mu.Unlock() + + amount := 0.0 + if ducked { + amount = duckAttenuation + } + + if p.volume == nil || amount == p.duckAmount { + return + } + + current := p.getUserVolume() + p.duckAmount = amount + p.setVolumeLocked(current) +} + // ChangeVolume adjusts the volume by a relative amount. func (p *Player) ChangeVolume(deltaVolume int) error { p.mu.Lock() @@ -812,7 +847,9 @@ func (p *Player) ChangeVolume(deltaVolume int) error { } func (p *Player) getUserVolume() UserVolume { - return Volume(p.volume.Volume).ToUserVolume() + // Undo any duck, so every caller -- the event, the persisted + // state, a relative change -- sees the level the user chose. + return Volume(p.volume.Volume + p.duckAmount).ToUserVolume() } // Muted reports whether playback is currently silenced. diff --git a/backend/player/volume.go b/backend/player/volume.go index 5f2a002..e9cd0b0 100644 --- a/backend/player/volume.go +++ b/backend/player/volume.go @@ -19,6 +19,12 @@ const ( MaxVol Volume = 0 ) +// duckAttenuation is how far playback drops when the OS asks us to +// duck, on the same base-2 exponent scale: two steps is a quarter of +// the amplitude (-12 dB), which is audible under a spoken notification +// without sounding like a pause. +const duckAttenuation = 2.0 + // ToVolume converts user volume to internal player volume. func (oldVol UserVolume) ToVolume() Volume { var newVol Volume diff --git a/backend/player/volume_test.go b/backend/player/volume_test.go index b887a28..423e1c7 100644 --- a/backend/player/volume_test.go +++ b/backend/player/volume_test.go @@ -1,9 +1,12 @@ package player import ( + "log/slog" "math" "testing" + "github.com/gopxl/beep/v2/effects" + "yellowjacket/backend/mediacontrols" ) @@ -202,3 +205,60 @@ func TestStateToMediaControls(t *testing.T) { }) } } + +// TestSetDuck covers the property the duck rests on: the attenuation +// is applied to the output and is invisible to everything that asks +// what the volume is -- the event, the persisted state, a relative +// change. Getting that wrong would let one notification tone +// permanently rewrite the user's volume. +func TestSetDuck(t *testing.T) { + t.Parallel() + + p := NewPlayer(slog.Default(), nil) + p.volume = &effects.Volume{Base: 2} + p.setVolumeLocked(80) + + unducked := p.volume.Volume + + p.SetDuck(true) + + if p.volume.Volume >= unducked { + t.Errorf( + "ducked output volume = %v, want less than %v", + p.volume.Volume, unducked, + ) + } + + if got := p.getUserVolume(); got != 80 { + t.Errorf("user volume while ducked = %d, want 80", got) + } + + // A second duck must not stack: the offset is re-applied to the + // user's level, never subtracted again from the current output. + ducked := p.volume.Volume + + p.SetDuck(true) + + if p.volume.Volume != ducked { + t.Errorf( + "duck applied twice = %v, want %v", p.volume.Volume, ducked, + ) + } + + // Changing the volume while ducked keeps the attenuation. + p.setVolumeLocked(60) + + if got := p.getUserVolume(); got != 60 { + t.Errorf("user volume set while ducked = %d, want 60", got) + } + + if want := float64(UserVolume(60).ToVolume()) - duckAttenuation; p.volume.Volume != want { + t.Errorf("output while ducked = %v, want %v", p.volume.Volume, want) + } + + p.SetDuck(false) + + if want := float64(UserVolume(60).ToVolume()); p.volume.Volume != want { + t.Errorf("output after unduck = %v, want %v", p.volume.Volume, want) + } +} diff --git a/build/android/app/src/main/java/com/wails/app/WailsBridge.java b/build/android/app/src/main/java/com/wails/app/WailsBridge.java index bdce333..6198770 100644 --- a/build/android/app/src/main/java/com/wails/app/WailsBridge.java +++ b/build/android/app/src/main/java/com/wails/app/WailsBridge.java @@ -81,6 +81,9 @@ public class WailsBridge { System.loadLibrary("wails"); } + /** The live bridge, for in-process components that are not given one. */ + private static volatile WailsBridge instance; + private final Activity activity; private final Handler mainHandler = new Handler(Looper.getMainLooper()); private WebView webView; @@ -122,6 +125,7 @@ public class WailsBridge { public WailsBridge(Activity activity) { this.activity = activity; + instance = this; } /** @@ -210,6 +214,19 @@ public class WailsBridge { if (initialized) nativeEmitEvent(name, json); } + /** + * Emit an event from a component that holds no bridge reference — + * {@link WailsForegroundService}, which Android constructs itself. It is a + * static hop rather than a binder because the service runs in this same + * process; before the bridge exists (or after it is gone) the event is + * dropped, which is the same thing {@link #emitEvent} does when the native + * library has not been initialized. + */ + public static void emitFromService(String name, String json) { + WailsBridge b = instance; + if (b != null) b.emitEvent(name, json); + } + /** * Serve an asset from the Go asset server */ @@ -1193,7 +1210,16 @@ public class WailsBridge { i.setAction(WailsForegroundService.ACTION_START); i.putExtra("title", title); i.putExtra("text", text); - ContextCompat.startForegroundService(activity, i); + // The whole document, for the media service: seven extras + // would be seven chances for the two sides to disagree about + // a key, and the service already has to parse JSON for the + // fields the scaffold's title/text pair cannot carry. + i.putExtra("payload", json); + if (WailsForegroundService.running) { + activity.startService(i); + } else { + ContextCompat.startForegroundService(activity, i); + } emitEvent("android:foregroundService", "{\"running\":true}"); } catch (Exception e) { Log.e(TAG, "startForegroundService failed", e); diff --git a/build/android/app/src/main/java/com/wails/app/WailsForegroundService.java b/build/android/app/src/main/java/com/wails/app/WailsForegroundService.java index a54b66d..3954e63 100644 --- a/build/android/app/src/main/java/com/wails/app/WailsForegroundService.java +++ b/build/android/app/src/main/java/com/wails/app/WailsForegroundService.java @@ -4,71 +4,545 @@ import android.app.Notification; import android.app.NotificationChannel; import android.app.NotificationManager; import android.app.PendingIntent; +import android.content.BroadcastReceiver; +import android.content.Context; import android.content.Intent; +import android.content.IntentFilter; import android.content.pm.ServiceInfo; +import android.graphics.Bitmap; +import android.graphics.BitmapFactory; +import android.media.AudioAttributes; +import android.media.AudioFocusRequest; +import android.media.AudioManager; +import android.media.MediaMetadata; +import android.media.session.MediaSession; +import android.media.session.PlaybackState; import android.os.Build; +import android.os.Handler; import android.os.IBinder; +import android.os.Looper; +import android.util.Log; import androidx.annotation.Nullable; -import androidx.core.app.NotificationCompat; + +import org.json.JSONObject; + +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; /** - * A minimal started foreground service. It does no work of its own — its purpose - * is to keep the app's process alive (with the required ongoing notification) so - * the developer's Go goroutines keep running while the app is backgrounded, - * which Android would otherwise be free to kill. Start it from - * {@link WailsBridge#startForegroundService(String)} and stop it with - * {@link WailsBridge#stopForegroundService()}. + * The foreground service that keeps playback alive with the screen off, and + * the app's whole media-control surface: a {@link MediaSession} for the lock + * screen and headset buttons, a transport notification, and audio focus. + * + *

The scaffold shipped this as a generic "keep the process alive" service + * typed {@code dataSync}. YellowJacket's reason for staying alive in the + * background is that a song is playing, so it is {@code mediaPlayback} — the + * manifest and {@code startForeground} must agree on that or the call throws. + * + *

It is driven entirely from Go. {@code backend/mediacontrols/android.go} + * pushes a JSON payload through + * {@link WailsBridge#startForegroundService(String)}, and every command the + * user gives here — a notification button, the lock screen, a headset, or the + * OS taking audio focus away — goes back the other way as a + * {@code yj:media:command} event. Nothing about playback is decided here: this + * class renders state and reports intent. */ public class WailsForegroundService extends android.app.Service { public static final String ACTION_START = "com.wails.app.FGS_START"; - private static final String CHANNEL_ID = "wails_foreground"; + + // Transport actions, delivered to ourselves by the notification's + // PendingIntents. getService rather than a broadcast: a receiver would + // have to be exported or registered, and this service is already the + // thing that has to be running for any of them to be meaningful. + private static final String ACTION_PLAY = "com.wails.app.MEDIA_PLAY"; + private static final String ACTION_PAUSE = "com.wails.app.MEDIA_PAUSE"; + private static final String ACTION_NEXT = "com.wails.app.MEDIA_NEXT"; + private static final String ACTION_PREVIOUS = "com.wails.app.MEDIA_PREVIOUS"; + + private static final String TAG = "WailsMedia"; + private static final String CHANNEL_ID = "yellowjacket_playback"; private static final int NOTIFICATION_ID = 0x57A1; // "WAI" + private static final String COMMAND_EVENT = "yj:media:command"; + + /** Cover art is decoded down to this, which is larger than any lock screen. */ + private static final int ART_MAX_PX = 512; + + /** + * Whether an instance is alive. {@link WailsBridge} reads it to decide + * between startForegroundService and startService: from Android 12 an app + * in the background may not start a foreground service, but it + * may go on delivering intents to one it already has — and every update + * after the first (a track change with the screen off, most of them) is + * exactly that case. + */ + static volatile boolean running = false; + + private final Handler mainHandler = new Handler(Looper.getMainLooper()); + private final ExecutorService artExecutor = Executors.newSingleThreadExecutor(); + + private MediaSession session; + private AudioManager audioManager; + private AudioFocusRequest focusRequest; // API 26+ only. + private AudioManager.OnAudioFocusChangeListener focusListener; + + private String title = ""; + private String artist = ""; + private String album = ""; + private String artPath = ""; + private long durationMs = 0; + private long positionMs = 0; + private boolean playing = false; + + private Bitmap art; + + private boolean hasFocus = false; + /** + * Whether *we* paused because focus went away. Only then does regaining it + * resume: a user who paused during a phone call did not ask us to start + * again when it ended. + */ + private boolean pausedByFocusLoss = false; + + private boolean noisyRegistered = false; + + /** Headphones pulled out. Anything else and the room hears the album. */ + private final BroadcastReceiver noisyReceiver = new BroadcastReceiver() { + @Override + public void onReceive(Context context, Intent intent) { + if (AudioManager.ACTION_AUDIO_BECOMING_NOISY.equals(intent.getAction())) { + emitCommand("pause"); + } + } + }; + + @Override + public void onCreate() { + super.onCreate(); + running = true; + audioManager = (AudioManager) getSystemService(AUDIO_SERVICE); + createChannel(); + createSession(); + } @Override public int onStartCommand(Intent intent, int flags, int startId) { - String title = "Wails"; - String text = "Running in the background"; - if (intent != null) { - if (intent.getStringExtra("title") != null) title = intent.getStringExtra("title"); - if (intent.getStringExtra("text") != null) text = intent.getStringExtra("text"); + String action = intent == null ? null : intent.getAction(); + + if (ACTION_PLAY.equals(action)) { + emitCommand("play"); + } else if (ACTION_PAUSE.equals(action)) { + emitCommand("pause"); + } else if (ACTION_NEXT.equals(action)) { + emitCommand("next"); + } else if (ACTION_PREVIOUS.equals(action)) { + emitCommand("previous"); + } else if (intent != null) { + applyPayload(intent); } - NotificationManager nm = (NotificationManager) getSystemService(NOTIFICATION_SERVICE); - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { - NotificationChannel ch = new NotificationChannel( - CHANNEL_ID, "Background work", NotificationManager.IMPORTANCE_LOW); - nm.createNotificationChannel(ch); + // Unconditionally, on every path: a service started with + // startForegroundService that returns from onStartCommand without + // calling startForeground is killed with a + // ForegroundServiceDidNotStartInTimeException. + goForeground(); + + return START_STICKY; + } + + /** + * Read the state Go pushed. "payload" is the whole JSON document; the + * title/text extras are the scaffold's original contract and are kept as a + * fallback so a non-media caller still gets a sensible notification. + */ + private void applyPayload(Intent intent) { + String payload = intent.getStringExtra("payload"); + if (payload == null || payload.isEmpty()) { + if (intent.getStringExtra("title") != null) { + title = intent.getStringExtra("title"); + } + if (intent.getStringExtra("text") != null) { + artist = intent.getStringExtra("text"); + } + return; } - PendingIntent contentIntent = null; - Intent launch = getPackageManager().getLaunchIntentForPackage(getPackageName()); - if (launch != null) { - int piFlags = Build.VERSION.SDK_INT >= Build.VERSION_CODES.M - ? PendingIntent.FLAG_IMMUTABLE : 0; - contentIntent = PendingIntent.getActivity(this, 0, launch, piFlags); + try { + JSONObject o = new JSONObject(payload); + title = o.optString("title", ""); + artist = o.optString("artist", ""); + album = o.optString("album", ""); + durationMs = o.optLong("durationSec", 0) * 1000L; + positionMs = o.optLong("positionSec", 0) * 1000L; + playing = "playing".equals(o.optString("state", "paused")); + + String path = o.optString("artPath", ""); + if (!path.equals(artPath)) { + artPath = path; + loadArt(path); + } + } catch (Exception e) { + Log.e(TAG, "bad media payload", e); + return; } - Notification n = new NotificationCompat.Builder(this, CHANNEL_ID) - .setSmallIcon(android.R.drawable.ic_popup_sync) - .setContentTitle(title) - .setContentText(text) - .setOngoing(true) - .setContentIntent(contentIntent) + if (playing) { + requestFocus(); + registerNoisy(); + } else { + unregisterNoisy(); + } + + updateSession(); + } + + // --- MediaSession ------------------------------------------------------ + + private void createSession() { + session = new MediaSession(this, "YellowJacket"); + session.setFlags(MediaSession.FLAG_HANDLES_MEDIA_BUTTONS + | MediaSession.FLAG_HANDLES_TRANSPORT_CONTROLS); + session.setCallback(new MediaSession.Callback() { + @Override + public void onPlay() { + emitCommand("play"); + } + + @Override + public void onPause() { + emitCommand("pause"); + } + + @Override + public void onStop() { + emitCommand("stop"); + } + + @Override + public void onSkipToNext() { + emitCommand("next"); + } + + @Override + public void onSkipToPrevious() { + emitCommand("previous"); + } + + @Override + public void onSeekTo(long pos) { + try { + JSONObject o = new JSONObject(); + o.put("command", "seek"); + o.put("positionSec", pos / 1000L); + WailsBridge.emitFromService(COMMAND_EVENT, o.toString()); + } catch (Exception e) { + Log.e(TAG, "seek command failed", e); + } + } + }); + session.setActive(true); + } + + private void updateSession() { + MediaMetadata.Builder meta = new MediaMetadata.Builder() + .putString(MediaMetadata.METADATA_KEY_TITLE, title) + .putString(MediaMetadata.METADATA_KEY_ARTIST, artist) + .putString(MediaMetadata.METADATA_KEY_ALBUM, album) + .putLong(MediaMetadata.METADATA_KEY_DURATION, durationMs); + if (art != null) { + meta.putBitmap(MediaMetadata.METADATA_KEY_ALBUM_ART, art); + } + session.setMetadata(meta.build()); + + // The position is an anchor, not a clock: the state carries the + // playback speed and the OS interpolates from here, which is why the + // Go side only pushes on a real state change or a seek. + PlaybackState state = new PlaybackState.Builder() + .setActions(PlaybackState.ACTION_PLAY + | PlaybackState.ACTION_PAUSE + | PlaybackState.ACTION_PLAY_PAUSE + | PlaybackState.ACTION_STOP + | PlaybackState.ACTION_SKIP_TO_NEXT + | PlaybackState.ACTION_SKIP_TO_PREVIOUS + | PlaybackState.ACTION_SEEK_TO) + .setState(playing ? PlaybackState.STATE_PLAYING : PlaybackState.STATE_PAUSED, + positionMs, playing ? 1.0f : 0.0f) .build(); + session.setPlaybackState(state); + } + // --- Notification ------------------------------------------------------ + + private void createChannel() { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) { + return; + } + NotificationManager nm = (NotificationManager) getSystemService(NOTIFICATION_SERVICE); + // LOW: a transport notification is a control surface, not news, and + // IMPORTANCE_DEFAULT would make a sound on every track change. + NotificationChannel ch = new NotificationChannel( + CHANNEL_ID, "Playback", NotificationManager.IMPORTANCE_LOW); + ch.setShowBadge(false); + nm.createNotificationChannel(ch); + } + + private void goForeground() { + Notification n = buildNotification(); if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { - // MEDIA_PLAYBACK, not the scaffold's DATA_SYNC. It must match - // android:foregroundServiceType in the manifest, or - // startForeground throws; and on Android 14+ the declared type - // is what decides whether the service may start from the - // background at all. startForeground(NOTIFICATION_ID, n, ServiceInfo.FOREGROUND_SERVICE_TYPE_MEDIA_PLAYBACK); } else { startForeground(NOTIFICATION_ID, n); } - // Restart if the OS kills us while still wanted. - return START_STICKY; + } + + @SuppressWarnings("deprecation") + private Notification buildNotification() { + Notification.Builder b = Build.VERSION.SDK_INT >= Build.VERSION_CODES.O + ? new Notification.Builder(this, CHANNEL_ID) + : new Notification.Builder(this); + + b.setSmallIcon(android.R.drawable.ic_media_play) + .setContentTitle(title.isEmpty() ? getString(R.string.app_name) : title) + .setContentText(artist) + .setSubText(album) + .setOngoing(playing) + .setVisibility(Notification.VISIBILITY_PUBLIC) + .setContentIntent(launchIntent()); + + if (art != null) { + b.setLargeIcon(art); + } + + b.addAction(new Notification.Action.Builder( + android.R.drawable.ic_media_previous, "Previous", + transportIntent(ACTION_PREVIOUS, 1)).build()); + b.addAction(playing + ? new Notification.Action.Builder(android.R.drawable.ic_media_pause, "Pause", + transportIntent(ACTION_PAUSE, 2)).build() + : new Notification.Action.Builder(android.R.drawable.ic_media_play, "Play", + transportIntent(ACTION_PLAY, 3)).build()); + b.addAction(new Notification.Action.Builder( + android.R.drawable.ic_media_next, "Next", + transportIntent(ACTION_NEXT, 4)).build()); + + Notification.MediaStyle style = new Notification.MediaStyle() + .setShowActionsInCompactView(0, 1, 2); + if (session != null) { + style.setMediaSession(session.getSessionToken()); + } + b.setStyle(style); + + return b.build(); + } + + private PendingIntent transportIntent(String action, int requestCode) { + Intent i = new Intent(this, WailsForegroundService.class).setAction(action); + return PendingIntent.getService(this, requestCode, i, pendingIntentFlags()); + } + + private PendingIntent launchIntent() { + Intent launch = getPackageManager().getLaunchIntentForPackage(getPackageName()); + if (launch == null) { + return null; + } + return PendingIntent.getActivity(this, 0, launch, pendingIntentFlags()); + } + + private int pendingIntentFlags() { + // Mandatory from S, unavailable before M. + return Build.VERSION.SDK_INT >= Build.VERSION_CODES.M + ? PendingIntent.FLAG_IMMUTABLE | PendingIntent.FLAG_UPDATE_CURRENT + : PendingIntent.FLAG_UPDATE_CURRENT; + } + + // --- Cover art --------------------------------------------------------- + + /** + * Decode the cover off the main thread and redraw when it lands. A track + * change must not wait on a JPEG, and the notification is correct without + * one — it simply has no image until this returns. + */ + private void loadArt(final String path) { + art = null; + if (path == null || path.isEmpty()) { + return; + } + + artExecutor.execute(() -> { + Bitmap decoded = decodeScaled(path); + mainHandler.post(() -> { + // The track may have changed while we decoded. + if (!path.equals(artPath)) { + return; + } + art = decoded; + updateSession(); + goForeground(); + }); + }); + } + + private Bitmap decodeScaled(String path) { + try { + BitmapFactory.Options bounds = new BitmapFactory.Options(); + bounds.inJustDecodeBounds = true; + BitmapFactory.decodeFile(path, bounds); + + int longest = Math.max(bounds.outWidth, bounds.outHeight); + int sample = 1; + while (longest / sample > ART_MAX_PX) { + sample *= 2; + } + + BitmapFactory.Options opts = new BitmapFactory.Options(); + opts.inSampleSize = sample; + return BitmapFactory.decodeFile(path, opts); + } catch (Throwable t) { + // OutOfMemoryError included: a missing cover is not a crash. + Log.w(TAG, "cover art decode failed: " + path, t); + return null; + } + } + + // --- Audio focus ------------------------------------------------------- + + private void requestFocus() { + if (hasFocus || audioManager == null) { + return; + } + + if (focusListener == null) { + focusListener = this::onFocusChange; + } + + int result; + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + AudioAttributes attrs = new AudioAttributes.Builder() + .setUsage(AudioAttributes.USAGE_MEDIA) + .setContentType(AudioAttributes.CONTENT_TYPE_MUSIC) + .build(); + // No setWillPauseWhenDucked: from Oreo the framework ducks us + // itself and reports no CAN_DUCK loss, so the Go-side duck below + // is a pre-Oreo path. Asking to be told instead would mean + // pausing for every notification tone. + focusRequest = new AudioFocusRequest.Builder(AudioManager.AUDIOFOCUS_GAIN) + .setAudioAttributes(attrs) + .setOnAudioFocusChangeListener(focusListener, mainHandler) + .build(); + result = audioManager.requestAudioFocus(focusRequest); + } else { + result = requestFocusLegacy(); + } + + hasFocus = result == AudioManager.AUDIOFOCUS_REQUEST_GRANTED; + } + + @SuppressWarnings("deprecation") + private int requestFocusLegacy() { + return audioManager.requestAudioFocus(focusListener, + AudioManager.STREAM_MUSIC, AudioManager.AUDIOFOCUS_GAIN); + } + + @SuppressWarnings("deprecation") + private void abandonFocus() { + if (!hasFocus || audioManager == null) { + return; + } + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O && focusRequest != null) { + audioManager.abandonAudioFocusRequest(focusRequest); + } else { + audioManager.abandonAudioFocus(focusListener); + } + hasFocus = false; + } + + private void onFocusChange(int change) { + switch (change) { + case AudioManager.AUDIOFOCUS_LOSS: + // Someone else owns the output now, for good. + hasFocus = false; + pausedByFocusLoss = false; + emitCommand("pause"); + break; + case AudioManager.AUDIOFOCUS_LOSS_TRANSIENT: + // A phone call. Remember that the pause was ours to undo. + pausedByFocusLoss = playing; + emitCommand("pause"); + break; + case AudioManager.AUDIOFOCUS_LOSS_TRANSIENT_CAN_DUCK: + emitDuck(true); + break; + case AudioManager.AUDIOFOCUS_GAIN: + hasFocus = true; + emitDuck(false); + if (pausedByFocusLoss) { + pausedByFocusLoss = false; + emitCommand("play"); + } + break; + default: + break; + } + } + + // --- Noisy (headphones) ------------------------------------------------ + + private void registerNoisy() { + if (noisyRegistered) { + return; + } + registerReceiver(noisyReceiver, + new IntentFilter(AudioManager.ACTION_AUDIO_BECOMING_NOISY)); + noisyRegistered = true; + } + + private void unregisterNoisy() { + if (!noisyRegistered) { + return; + } + try { + unregisterReceiver(noisyReceiver); + } catch (IllegalArgumentException ignored) { + // Already gone; nothing to undo. + } + noisyRegistered = false; + } + + // --- Talking to Go ----------------------------------------------------- + + private void emitCommand(String command) { + try { + JSONObject o = new JSONObject(); + o.put("command", command); + WailsBridge.emitFromService(COMMAND_EVENT, o.toString()); + } catch (Exception e) { + Log.e(TAG, "command emit failed: " + command, e); + } + } + + private void emitDuck(boolean on) { + try { + JSONObject o = new JSONObject(); + o.put("command", "duck"); + o.put("on", on); + WailsBridge.emitFromService(COMMAND_EVENT, o.toString()); + } catch (Exception e) { + Log.e(TAG, "duck emit failed", e); + } + } + + @Override + public void onDestroy() { + running = false; + unregisterNoisy(); + abandonFocus(); + if (session != null) { + session.setActive(false); + session.release(); + session = null; + } + artExecutor.shutdownNow(); + super.onDestroy(); } @Nullable