feat(android): LiveEventsDispatcher — cross-screen SSE invalidations
Subscribes to EventsStream and maps like-family events to LikesRepository.refreshIds(). Cross-device likes (web flips a heart, phone reflects it) now propagate without a manual refresh. ProcessLifecycleOwner foreground hook re-runs the same refresh as defensive cold-start cleanup — matches Flutter's resume-handler. Screen-scoped events (playlist.deleted for a specific id, single- request status_changed) intentionally NOT in the dispatcher; those ride EventsStream.events directly from per-screen ViewModels in the next commit. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -10,6 +10,7 @@ import com.fabledsword.minstrel.cache.mutations.MutationReplayer
|
||||
import com.fabledsword.minstrel.cache.sync.SyncController
|
||||
import com.fabledsword.minstrel.di.ApplicationScope
|
||||
import com.fabledsword.minstrel.events.EventsStream
|
||||
import com.fabledsword.minstrel.events.LiveEventsDispatcher
|
||||
import com.fabledsword.minstrel.player.PlayEventsReporter
|
||||
import com.fabledsword.minstrel.player.ResumeController
|
||||
import dagger.hilt.android.HiltAndroidApp
|
||||
@@ -78,6 +79,13 @@ class MinstrelApplication :
|
||||
*/
|
||||
@Suppress("unused") @Inject lateinit var eventsStream: EventsStream
|
||||
|
||||
/**
|
||||
* Same construct-the-singleton trick — LiveEventsDispatcher's
|
||||
* init block subscribes to EventsStream.events + the process
|
||||
* lifecycle and routes cross-screen state refreshes.
|
||||
*/
|
||||
@Suppress("unused") @Inject lateinit var liveEventsDispatcher: LiveEventsDispatcher
|
||||
|
||||
@Inject @ApplicationScope lateinit var appScope: CoroutineScope
|
||||
|
||||
override fun onCreate() {
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
package com.fabledsword.minstrel.events
|
||||
|
||||
import androidx.lifecycle.DefaultLifecycleObserver
|
||||
import androidx.lifecycle.LifecycleOwner
|
||||
import androidx.lifecycle.ProcessLifecycleOwner
|
||||
import com.fabledsword.minstrel.di.ApplicationScope
|
||||
import com.fabledsword.minstrel.likes.data.LikesRepository
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.launch
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
/**
|
||||
* Maps incoming [LiveEvent]s to cross-screen state refreshes. Mirrors
|
||||
* `flutter_client/lib/shared/live_events_dispatcher.dart`. Activated
|
||||
* by force-@Inject in MinstrelApplication.
|
||||
*
|
||||
* Scope is deliberately narrow: this dispatcher only touches state
|
||||
* that's reachable from /shared (i.e. that has a Repository-level
|
||||
* refresh method). Screen-scoped state (PlaylistDetail for a
|
||||
* specific id, a single Request's status_changed) is opted into by
|
||||
* the screen's ViewModel subscribing to [EventsStream.events]
|
||||
* directly.
|
||||
*
|
||||
* Includes a [ProcessLifecycleOwner] foreground hook that
|
||||
* defensively re-runs the same refreshes — SSE catches up on its
|
||||
* own, but a long-backgrounded app can hit `onStart` before the
|
||||
* stream reconnects and the re-refresh avoids stale data flashing.
|
||||
*/
|
||||
@Singleton
|
||||
class LiveEventsDispatcher @Inject constructor(
|
||||
private val eventsStream: EventsStream,
|
||||
private val likes: LikesRepository,
|
||||
@ApplicationScope private val scope: CoroutineScope,
|
||||
) : DefaultLifecycleObserver {
|
||||
|
||||
init {
|
||||
ProcessLifecycleOwner.get().lifecycle.addObserver(this)
|
||||
scope.launch {
|
||||
eventsStream.events.collect { e -> handle(e) }
|
||||
}
|
||||
}
|
||||
|
||||
private fun handle(event: LiveEvent) {
|
||||
when (event.kind) {
|
||||
"track.liked",
|
||||
"track.unliked",
|
||||
"album.liked",
|
||||
"album.unliked",
|
||||
"artist.liked",
|
||||
"artist.unliked",
|
||||
-> refreshLikes()
|
||||
}
|
||||
// Other kinds (playlist.*, quarantine.*, request.status_changed,
|
||||
// scan.*) reach screen-scoped subscribers via EventsStream
|
||||
// directly. Add cross-screen cases here only when a new
|
||||
// Repository-level refresh becomes available.
|
||||
}
|
||||
|
||||
override fun onStart(owner: LifecycleOwner) {
|
||||
// App returned to the foreground. SSE will catch up but might
|
||||
// not have reconnected yet; flush the cross-screen refreshes
|
||||
// defensively.
|
||||
refreshLikes()
|
||||
}
|
||||
|
||||
private fun refreshLikes() {
|
||||
scope.launch {
|
||||
runCatching { likes.refreshIds() }
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user