diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index 06a863e3..ed07c04e 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -119,6 +119,7 @@ dependencies { implementation(libs.androidx.core.ktx) implementation(libs.androidx.lifecycle.runtime.compose) implementation(libs.androidx.lifecycle.viewmodel.compose) + implementation(libs.androidx.lifecycle.process) implementation(libs.androidx.activity.compose) implementation(libs.androidx.nav.compose) implementation(libs.androidx.hilt.nav.compose) diff --git a/android/app/src/main/java/com/fabledsword/minstrel/MinstrelApplication.kt b/android/app/src/main/java/com/fabledsword/minstrel/MinstrelApplication.kt index e78340b3..8084b845 100644 --- a/android/app/src/main/java/com/fabledsword/minstrel/MinstrelApplication.kt +++ b/android/app/src/main/java/com/fabledsword/minstrel/MinstrelApplication.kt @@ -9,6 +9,7 @@ import coil3.network.okhttp.OkHttpNetworkFetcherFactory import com.fabledsword.minstrel.cache.mutations.MutationReplayer import com.fabledsword.minstrel.cache.sync.SyncController import com.fabledsword.minstrel.di.ApplicationScope +import com.fabledsword.minstrel.player.PlayEventsReporter import com.fabledsword.minstrel.player.ResumeController import dagger.hilt.android.HiltAndroidApp import kotlinx.coroutines.CoroutineScope @@ -58,6 +59,15 @@ class MinstrelApplication : */ @Suppress("unused") @Inject lateinit var mutationReplayer: MutationReplayer + /** + * Same construct-the-singleton trick — PlayEventsReporter's init + * block subscribes to PlayerController.uiState and reports the + * play-event lifecycle (started/ended/skipped/offline) to the + * server. Without this @Inject the singleton would never + * instantiate and Android plays would never reach the server. + */ + @Suppress("unused") @Inject lateinit var playEventsReporter: PlayEventsReporter + @Inject @ApplicationScope lateinit var appScope: CoroutineScope override fun onCreate() { diff --git a/android/app/src/main/java/com/fabledsword/minstrel/player/PlayEventsReporter.kt b/android/app/src/main/java/com/fabledsword/minstrel/player/PlayEventsReporter.kt new file mode 100644 index 00000000..f2f076fc --- /dev/null +++ b/android/app/src/main/java/com/fabledsword/minstrel/player/PlayEventsReporter.kt @@ -0,0 +1,247 @@ +package com.fabledsword.minstrel.player + +import androidx.lifecycle.DefaultLifecycleObserver +import androidx.lifecycle.LifecycleOwner +import androidx.lifecycle.ProcessLifecycleOwner +import com.fabledsword.minstrel.api.endpoints.EventsApi +import com.fabledsword.minstrel.auth.AuthStore +import com.fabledsword.minstrel.cache.mutations.MutationQueue +import com.fabledsword.minstrel.cache.mutations.PlayOfflinePayload +import com.fabledsword.minstrel.di.ApplicationScope +import com.fabledsword.minstrel.models.wire.PlayEndedRequest +import com.fabledsword.minstrel.models.wire.PlaySkippedRequest +import com.fabledsword.minstrel.models.wire.PlayStartedRequest +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.launch +import kotlinx.datetime.Clock +import kotlinx.datetime.Instant +import retrofit2.Retrofit +import retrofit2.create +import java.util.UUID +import javax.inject.Inject +import javax.inject.Singleton + +private const val COMPLETION_TOLERANCE_MS = 3_000L + +/** + * Mobile play-event lifecycle reporter. Closes the parity gap where + * Android-played tracks never reached the server — without this, + * History stayed empty for mobile listening, recommendation scoring + * was biased toward web/desktop, ListenBrainz scrobbles never fired, + * and system-playlist rotation didn't advance on mobile activity. + * + * State machine over (current track id, playing) observed against + * [PlayerController.uiState]. Mirrors Flutter's + * `play_events_reporter.dart`. The skip-vs-ended classification uses + * a 3-second tolerance to avoid marking naturally-finished in-queue + * tracks as skipped (which would dilute the recommendation + * skip-ratio). + * + * Live calls go through [EventsApi]; failures fall through to the + * offline [MutationQueue] so a flaky connection never loses a play. + * App-background / detached states close the current play durably + * via the offline queue (a fire-and-forget POST during teardown is + * unreliable; the queue survives a process kill). + */ +@Singleton +class PlayEventsReporter @Inject constructor( + private val playerController: PlayerController, + private val authStore: AuthStore, + private val mutationQueue: MutationQueue, + @ApplicationScope private val scope: CoroutineScope, + retrofit: Retrofit, +) : DefaultLifecycleObserver { + + private val api: EventsApi = retrofit.create() + + private var curTrackId: String? = null + private var curStartedAt: Instant? = null + private var curSource: String? = null + private var curLastPositionMs: Long = 0 + private var curDurationMs: Long = 0 + private var curReachedEnd: Boolean = false + + // Server play_event_id when the live play_started succeeded; + // null if start failed/offline — then the close is captured into + // the offline mutation queue instead of a live ended/skipped. + private var openPlayEventId: String? = null + + private var prevTrackId: String? = null + + init { + ProcessLifecycleOwner.get().lifecycle.addObserver(this) + scope.launch { + playerController.uiState.collect { state -> + evaluate( + trackId = state.currentTrack?.id, + playing = state.isPlaying, + positionMs = state.positionMs, + durationMs = state.durationMs, + source = state.currentSource, + ) + } + } + } + + private fun evaluate( + trackId: String?, + playing: Boolean, + positionMs: Long, + durationMs: Long, + source: String?, + ) { + // Track-change → close the prior tracked play. + if (trackId != prevTrackId && curTrackId != null) { + closeCurrent(viaOffline = false) + } + + // Entered playing for a new track → begin tracking it. + if (trackId != null && playing && curTrackId != trackId) { + beginTrack(trackId = trackId, source = source, durationMs = durationMs) + } + + // Advance the tracked play's progress. The gate `trackId == + // curTrackId` keeps a finishing track's last-known position + // intact across the moment the listener fires for a track + // change (where currentTrack is briefly the new one). + if (curTrackId != null && trackId == curTrackId) { + curLastPositionMs = positionMs + if (durationMs > 0) { + curDurationMs = durationMs + if (positionMs >= curDurationMs - COMPLETION_TOLERANCE_MS) { + curReachedEnd = true + } + } + } + + prevTrackId = trackId + } + + private fun beginTrack(trackId: String, source: String?, durationMs: Long) { + curTrackId = trackId + curStartedAt = Clock.System.now() + curSource = source + curLastPositionMs = 0 + curDurationMs = durationMs + curReachedEnd = false + openPlayEventId = null + startLive(trackId = trackId, source = source) + } + + private fun startLive(trackId: String, source: String?) { + val cid = resolveClientId() ?: return + scope.launch { + try { + val response = api.playStarted( + PlayStartedRequest( + trackId = trackId, + clientId = cid, + source = source.takeIf { !it.isNullOrEmpty() }, + ), + ) + if (curTrackId == trackId) { + openPlayEventId = response.playEventId + } + } catch ( + @Suppress("TooGenericExceptionCaught", "SwallowedException") e: Throwable, + ) { + // Offline / flaky — openPlayEventId stays null; close + // path will enqueue the completed play for replay. + } + } + } + + private fun closeCurrent(viaOffline: Boolean) { + val trackId = curTrackId + val startedAt = curStartedAt + if (trackId == null || startedAt == null) { + resetCurrent() + return + } + val reached = curReachedEnd + val lastPos = curLastPositionMs + val durationMs = if (reached && curDurationMs > 0) curDurationMs else lastPos + val source = curSource + val id = openPlayEventId + + if (!viaOffline && id != null) { + scope.launch { + try { + if (reached) { + api.playEnded( + PlayEndedRequest( + playEventId = id, + durationPlayedMs = durationMs, + ), + ) + } else { + api.playSkipped( + PlaySkippedRequest( + playEventId = id, + positionMs = lastPos, + ), + ) + } + } catch ( + @Suppress("TooGenericExceptionCaught", "SwallowedException") e: Throwable, + ) { + enqueueOffline(trackId, startedAt, source, durationMs) + } + } + } else { + enqueueOffline(trackId, startedAt, source, durationMs) + } + resetCurrent() + } + + private fun resetCurrent() { + curTrackId = null + curStartedAt = null + curSource = null + curLastPositionMs = 0 + curDurationMs = 0 + curReachedEnd = false + openPlayEventId = null + } + + private fun enqueueOffline( + trackId: String, + startedAt: Instant, + source: String?, + durationPlayedMs: Long, + ) { + val cid = resolveClientId() ?: return + scope.launch { + mutationQueue.enqueuePlayOffline( + PlayOfflinePayload( + trackId = trackId, + clientId = cid, + atIso = startedAt.toString(), + durationPlayedMs = durationPlayedMs, + source = source.takeIf { !it.isNullOrEmpty() }, + ), + ) + } + } + + /** + * Returns the persisted client id, generating + persisting one on + * first read. The setter is fire-and-forget on the app scope so + * this stays synchronous; the in-memory StateFlow update is + * immediate so the next caller observes the new value. + */ + private fun resolveClientId(): String? { + authStore.clientId.value?.let { return it } + val fresh = UUID.randomUUID().toString() + authStore.setClientId(fresh) + return fresh + } + + // ── Lifecycle: durable-close on app background / detach ──────── + + override fun onStop(owner: LifecycleOwner) { + if (curTrackId != null) { + closeCurrent(viaOffline = true) + } + } +} diff --git a/android/gradle/libs.versions.toml b/android/gradle/libs.versions.toml index 83632dcc..d19e6fdd 100644 --- a/android/gradle/libs.versions.toml +++ b/android/gradle/libs.versions.toml @@ -37,6 +37,7 @@ detekt = "2.0.0-alpha.3" androidx-core-ktx = { module = "androidx.core:core-ktx", version = "1.13.1" } androidx-lifecycle-viewmodel-compose = { module = "androidx.lifecycle:lifecycle-viewmodel-compose", version.ref = "lifecycle" } androidx-lifecycle-runtime-compose = { module = "androidx.lifecycle:lifecycle-runtime-compose", version.ref = "lifecycle" } +androidx-lifecycle-process = { module = "androidx.lifecycle:lifecycle-process", version.ref = "lifecycle" } androidx-activity-compose = { module = "androidx.activity:activity-compose", version = "1.9.3" } androidx-nav-compose = { module = "androidx.navigation:navigation-compose", version.ref = "nav-compose" } androidx-hilt-nav-compose = { module = "androidx.hilt:hilt-navigation-compose", version.ref = "hilt-androidx" }