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.
166 lines
4.1 KiB
Go
166 lines
4.1 KiB
Go
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)
|
|
}
|
|
}
|
|
}
|