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.
86 lines
2.4 KiB
Go
86 lines
2.4 KiB
Go
// 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
|
|
}
|