fix(android): always serialize event type so plays aren't dropped
android / Build + lint + test (push) Successful in 5m21s

The native client's /api/events requests omitted the `type`
discriminator entirely. The app's Json is configured with
encodeDefaults=false (AppModule), so a `type` left at its data-class
default ("play_started" etc.) is never written to the wire. The server
multiplexes on `type` and returns 400 "unknown event type" for an
empty one, which PlayEventsReporter's catch swallows — and the
play_started path has no offline fallback, so the play is lost with no
trace.

Net effect: EVERY native Android play event (started/ended/skipped/
offline) has 400'd since this code was written. Listening History only
ever populated from the Flutter/web clients; as usage moved to the
native app, History went sparse. Confirmed live in the server access
log: POST /api/events -> 400 on every play, while reads 200.

Force the discriminator onto the wire with
@EncodeDefault(Mode.ALWAYS) on each request type's `type` field.
Surgical (vs flipping encodeDefaults globally), and idiomatic for a
constant-valued discriminator.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-11 00:01:13 -04:00
parent b2c6f6f0e9
commit 653ed95f14
@@ -1,5 +1,9 @@
@file:OptIn(ExperimentalSerializationApi::class)
package com.fabledsword.minstrel.models.wire
import kotlinx.serialization.EncodeDefault
import kotlinx.serialization.ExperimentalSerializationApi
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
@@ -15,7 +19,11 @@ import kotlinx.serialization.Serializable
@Serializable
data class PlayStartedRequest(
val type: String = "play_started",
// Discriminator MUST stay on the wire. The app's Json has
// encodeDefaults=false, so without this a `type` left at its
// default is omitted entirely and the server 400s the event as
// "unknown event type" — silently dropping every native play.
@EncodeDefault(EncodeDefault.Mode.ALWAYS) val type: String = "play_started",
@SerialName("track_id") val trackId: String,
@SerialName("client_id") val clientId: String,
val source: String? = null,
@@ -28,21 +36,21 @@ data class PlayStartedResponse(
@Serializable
data class PlayEndedRequest(
val type: String = "play_ended",
@EncodeDefault(EncodeDefault.Mode.ALWAYS) val type: String = "play_ended",
@SerialName("play_event_id") val playEventId: String,
@SerialName("duration_played_ms") val durationPlayedMs: Long,
)
@Serializable
data class PlaySkippedRequest(
val type: String = "play_skipped",
@EncodeDefault(EncodeDefault.Mode.ALWAYS) val type: String = "play_skipped",
@SerialName("play_event_id") val playEventId: String,
@SerialName("position_ms") val positionMs: Long,
)
@Serializable
data class PlayOfflineRequest(
val type: String = "play_offline",
@EncodeDefault(EncodeDefault.Mode.ALWAYS) val type: String = "play_offline",
@SerialName("track_id") val trackId: String,
@SerialName("client_id") val clientId: String,
val at: String,