diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index e52257f3228..0c075ddfa45 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -36,6 +36,7 @@
+
+
+
+
+
+
+
+
0 } == true
+
+ /** True if the player has at least one media item (playing or paused). */
+ val hasActiveMedia: Boolean
+ get() = mediaSession?.player?.let { it.mediaItemCount > 0 } == true
+
+ /**
+ * Unregisters this session from [service] by calling
+ * [MediaSessionService.removeSession]. Has no effect if the session is not currently active.
+ */
+ fun unregisterFrom(service: MediaSessionService) {
+ mediaSession?.let { service.removeSession(it) }
+ }
+
+ /**
+ * Builds a [MediaStyle][MediaStyleNotificationHelper.MediaStyle] notification for this session
+ * using the player's current metadata (title, artist, artwork).
+ *
+ * @return The notification, or null if the session is not currently active.
+ */
+ @OptIn(UnstableApi::class)
+ fun buildNotification(): Notification? {
+ val session = mediaSession ?: return null
+ val metadata = session.player.mediaMetadata
+ val artworkBitmap = metadata.artworkData?.let { BitmapFactory.decodeByteArray(it, 0, it.size) }
+ return NotificationCompat.Builder(context, NOTIFICATION_CHANNEL_ID)
+ .setStyle(MediaStyleNotificationHelper.MediaStyle(session))
+ .setSmallIcon(commonR.drawable.ic_stat_ic_notification)
+ .setVisibility(NotificationCompat.VISIBILITY_PUBLIC)
+ .setCategory(NotificationCompat.CATEGORY_TRANSPORT)
+ .setContentTitle(metadata.title ?: id)
+ .setContentText(metadata.artist)
+ .setLargeIcon(artworkBitmap)
+ .setOngoing(session.player.isPlaying)
+ .setContentIntent(session.sessionActivity)
+ .build()
+ }
+
+ private fun getCommandCallback(scope: CoroutineScope, onCommandComplete: () -> Unit) =
+ object : HaRemoteMediaPlayer.CommandCallback {
+ override fun onPlayRequested() = scope.launch {
+ callMediaAction(ACTION_MEDIA_PLAY)
+ onCommandComplete()
+ }
+
+ override fun onPauseRequested() = scope.launch {
+ callMediaAction(ACTION_MEDIA_PAUSE)
+ onCommandComplete()
+ }
+
+ override fun onSeekRequested(positionMs: Long) = scope.launch {
+ callMediaAction(
+ action = ACTION_MEDIA_SEEK,
+ extraData = mapOf("seek_position" to positionMs / 1000.0),
+ )
+ onCommandComplete()
+ }
+
+ override fun onNextRequested() = scope.launch {
+ callMediaAction(ACTION_MEDIA_NEXT_TRACK)
+ onCommandComplete()
+ }
+
+ override fun onPreviousRequested() = scope.launch {
+ callMediaAction(ACTION_MEDIA_PREVIOUS_TRACK)
+ onCommandComplete()
+ }
+
+ override fun onSetVolumeRequested(volume: Float) = scope.launch {
+ callMediaAction(
+ action = ACTION_VOLUME_SET,
+ extraData = mapOf("volume_level" to volume),
+ )
+ onCommandComplete()
+ }
+
+ override fun onIncreaseVolumeRequested() = scope.launch {
+ callMediaAction(ACTION_VOLUME_UP)
+ onCommandComplete()
+ }
+
+ override fun onDecreaseVolumeRequested() = scope.launch {
+ callMediaAction(ACTION_VOLUME_DOWN)
+ onCommandComplete()
+ }
+
+ override fun onMuteRequested(muted: Boolean) = scope.launch {
+ callMediaAction(
+ action = ACTION_VOLUME_MUTE,
+ extraData = mapOf("is_volume_muted" to muted),
+ )
+ onCommandComplete()
+ }
+
+ override fun onStopRequested() = scope.launch {
+ callMediaAction(ACTION_MEDIA_STOP)
+ onCommandComplete()
+ }
+
+ override fun onShuffleRequested(shuffle: Boolean) = scope.launch {
+ callMediaAction(
+ action = ACTION_SHUFFLE_SET,
+ extraData = mapOf("shuffle" to shuffle),
+ )
+ onCommandComplete()
+ }
+
+ override fun onRepeatRequested(repeatMode: MediaRepeatMode): Job {
+ val haRepeatValue = when (repeatMode) {
+ is MediaRepeatMode.Off -> "off"
+ is MediaRepeatMode.One -> "one"
+ is MediaRepeatMode.All -> "all"
+ }
+ return scope.launch {
+ callMediaAction(
+ action = ACTION_REPEAT_SET,
+ extraData = mapOf("repeat" to haRepeatValue),
+ )
+ onCommandComplete()
+ }
+ }
+ }
+
+ /**
+ * Creates the [MediaSession] and player, starts observing entity state, and suspends until
+ * the calling coroutine is cancelled. Calls [onSessionReady] with the new session immediately
+ * after creation so the caller can register it with
+ * [androidx.media3.session.MediaSessionService.addSession].
+ *
+ * All Media3 resources are released in a `finally` block, so they are always cleaned up
+ * regardless of how the coroutine ends (cancellation or normal flow completion).
+ */
+ suspend fun observe(onSessionReady: suspend (MediaSession) -> Unit) {
+ coroutineScope {
+ FailFast.failWhen(mediaSession != null) {
+ "observe() called while a session is already active for ${config.entityId}"
+ }
+ Timber.d("observe: starting for ${config.entityId}")
+
+ var observationJob = launch { startObservingState() }
+
+ // After each command, restart observation if the WebSocket flow has completed (e.g.
+ // after a transient disconnect). This lets the user resume control without reopening
+ // the app.
+ fun restartObservationIfNeeded() {
+ if (!observationJob.isActive) {
+ Timber.d("observe: restarting observation after command for ${config.entityId}")
+ observationJob = launch { startObservingState() }
+ }
+ }
+
+ val player =
+ HaRemoteMediaPlayer(Looper.getMainLooper(), getCommandCallback(this, ::restartObservationIfNeeded))
+ val session = buildMediaSession(player)
+ mediaSession = session
+ try {
+ onSessionReady(session)
+ awaitCancellation()
+ } catch (e: CancellationException) {
+ Timber.d("observe: cancelled for ${config.entityId}")
+ throw e
+ } finally {
+ Timber.d("observe: finally block running for ${config.entityId}, releasing player and session")
+ mediaSession = null
+ withContext(NonCancellable + Dispatchers.Main) {
+ player.release()
+ session.release()
+ }
+ }
+ }
+ }
+
+ /**
+ * Observes entity state for [config] until the flow completes or the coroutine is cancelled.
+ * The flow completes when the WebSocket subscription returns null (not yet connected), and
+ * is cancelled when the WebSocket disconnects (the backing SharedFlow's scope is cancelled).
+ * In both cases the session is not restarted here; reconnection happens when the user opens
+ * the app, which recreates active sessions via [HaMediaSessionService].
+ */
+ private suspend fun startObservingState() {
+ Timber.d("startObservingState: starting for ${config.entityId}")
+ var artworkCache = ArtworkCache()
+ mediaControlRepository.observeEntityState(config).collect { state ->
+ if (state == null) {
+ Timber.d("startObservingState: received null state for ${config.entityId}, skipping update")
+ return@collect
+ }
+ Timber.d("startObservingState: received state for ${config.entityId}, playbackState=${state.playbackState}")
+ if (state.playbackState is MediaPlaybackState.Off) {
+ // Entity is off: reset the player to idle (no playlist, no commands) so Media3
+ // does not create a notification for this session. A notification for an idle
+ // session with no content would replace the foreground notification of any
+ // currently-playing session (e.g. another configured entity), hiding its control.
+ artworkCache = ArtworkCache()
+ withContext(Dispatchers.Main) {
+ mediaSession?.player?.let {
+ (it as? HaRemoteMediaPlayer)?.updateState(state = null, artworkPngBytes = null)
+ }
+ }
+ } else {
+ artworkCache = loadArtworkAndUpdatePlayer(state, artworkCache)
+ }
+ }
+ Timber.d("startObservingState: flow collection ended for ${config.entityId}")
+ }
+
+ private fun buildMediaSession(player: HaRemoteMediaPlayer): MediaSession = MediaSession.Builder(context, player)
+ .setId("${config.serverId}:${config.entityId}")
+ .setCallback(MediaSessionCallback())
+ .build()
+ .also { session ->
+ /**
+ * FLAG_ACTIVITY_NEW_TASK is required when starting an activity from a service context
+ * (PendingIntents from notifications always fire in a non-Activity context).
+ * FLAG_ACTIVITY_SINGLE_TOP prevents stacking a redundant WebViewActivity if one is
+ * already at the top; onNewIntent delivers the path to the existing instance instead.
+ */
+ val tapIntent = LaunchActivity.newInstance(
+ context = context,
+ deepLink = LaunchActivity.DeepLink.NavigateTo(
+ path = "entityId:${config.entityId}",
+ serverId = config.serverId,
+ ),
+ ).apply {
+ flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_SINGLE_TOP
+ }
+ session.sessionActivity = PendingIntent.getActivity(
+ context,
+ "${config.serverId}:${config.entityId}".hashCode(),
+ tapIntent,
+ PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT,
+ )
+ }
+
+ private suspend fun callMediaAction(action: String, extraData: Map = emptyMap()) {
+ val actionData = hashMapOf("entity_id" to config.entityId)
+ actionData.putAll(extraData)
+ try {
+ serverManager.integrationRepository(config.serverId)
+ .callAction(MEDIA_PLAYER_DOMAIN, action, actionData)
+ } catch (e: CancellationException) {
+ throw e
+ } catch (e: Exception) {
+ Timber.e(e, "Failed to call media action $action on ${config.entityId}")
+ }
+ }
+
+ /**
+ * Loads artwork for [state] if the URL has changed, then updates the player on the main thread.
+ *
+ * @return An updated [ArtworkCache] reflecting the outcome of the load attempt.
+ */
+ private suspend fun loadArtworkAndUpdatePlayer(state: MediaControlState, cache: ArtworkCache): ArtworkCache {
+ val pictureUrl = state.entityPictureUrl
+ val updatedCache = when {
+ pictureUrl == null -> ArtworkCache()
+ pictureUrl == cache.url -> cache
+ else -> {
+ val bytes = resolveArtworkUrl(state)?.let { loadBitmapAsPng(it) }
+ if (bytes != null) ArtworkCache(url = pictureUrl, bytes = bytes) else cache
+ }
+ }
+
+ withContext(Dispatchers.Main) {
+ mediaSession?.player?.let { player ->
+ (player as? HaRemoteMediaPlayer)?.updateState(state = state, artworkPngBytes = updatedCache.bytes)
+ }
+ }
+ return updatedCache
+ }
+
+ private suspend fun resolveArtworkUrl(state: MediaControlState): String? {
+ val entityPictureUrl = state.entityPictureUrl ?: return null
+ if (entityPictureUrl.startsWith("http")) return entityPictureUrl
+
+ val baseUrl = try {
+ serverManager.connectionStateProvider(state.serverId)
+ .urlFlow()
+ .firstUrlOrNull()
+ } catch (e: CancellationException) {
+ throw e
+ } catch (e: Exception) {
+ Timber.e(e, "Failed to resolve artwork base URL for server ${state.serverId}")
+ null
+ } ?: return null
+
+ return URL(baseUrl, entityPictureUrl).toString()
+ }
+
+ /** Loads album art and compresses to PNG bytes on the IO dispatcher. */
+ private suspend fun loadBitmapAsPng(url: String): ByteArray? = withContext(Dispatchers.IO) {
+ try {
+ val request = ImageRequest.Builder(context)
+ .data(url)
+ .allowHardware(false)
+ .size(NOTIFICATION_ICON_SIZE_PX, NOTIFICATION_ICON_SIZE_PX)
+ .build()
+ val result = context.imageLoader.execute(request)
+ result.image?.toBitmap()?.let { bitmap ->
+ val stream = ByteArrayOutputStream()
+ bitmap.compress(CompressFormat.PNG, 100, stream)
+ stream.toByteArray()
+ }
+ } catch (e: CancellationException) {
+ throw e
+ } catch (e: Exception) {
+ Timber.e(e, "Failed to load album art from ${sensitive(url)}")
+ null
+ }
+ }
+
+ /**
+ * Restricts media session connections to trusted controllers (same app, system,
+ * or apps with MEDIA_CONTENT_CONTROL / notification listener access).
+ */
+ @OptIn(UnstableApi::class)
+ private class MediaSessionCallback : MediaSession.Callback {
+ override fun onConnect(
+ session: MediaSession,
+ controller: MediaSession.ControllerInfo,
+ ): MediaSession.ConnectionResult {
+ if (!controller.isTrusted) {
+ Timber.w("Rejecting connection from untrusted media controller package=${controller.packageName}")
+ return MediaSession.ConnectionResult.reject()
+ }
+ return MediaSession.ConnectionResult.accept(
+ MediaSession.ConnectionResult.DEFAULT_SESSION_COMMANDS,
+ MediaSession.ConnectionResult.DEFAULT_PLAYER_COMMANDS,
+ )
+ }
+ }
+
+ /** Immutable cache of the last successfully loaded artwork. */
+ private data class ArtworkCache(val url: String? = null, val bytes: ByteArray? = null)
+
+ companion object {
+ /** Notification channel ID used for all media control notifications. */
+ const val NOTIFICATION_CHANNEL_ID = "media_session"
+
+ /** Target pixel size for notification large icon artwork. Pre-scaling in Coil avoids
+ * main-thread downscaling by Android's Icon class (StrictMode CustomViolation). */
+ private const val NOTIFICATION_ICON_SIZE_PX = 256
+
+ private const val ACTION_MEDIA_PLAY = "media_play"
+ private const val ACTION_MEDIA_PAUSE = "media_pause"
+ private const val ACTION_MEDIA_STOP = "media_stop"
+ private const val ACTION_MEDIA_SEEK = "media_seek"
+ private const val ACTION_MEDIA_NEXT_TRACK = "media_next_track"
+ private const val ACTION_MEDIA_PREVIOUS_TRACK = "media_previous_track"
+ private const val ACTION_VOLUME_SET = "volume_set"
+ private const val ACTION_VOLUME_UP = "volume_up"
+ private const val ACTION_VOLUME_DOWN = "volume_down"
+ private const val ACTION_VOLUME_MUTE = "volume_mute"
+ private const val ACTION_SHUFFLE_SET = "shuffle_set"
+ private const val ACTION_REPEAT_SET = "repeat_set"
+ }
+
+ /** Creates [HaMediaSession] instances with the runtime-provided [config]. */
+ @AssistedFactory
+ interface Factory {
+ fun create(config: MediaControlEntityConfig): HaMediaSession
+ }
+}
diff --git a/app/src/main/kotlin/io/homeassistant/companion/android/mediacontrol/HaMediaSessionService.kt b/app/src/main/kotlin/io/homeassistant/companion/android/mediacontrol/HaMediaSessionService.kt
new file mode 100644
index 00000000000..e0a3065e9e2
--- /dev/null
+++ b/app/src/main/kotlin/io/homeassistant/companion/android/mediacontrol/HaMediaSessionService.kt
@@ -0,0 +1,284 @@
+package io.homeassistant.companion.android.mediacontrol
+
+import android.annotation.SuppressLint
+import android.app.NotificationChannel
+import android.app.NotificationManager
+import android.content.Context
+import android.content.Intent
+import androidx.annotation.OptIn
+import androidx.annotation.VisibleForTesting
+import androidx.core.app.NotificationManagerCompat
+import androidx.core.app.ServiceCompat
+import androidx.media3.common.util.UnstableApi
+import androidx.media3.session.MediaSession
+import androidx.media3.session.MediaSessionService
+import dagger.hilt.android.AndroidEntryPoint
+import io.homeassistant.companion.android.common.R as commonR
+import io.homeassistant.companion.android.common.data.mediacontrol.MediaControlEntityConfig
+import io.homeassistant.companion.android.common.data.mediacontrol.MediaControlRepository
+import javax.inject.Inject
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.Job
+import kotlinx.coroutines.SupervisorJob
+import kotlinx.coroutines.cancel
+import kotlinx.coroutines.cancelAndJoin
+import kotlinx.coroutines.flow.launchIn
+import kotlinx.coroutines.flow.onEach
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.withContext
+import timber.log.Timber
+
+/**
+ * A [MediaSessionService] that exposes one or more Home Assistant media_player entities as native
+ * Android media controls in the notification shade. Each configured entity gets its own
+ * [HaMediaSession] and [MediaSession], which Media3 registers and presents individually.
+ *
+ * Notifications are managed via [onUpdateNotification], which is called per-session by Media3
+ * whenever a session's player state changes. Each entity receives a notification with a unique ID
+ * derived from its session ID, so each entity appears as a separate card in the notification shade.
+ *
+ * This service is responsible only for the Android service lifecycle and session reconciliation.
+ * All per-entity session logic is delegated to [HaMediaSession].
+ */
+@AndroidEntryPoint
+class HaMediaSessionService @VisibleForTesting constructor(private val serviceScope: CoroutineScope) :
+ MediaSessionService() {
+
+ @Inject constructor() : this(CoroutineScope(SupervisorJob() + Dispatchers.Default))
+
+ @Inject
+ lateinit var mediaControlRepository: MediaControlRepository
+
+ @Inject
+ lateinit var haMediaSessionFactory: HaMediaSession.Factory
+
+ // Keyed by "$serverId:$entityId". Each entry pairs the session with the job running observe().
+ private val activeSessions = mutableMapOf>()
+
+ /** The notification ID last passed to [startForeground], or null if not in the foreground. */
+ private var foregroundNotificationId: Int? = null
+ private val notificationManager by lazy { NotificationManagerCompat.from(this) }
+
+ override fun onCreate() {
+ super.onCreate()
+ createNotificationChannel()
+ Timber.d("HaMediaSessionService created")
+ startObservingEntities()
+ }
+
+ @VisibleForTesting
+ internal fun startObservingEntities() {
+ mediaControlRepository.observeConfiguredEntities()
+ .onEach { entities -> reconcileSessions(entities) }
+ .launchIn(serviceScope)
+ }
+
+ // Returns null intentionally: Media3 routes each controller to the session whose ID matches
+ // the one it was constructed with. Returning a specific session here would cause all
+ // controllers (including the notification) to connect to that one session, breaking
+ // multi-session behavior where each entity has its own independent media control card.
+ override fun onGetSession(controllerInfo: MediaSession.ControllerInfo): MediaSession? = null
+
+ override fun onTaskRemoved(rootIntent: Intent?) {
+ val anyPlaying = activeSessions.values.any { (session, _) -> session.isPlaying }
+ // Keep the service alive while playback is active so the media notification remains
+ // visible and controllable from the notification shade after the app is dismissed.
+ // If nothing is playing there is no reason to keep the service alive.
+ // Note: there is no automatic stop when playback ends after this point — the service
+ // will only stop when the user removes all configured entities, which causes
+ // reconcileSessions to call stopSelf() on an empty list.
+ if (!anyPlaying) {
+ stopSelf()
+ }
+ }
+
+ /**
+ * Called by Media3 whenever a session's player state changes and the notification needs to be
+ * updated. Each session gets a notification with a unique ID derived from the session's ID,
+ * so each entity appears as its own card in the media controls carousel.
+ */
+ // POST_NOTIFICATIONS is not required for notifications linked to an active MediaSession
+ // (MediaStyle notifications). This is a platform-level guarantee on API 33+; on API < 33
+ // the permission does not exist at all.
+ @SuppressLint("MissingPermission")
+ @OptIn(UnstableApi::class)
+ override fun onUpdateNotification(session: MediaSession, startInForegroundRequired: Boolean) {
+ val notificationId = session.id.hashCode()
+
+ // A session not in activeSessions is being torn down. removeSession() and player.release()
+ // both trigger onUpdateNotification, so without this guard we would re-post a notification
+ // we just cancelled, leaving a zombie media control card after removal.
+ val haSession = activeSessions.values.firstOrNull { (haSession, _) -> haSession.id == session.id }?.first
+
+ if (haSession == null || session.player.mediaItemCount == 0) {
+ // Entity is off, no state has arrived yet, or the session is being torn down.
+ notificationManager.cancel(notificationId)
+ if (foregroundNotificationId == notificationId) {
+ promoteForegroundOrStop(excludeKey = session.id)
+ }
+ return
+ }
+
+ val notification = haSession.buildNotification() ?: return
+ if (foregroundNotificationId == null && startInForegroundRequired) {
+ // Service is not yet in the foreground and playback requires it — start foreground
+ // with this session's notification. All subsequent sessions (and updates to this one)
+ // go through notificationManager.notify() to avoid replacing the foreground
+ // notification ID, which would dismiss the previously-shown notification on Android 13+.
+ startForeground(notificationId, notification)
+ foregroundNotificationId = notificationId
+ } else {
+ // Service is already in the foreground (or foreground not yet required).
+ // notificationManager.notify() works for both regular notifications and for updating
+ // the foreground notification in-place when the ID matches.
+ notificationManager.notify(notificationId, notification)
+ }
+ }
+
+ override fun onDestroy() {
+ Timber.d("HaMediaSessionService destroyed")
+ if (foregroundNotificationId != null) {
+ ServiceCompat.stopForeground(this, ServiceCompat.STOP_FOREGROUND_REMOVE)
+ foregroundNotificationId = null
+ }
+ // Snapshot and clear activeSessions before calling removeSession so that the
+ // onUpdateNotification guard (!isActive check) treats these sessions as inactive and
+ // cancels rather than re-posts their notifications during teardown.
+ val sessionsToClean = activeSessions.values.toList()
+ activeSessions.clear()
+ sessionsToClean.forEach { (session, job) ->
+ notificationManager.cancel(session.id.hashCode())
+ session.unregisterFrom(this)
+ job.cancel()
+ }
+ serviceScope.cancel()
+ super.onDestroy()
+ }
+
+ private suspend fun reconcileSessions(configuredEntities: List) {
+ Timber.d(
+ "reconcileSessions: received ${configuredEntities.size} entities=${configuredEntities.map {
+ it.entityId
+ }}",
+ )
+
+ if (configuredEntities.isEmpty()) {
+ Timber.d("No media control entities configured, stopping service")
+ withContext(Dispatchers.Main) { stopSelf() }
+ return
+ }
+
+ // All activeSessions reads and writes are confined to the Main thread to avoid data races
+ // with onUpdateNotification and onTaskRemoved, which are always called on Main by the OS
+ // and Media3. The diff is computed here on Main as well since it operates on small sets.
+ withContext(Dispatchers.Main) {
+ val desiredKeys = configuredEntities.map { it.sessionKey() }.toSet()
+ val currentKeys = activeSessions.keys.toSet()
+
+ Timber.d("reconcileSessions: desiredKeys=$desiredKeys, currentKeys=$currentKeys")
+
+ val toRemove = (currentKeys - desiredKeys).mapNotNull { key ->
+ val pair = activeSessions.remove(key) ?: return@mapNotNull null
+ key to pair
+ }
+ val toAdd = (desiredKeys - currentKeys).map { key ->
+ val entityConfig = configuredEntities.first { it.sessionKey() == key }
+ key to haMediaSessionFactory.create(config = entityConfig)
+ }
+
+ Timber.d("reconcileSessions: toRemove=${toRemove.map { it.first }}, toAdd=${toAdd.map { it.first }}")
+
+ toRemove.forEach { (key, pair) -> tearDownSession(key, pair) }
+ toAdd.forEach { (key, session) -> launchSession(key, session) }
+
+ Timber.d("reconcileSessions: done, activeSessions=${activeSessions.keys.toList()}")
+ }
+ }
+
+ /**
+ * Cancels the notification for a session, unregisters it from the service, and joins the
+ * observation coroutine so all Media3 resources are released before returning.
+ * Must be called from the Main dispatcher.
+ */
+ private suspend fun tearDownSession(key: String, pair: Pair) {
+ val (haSession, job) = pair
+ val notificationId = key.hashCode()
+ notificationManager.cancel(notificationId)
+ if (foregroundNotificationId == notificationId) {
+ promoteForegroundOrStop(excludeKey = key)
+ }
+ haSession.unregisterFrom(this)
+ job.cancelAndJoin()
+ Timber.d("Removed media session for $key")
+ }
+
+ /**
+ * Launches the observation coroutine for a new session, registers it with the service, and
+ * stores it in [activeSessions].
+ * Must be called from the Main dispatcher (required by [MediaSessionService.addSession]).
+ */
+ private fun launchSession(key: String, session: HaMediaSession) {
+ val job = serviceScope.launch {
+ session.observe { mediaSession -> addSession(mediaSession) }
+ }
+ activeSessions[key] = session to job
+ Timber.d("Added media session for $key")
+ }
+
+ /**
+ * Promotes a remaining active session to the foreground notification when the current
+ * foreground session is removed or goes idle. If no active session has media content,
+ * stops the foreground state.
+ *
+ * @param excludeKey The map key of the session being removed, to skip it when searching
+ * for a replacement.
+ */
+ private fun promoteForegroundOrStop(excludeKey: String) {
+ val nextSession = activeSessions.entries
+ .firstOrNull { (key, pair) -> key != excludeKey && pair.first.hasActiveMedia }
+ ?.value?.first
+
+ if (nextSession != null) {
+ val nextId = nextSession.id.hashCode()
+ val notification = nextSession.buildNotification() ?: return
+ startForeground(nextId, notification)
+ foregroundNotificationId = nextId
+ Timber.d("promoteForegroundOrStop: promoted session ${nextSession.id}")
+ } else {
+ ServiceCompat.stopForeground(this, ServiceCompat.STOP_FOREGROUND_REMOVE)
+ foregroundNotificationId = null
+ Timber.d("promoteForegroundOrStop: no active sessions, stopped foreground")
+ }
+ }
+
+ private fun createNotificationChannel() {
+ val channel = NotificationChannel(
+ HaMediaSession.NOTIFICATION_CHANNEL_ID,
+ getString(commonR.string.media_controls),
+ NotificationManager.IMPORTANCE_LOW,
+ ).apply {
+ setShowBadge(false)
+ }
+ notificationManager.createNotificationChannel(channel)
+ }
+
+ companion object {
+ /**
+ * Starts the service. Should be called from a foreground context (e.g. Activity) to avoid
+ * Android 15+ restrictions on starting mediaPlayback foreground services from background.
+ * If no entities are configured the service will stop itself immediately after starting.
+ * Once running, the service observes the database and reconciles sessions automatically.
+ */
+ fun start(context: Context) {
+ Timber.d("Starting HaMediaSessionService")
+ try {
+ context.startService(Intent(context, HaMediaSessionService::class.java))
+ } catch (e: Exception) {
+ Timber.e(e, "Failed to start HaMediaSessionService")
+ }
+ }
+ }
+}
+
+private fun MediaControlEntityConfig.sessionKey(): String = "$serverId:$entityId"
diff --git a/app/src/main/kotlin/io/homeassistant/companion/android/mediacontrol/HaRemoteMediaPlayer.kt b/app/src/main/kotlin/io/homeassistant/companion/android/mediacontrol/HaRemoteMediaPlayer.kt
new file mode 100644
index 00000000000..2331573dfbd
--- /dev/null
+++ b/app/src/main/kotlin/io/homeassistant/companion/android/mediacontrol/HaRemoteMediaPlayer.kt
@@ -0,0 +1,328 @@
+package io.homeassistant.companion.android.mediacontrol
+
+import android.os.Looper
+import androidx.annotation.MainThread
+import androidx.annotation.OptIn
+import androidx.media3.common.DeviceInfo
+import androidx.media3.common.MediaMetadata
+import androidx.media3.common.PlaybackParameters
+import androidx.media3.common.Player
+import androidx.media3.common.SimpleBasePlayer
+import androidx.media3.common.util.UnstableApi
+import com.google.common.util.concurrent.Futures
+import com.google.common.util.concurrent.ListenableFuture
+import com.google.common.util.concurrent.SettableFuture
+import io.homeassistant.companion.android.common.data.mediacontrol.MediaControlState
+import io.homeassistant.companion.android.common.data.mediacontrol.MediaPlaybackState
+import io.homeassistant.companion.android.common.data.mediacontrol.MediaRepeatMode
+import kotlinx.coroutines.CancellationException
+import kotlinx.coroutines.Job
+
+/**
+ * A [SimpleBasePlayer] that acts as a remote control proxy for a Home Assistant media_player entity.
+ * It does not play audio itself — it reports state and translates playback commands into callbacks.
+ *
+ * This class is not thread-safe. All public methods must be called on the looper thread passed to
+ * the constructor, which is enforced by [SimpleBasePlayer].
+ */
+@OptIn(UnstableApi::class)
+internal class HaRemoteMediaPlayer(looper: Looper, private val commandCallback: CommandCallback) :
+ SimpleBasePlayer(looper) {
+
+ /**
+ * Callback interface for translating player commands into HA service calls.
+ * Each method returns the [Job] for the launched coroutine so that [handleCommand] can
+ * tie the [ListenableFuture] lifetime to the coroutine's completion.
+ */
+ interface CommandCallback {
+ fun onPlayRequested(): Job
+ fun onPauseRequested(): Job
+ fun onStopRequested(): Job
+ fun onSeekRequested(positionMs: Long): Job
+ fun onNextRequested(): Job
+ fun onPreviousRequested(): Job
+
+ fun onSetVolumeRequested(volume: Float): Job
+ fun onIncreaseVolumeRequested(): Job
+ fun onDecreaseVolumeRequested(): Job
+ fun onMuteRequested(muted: Boolean): Job
+
+ fun onShuffleRequested(shuffle: Boolean): Job
+ fun onRepeatRequested(repeatMode: MediaRepeatMode): Job
+ }
+
+ private var mediaState: MediaControlState? = null
+ private var artworkBytes: ByteArray? = null
+ private var isConnecting: Boolean = false
+
+ /**
+ * Updates the internal state from a new [MediaControlState] and triggers a state refresh.
+ * Must be called on the looper thread passed to the constructor.
+ * @param artworkPngBytes Pre-compressed PNG bytes for album art (compress off main thread).
+ */
+ @MainThread
+ fun updateState(state: MediaControlState?, artworkPngBytes: ByteArray?) {
+ isConnecting = false
+ mediaState = state
+ artworkBytes = artworkPngBytes
+ invalidateState()
+ }
+
+ /**
+ * Signals that the connection to HA has been lost and is being retried.
+ * Transitions to [STATE_BUFFERING] with the last known metadata visible but all
+ * interactive commands disabled, so the notification stays visible without showing
+ * stale controls.
+ * Must be called on the looper thread passed to the constructor.
+ */
+ @MainThread
+ fun setConnecting() {
+ isConnecting = true
+ invalidateState()
+ }
+
+ override fun getState(): State {
+ if (isConnecting) return buildConnectingState()
+ val state = mediaState ?: return buildIdleState()
+ return buildConnectedState(state, artworkBytes)
+ }
+
+ private fun buildConnectedState(state: MediaControlState, artwork: ByteArray?): State {
+ val availableCommands = buildAvailableCommands(state)
+
+ val playbackState = when (state.playbackState) {
+ is MediaPlaybackState.Playing -> STATE_READY
+ is MediaPlaybackState.Paused -> STATE_READY
+ is MediaPlaybackState.Buffering -> STATE_BUFFERING
+ is MediaPlaybackState.Idle -> STATE_ENDED
+ is MediaPlaybackState.Off -> STATE_IDLE
+ }
+
+ val isPlaying = state.playbackState is MediaPlaybackState.Playing
+
+ val durationUs = state.mediaDuration?.inWholeMicroseconds ?: DURATION_UNSET_US
+ val positionMs = state.mediaPosition?.inWholeMilliseconds ?: 0L
+
+ val currentItem = MediaItemData.Builder(state.entityId)
+ .setMediaMetadata(buildMetadata(state, artwork))
+ .setDurationUs(durationUs)
+ .build()
+
+ val deviceVolume = state.volumeLevel?.let { (it * VOLUME_SCALE).toInt() } ?: 0
+
+ val media3RepeatMode = when (state.repeatMode) {
+ is MediaRepeatMode.Off -> Player.REPEAT_MODE_OFF
+ is MediaRepeatMode.One -> Player.REPEAT_MODE_ONE
+ is MediaRepeatMode.All -> Player.REPEAT_MODE_ALL
+ }
+
+ return State.Builder()
+ .setAvailableCommands(availableCommands)
+ .setPlaybackState(playbackState)
+ .setPlayWhenReady(isPlaying, PLAY_WHEN_READY_CHANGE_REASON_REMOTE)
+ .setPlaybackParameters(PlaybackParameters(PLAYBACK_SPEED))
+ .setCurrentMediaItemIndex(CURRENT_ITEM_INDEX)
+ .setContentPositionMs(positionMs)
+ .setPlaylist(buildPlaylist(currentItem))
+ .setDeviceInfo(REMOTE_DEVICE_INFO)
+ .setDeviceVolume(deviceVolume)
+ .setIsDeviceMuted(state.isVolumeMuted)
+ .setShuffleModeEnabled(state.shuffle)
+ .setRepeatMode(media3RepeatMode)
+ .build()
+ }
+
+ private fun buildMetadata(state: MediaControlState, artwork: ByteArray?): MediaMetadata {
+ val builder = MediaMetadata.Builder()
+ .setTitle(state.title)
+ .setArtist(state.artist)
+ .setAlbumTitle(state.albumName)
+ .setAlbumArtist(state.albumArtist)
+ .setTrackNumber(state.mediaTrack)
+ .setStation(state.mediaChannel)
+ .setSubtitle(state.mediaSeriesTitle ?: state.appName)
+ .setMediaType(state.mediaContentType?.toMedia3MediaType())
+ artwork?.let { builder.setArtworkData(it, MediaMetadata.PICTURE_TYPE_FRONT_COVER) }
+ return builder.build()
+ }
+
+ private fun buildPlaylist(currentItem: MediaItemData): List = listOf(currentItem)
+
+ override fun handleSetPlayWhenReady(playWhenReady: Boolean): ListenableFuture<*> = handleCommand {
+ if (playWhenReady) commandCallback.onPlayRequested() else commandCallback.onPauseRequested()
+ }
+
+ override fun handleSeek(mediaItemIndex: Int, positionMs: Long, seekCommand: Int): ListenableFuture<*> =
+ handleCommand {
+ when (seekCommand) {
+ Player.COMMAND_SEEK_TO_NEXT,
+ Player.COMMAND_SEEK_TO_NEXT_MEDIA_ITEM,
+ -> commandCallback.onNextRequested()
+
+ Player.COMMAND_SEEK_TO_PREVIOUS,
+ Player.COMMAND_SEEK_TO_PREVIOUS_MEDIA_ITEM,
+ -> commandCallback.onPreviousRequested()
+
+ else -> {
+ if (mediaState?.supportsSeek == true) {
+ commandCallback.onSeekRequested(positionMs)
+ } else {
+ null
+ }
+ }
+ }
+ }
+
+ override fun handleSetDeviceVolume(deviceVolume: Int, flags: Int): ListenableFuture<*> =
+ handleCommand { commandCallback.onSetVolumeRequested(volume = deviceVolume / VOLUME_SCALE.toFloat()) }
+
+ override fun handleIncreaseDeviceVolume(flags: Int): ListenableFuture<*> =
+ handleCommand { commandCallback.onIncreaseVolumeRequested() }
+
+ override fun handleDecreaseDeviceVolume(flags: Int): ListenableFuture<*> =
+ handleCommand { commandCallback.onDecreaseVolumeRequested() }
+
+ override fun handleSetDeviceMuted(muted: Boolean, flags: Int): ListenableFuture<*> = handleCommand {
+ if (mediaState?.supportsMute == true) {
+ commandCallback.onMuteRequested(muted = muted)
+ } else {
+ null
+ }
+ }
+
+ override fun handleStop(): ListenableFuture<*> = handleCommand { commandCallback.onStopRequested() }
+
+ override fun handleSetShuffleModeEnabled(shuffleModeEnabled: Boolean): ListenableFuture<*> =
+ handleCommand { commandCallback.onShuffleRequested(shuffle = shuffleModeEnabled) }
+
+ override fun handleSetRepeatMode(repeatMode: Int): ListenableFuture<*> = handleCommand {
+ val haRepeatMode = when (repeatMode) {
+ Player.REPEAT_MODE_ONE -> MediaRepeatMode.One
+ Player.REPEAT_MODE_ALL -> MediaRepeatMode.All
+ else -> MediaRepeatMode.Off
+ }
+ commandCallback.onRepeatRequested(repeatMode = haRepeatMode)
+ }
+
+ /**
+ * Executes [block] to launch a command coroutine and returns a [ListenableFuture] tied to
+ * that coroutine's [Job]. The future completes when the coroutine finishes (success or
+ * cancellation) and fails if the coroutine throws. Keeping the future pending until the
+ * network call finishes prevents [SimpleBasePlayer] from calling [getState] prematurely,
+ * which preserves the position extrapolation anchor and avoids a seek bar jump.
+ *
+ * [CancellationException] from the [Job] is treated as success (the command was sent; the
+ * scope was cancelled). If [block] itself throws before returning a [Job], returns an
+ * immediate failed future.
+ */
+ private inline fun handleCommand(block: () -> Job?): ListenableFuture {
+ val job = try {
+ block()
+ } catch (e: Exception) {
+ return Futures.immediateFailedFuture(e)
+ } ?: return Futures.immediateFuture(null)
+ val future = SettableFuture.create()
+ job.invokeOnCompletion { cause ->
+ when (cause) {
+ null, is CancellationException -> future.set(null)
+ else -> future.setException(cause)
+ }
+ }
+ return future
+ }
+
+ private fun buildIdleState(): State = State.Builder()
+ .setAvailableCommands(Player.Commands.EMPTY)
+ .setPlaybackState(STATE_IDLE)
+ .setPlayWhenReady(false, PLAY_WHEN_READY_CHANGE_REASON_REMOTE)
+ .setDeviceInfo(REMOTE_DEVICE_INFO)
+ .build()
+
+ /**
+ * Builds a buffering state that keeps the last known metadata visible while the
+ * connection is being re-established. All interactive commands are disabled so the
+ * user cannot act on stale state.
+ */
+ private fun buildConnectingState(): State {
+ val state = mediaState ?: return buildIdleState()
+ val currentItem = MediaItemData.Builder(state.entityId)
+ .setMediaMetadata(buildMetadata(state, artworkBytes))
+ .build()
+ return State.Builder()
+ .setAvailableCommands(Player.Commands.EMPTY)
+ .setPlaybackState(STATE_BUFFERING)
+ .setPlayWhenReady(false, PLAY_WHEN_READY_CHANGE_REASON_REMOTE)
+ .setCurrentMediaItemIndex(CURRENT_ITEM_INDEX)
+ .setPlaylist(buildPlaylist(currentItem))
+ .setDeviceInfo(REMOTE_DEVICE_INFO)
+ .build()
+ }
+
+ private fun buildAvailableCommands(state: MediaControlState): Player.Commands {
+ val builder = Player.Commands.Builder()
+ if (state.supportsPlay || state.supportsPause) builder.add(Player.COMMAND_PLAY_PAUSE)
+ if (state.supportsStop) builder.add(Player.COMMAND_STOP)
+ if (state.supportsSeek) {
+ builder.add(Player.COMMAND_SEEK_IN_CURRENT_MEDIA_ITEM)
+ builder.add(Player.COMMAND_SEEK_TO_DEFAULT_POSITION)
+ builder.add(Player.COMMAND_SEEK_BACK)
+ builder.add(Player.COMMAND_SEEK_FORWARD)
+ }
+ builder.add(Player.COMMAND_GET_CURRENT_MEDIA_ITEM)
+ if (state.supportsPreviousTrack) {
+ builder.add(Player.COMMAND_SEEK_TO_PREVIOUS)
+ builder.add(Player.COMMAND_SEEK_TO_PREVIOUS_MEDIA_ITEM)
+ }
+ if (state.supportsNextTrack) {
+ builder.add(Player.COMMAND_SEEK_TO_NEXT)
+ builder.add(Player.COMMAND_SEEK_TO_NEXT_MEDIA_ITEM)
+ }
+ if (state.supportsVolumeSet) {
+ builder.add(Player.COMMAND_GET_DEVICE_VOLUME)
+ // Both the deprecated and _WITH_FLAGS variants are required: the deprecated ones are
+ // checked by Media3's MediaSessionLegacyStub when setting up VolumeProviderCompat
+ // (which drives the SystemUI device-chip volume slider), while the _WITH_FLAGS variants
+ // are used by newer clients and the volume button key-event path.
+ @Suppress("DEPRECATION")
+ builder.add(Player.COMMAND_SET_DEVICE_VOLUME)
+ builder.add(Player.COMMAND_SET_DEVICE_VOLUME_WITH_FLAGS)
+ @Suppress("DEPRECATION")
+ builder.add(Player.COMMAND_ADJUST_DEVICE_VOLUME)
+ builder.add(Player.COMMAND_ADJUST_DEVICE_VOLUME_WITH_FLAGS)
+ }
+ if (state.supportsShuffleSet) builder.add(Player.COMMAND_SET_SHUFFLE_MODE)
+ if (state.supportsRepeatSet) builder.add(Player.COMMAND_SET_REPEAT_MODE)
+ builder.add(Player.COMMAND_GET_METADATA)
+ builder.add(Player.COMMAND_GET_TIMELINE)
+ return builder.build()
+ }
+
+ /**
+ * Maps a Home Assistant media_content_type string to the corresponding Media3 media type
+ * constant, or null if there is no suitable mapping.
+ */
+ private fun String.toMedia3MediaType(): Int? = when (this) {
+ "music" -> MediaMetadata.MEDIA_TYPE_MUSIC
+ "tvshow", "episode" -> MediaMetadata.MEDIA_TYPE_TV_SHOW
+ "movie" -> MediaMetadata.MEDIA_TYPE_MOVIE
+ "video" -> MediaMetadata.MEDIA_TYPE_VIDEO
+ "channel" -> MediaMetadata.MEDIA_TYPE_TV_CHANNEL
+ "playlist" -> MediaMetadata.MEDIA_TYPE_PLAYLIST
+ else -> null
+ }
+
+ private companion object {
+ const val DURATION_UNSET_US = androidx.media3.common.C.TIME_UNSET
+ const val CURRENT_ITEM_INDEX = 0
+ const val PLAYBACK_SPEED = 1.0f
+
+ // HA uses 0.0–1.0; we tell Media3 our volume range is 0–VOLUME_SCALE via
+ // REMOTE_DEVICE_INFO, so Media3 will call handleSetDeviceVolume with values in that range.
+ const val VOLUME_SCALE = 100
+
+ val REMOTE_DEVICE_INFO: DeviceInfo = DeviceInfo.Builder(DeviceInfo.PLAYBACK_TYPE_REMOTE)
+ .setMinVolume(0)
+ .setMaxVolume(VOLUME_SCALE)
+ .build()
+ }
+}
diff --git a/app/src/main/kotlin/io/homeassistant/companion/android/settings/SettingsFragment.kt b/app/src/main/kotlin/io/homeassistant/companion/android/settings/SettingsFragment.kt
index 55e580b4d75..64e194a11d7 100644
--- a/app/src/main/kotlin/io/homeassistant/companion/android/settings/SettingsFragment.kt
+++ b/app/src/main/kotlin/io/homeassistant/companion/android/settings/SettingsFragment.kt
@@ -41,6 +41,7 @@ import io.homeassistant.companion.android.settings.developer.DeveloperSettingsFr
import io.homeassistant.companion.android.settings.gestures.GesturesFragment
import io.homeassistant.companion.android.settings.language.LanguagesProvider
import io.homeassistant.companion.android.settings.license.LicensesFragment
+import io.homeassistant.companion.android.settings.mediacontrol.MediaControlSettingsFragment
import io.homeassistant.companion.android.settings.notification.NotificationChannelFragment
import io.homeassistant.companion.android.settings.notification.NotificationHistoryFragment
import io.homeassistant.companion.android.settings.qs.ManageTilesFragment
@@ -234,6 +235,21 @@ class SettingsFragment(
}
}
+ findPreference("media_controls")?.let {
+ it.isVisible = !isAutomotive && !QuestUtil.isQuest
+ }
+ findPreference("manage_media_controls")?.setOnPreferenceClickListener {
+ parentFragmentManager.commit {
+ replace(
+ R.id.content,
+ MediaControlSettingsFragment::class.java,
+ null,
+ )
+ addToBackStack(getString(commonR.string.media_controls))
+ }
+ return@setOnPreferenceClickListener true
+ }
+
if (!isAutomotive && Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
findPreference("device_controls")?.let {
it.isVisible = true
diff --git a/app/src/main/kotlin/io/homeassistant/companion/android/settings/mediacontrol/MediaControlSettingsFragment.kt b/app/src/main/kotlin/io/homeassistant/companion/android/settings/mediacontrol/MediaControlSettingsFragment.kt
new file mode 100644
index 00000000000..9880613229b
--- /dev/null
+++ b/app/src/main/kotlin/io/homeassistant/companion/android/settings/mediacontrol/MediaControlSettingsFragment.kt
@@ -0,0 +1,54 @@
+package io.homeassistant.companion.android.settings.mediacontrol
+
+import android.os.Bundle
+import android.view.LayoutInflater
+import android.view.View
+import android.view.ViewGroup
+import androidx.compose.ui.platform.ComposeView
+import androidx.fragment.app.Fragment
+import androidx.fragment.app.viewModels
+import androidx.lifecycle.Lifecycle
+import androidx.lifecycle.lifecycleScope
+import androidx.lifecycle.repeatOnLifecycle
+import dagger.hilt.android.AndroidEntryPoint
+import io.homeassistant.companion.android.common.R as commonR
+import io.homeassistant.companion.android.common.compose.theme.HATheme
+import io.homeassistant.companion.android.mediacontrol.HaMediaSessionService
+import io.homeassistant.companion.android.settings.addHelpMenuProvider
+import io.homeassistant.companion.android.settings.mediacontrol.views.MediaControlSettingsScreen
+import kotlinx.coroutines.launch
+
+@AndroidEntryPoint
+class MediaControlSettingsFragment : Fragment() {
+ private val viewModel: MediaControlSettingsViewModel by viewModels()
+
+ override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
+ return ComposeView(requireContext()).apply {
+ setContent {
+ HATheme {
+ MediaControlSettingsScreen(viewModel = viewModel)
+ }
+ }
+ }
+ }
+
+ override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
+ addHelpMenuProvider("https://companion.home-assistant.io/docs/integrations/android-media-controls")
+ viewLifecycleOwner.lifecycleScope.launch {
+ viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) {
+ viewModel.serviceEvents.collect { event ->
+ when (event) {
+ MediaControlServiceEvent.Start -> {
+ HaMediaSessionService.start(requireContext())
+ }
+ }
+ }
+ }
+ }
+ }
+
+ override fun onResume() {
+ super.onResume()
+ activity?.title = getString(commonR.string.media_controls)
+ }
+}
diff --git a/app/src/main/kotlin/io/homeassistant/companion/android/settings/mediacontrol/MediaControlSettingsViewModel.kt b/app/src/main/kotlin/io/homeassistant/companion/android/settings/mediacontrol/MediaControlSettingsViewModel.kt
new file mode 100644
index 00000000000..388a6eec054
--- /dev/null
+++ b/app/src/main/kotlin/io/homeassistant/companion/android/settings/mediacontrol/MediaControlSettingsViewModel.kt
@@ -0,0 +1,260 @@
+package io.homeassistant.companion.android.settings.mediacontrol
+
+import android.app.Application
+import androidx.annotation.VisibleForTesting
+import androidx.compose.runtime.Stable
+import androidx.lifecycle.AndroidViewModel
+import androidx.lifecycle.viewModelScope
+import com.mikepenz.iconics.typeface.IIcon
+import dagger.hilt.android.lifecycle.HiltViewModel
+import io.homeassistant.companion.android.common.data.integration.Entity
+import io.homeassistant.companion.android.common.data.integration.IntegrationDomains.MEDIA_PLAYER_DOMAIN
+import io.homeassistant.companion.android.common.data.integration.friendlyName
+import io.homeassistant.companion.android.common.data.integration.getIcon
+import io.homeassistant.companion.android.common.data.mediacontrol.MediaControlEntityConfig
+import io.homeassistant.companion.android.common.data.mediacontrol.MediaControlRepository
+import io.homeassistant.companion.android.common.data.servers.ServerManager
+import io.homeassistant.companion.android.common.data.websocket.impl.entities.AreaRegistryResponse
+import io.homeassistant.companion.android.common.data.websocket.impl.entities.DeviceRegistryResponse
+import io.homeassistant.companion.android.common.data.websocket.impl.entities.EntityRegistryResponse
+import io.homeassistant.companion.android.database.server.Server
+import javax.inject.Inject
+import kotlinx.coroutines.CancellationException
+import kotlinx.coroutines.CoroutineDispatcher
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.async
+import kotlinx.coroutines.awaitAll
+import kotlinx.coroutines.flow.MutableSharedFlow
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.SharedFlow
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.asSharedFlow
+import kotlinx.coroutines.flow.asStateFlow
+import kotlinx.coroutines.flow.update
+import kotlinx.coroutines.launch
+import timber.log.Timber
+
+/** One-shot events emitted by [MediaControlSettingsViewModel] for the UI layer to act on. */
+sealed interface MediaControlServiceEvent {
+ data object Start : MediaControlServiceEvent
+}
+
+@Stable
+data class MediaControlSettingsUiState(
+ val servers: List = emptyList(),
+ // All loaded entities/registries per server, used by the entity picker
+ val entitiesPerServer: Map> = emptyMap(),
+ val entityRegistryPerServer: Map> = emptyMap(),
+ val deviceRegistryPerServer: Map> = emptyMap(),
+ val areaRegistryPerServer: Map> = emptyMap(),
+ // The in-memory list of entities being configured
+ val configuredEntities: List = emptyList(),
+ // Precomputed friendly names for each configured entity; absent means not yet loaded
+ val entityNamesByConfig: Map = emptyMap(),
+ // Precomputed icons for each configured entity; absent means not yet loaded
+ val entityIconsByConfig: Map = emptyMap(),
+ // Entities for the selected server that are not yet configured, ready for the picker
+ val availableEntities: List = emptyList(),
+ // Server selection for the entity picker
+ val selectedServerId: Int = ServerManager.SERVER_ID_ACTIVE,
+ // True while entities and registries are being loaded from the server
+ val isLoading: Boolean = true,
+) {
+ fun entityRegistryForServer(serverId: Int): List =
+ entityRegistryPerServer[serverId] ?: emptyList()
+ fun deviceRegistryForServer(serverId: Int): List =
+ deviceRegistryPerServer[serverId] ?: emptyList()
+ fun areaRegistryForServer(serverId: Int): List =
+ areaRegistryPerServer[serverId] ?: emptyList()
+}
+
+@HiltViewModel
+class MediaControlSettingsViewModel @VisibleForTesting constructor(
+ application: Application,
+ private val serverManager: ServerManager,
+ private val mediaControlRepository: MediaControlRepository,
+ private val backgroundDispatcher: CoroutineDispatcher,
+) : AndroidViewModel(application) {
+
+ @Inject
+ constructor(
+ application: Application,
+ serverManager: ServerManager,
+ mediaControlRepository: MediaControlRepository,
+ ) : this(application, serverManager, mediaControlRepository, Dispatchers.Default)
+
+ private val _uiState = MutableStateFlow(MediaControlSettingsUiState())
+ val uiState: StateFlow = _uiState.asStateFlow()
+
+ private val _serviceEvents = MutableSharedFlow(extraBufferCapacity = 1)
+ val serviceEvents: SharedFlow = _serviceEvents.asSharedFlow()
+
+ init {
+ viewModelScope.launch(backgroundDispatcher) {
+ val loadedServers = serverManager.servers()
+ val defaultServerId = serverManager.getServer()?.id ?: ServerManager.SERVER_ID_ACTIVE
+
+ // Load configured entities (local DB) and server registries (network) concurrently.
+ // Emit the configured list as soon as the DB read completes so the list appears immediately.
+ val configuredEntitiesDeferred = async { mediaControlRepository.getConfiguredEntities() }
+ val entitiesDeferred = loadedServers.map { server ->
+ async { server.id to loadMediaPlayerEntities(server.id) }
+ }
+ val entityRegistryDeferred = loadedServers.map { server ->
+ async {
+ server.id to loadRegistry(server.id, "entity registry") {
+ serverManager.webSocketRepository(it).getEntityRegistry()
+ }
+ }
+ }
+ val deviceRegistryDeferred = loadedServers.map { server ->
+ async {
+ server.id to loadRegistry(server.id, "device registry") {
+ serverManager.webSocketRepository(it).getDeviceRegistry()
+ }
+ }
+ }
+ val areaRegistryDeferred = loadedServers.map { server ->
+ async {
+ server.id to loadRegistry(server.id, "area registry") {
+ serverManager.webSocketRepository(it).getAreaRegistry()
+ }
+ }
+ }
+
+ val configuredEntities = configuredEntitiesDeferred.await()
+ _uiState.update {
+ it.copy(
+ servers = loadedServers,
+ selectedServerId = defaultServerId,
+ configuredEntities = configuredEntities,
+ )
+ }
+
+ val entitiesPerServer = entitiesDeferred.awaitAll().toMap()
+ _uiState.update { state ->
+ state.copy(
+ entitiesPerServer = entitiesPerServer,
+ entityRegistryPerServer = entityRegistryDeferred.awaitAll().toMap(),
+ deviceRegistryPerServer = deviceRegistryDeferred.awaitAll().toMap(),
+ areaRegistryPerServer = areaRegistryDeferred.awaitAll().toMap(),
+ entityNamesByConfig = buildEntityNamesByConfig(entitiesPerServer, state.configuredEntities),
+ entityIconsByConfig = buildEntityIconsByConfig(entitiesPerServer, state.configuredEntities),
+ isLoading = false,
+ )
+ }
+ updateAvailableEntities()
+ }
+ }
+
+ /** Updates the selected server in the entity picker. */
+ fun selectServerId(serverId: Int) {
+ _uiState.update { it.copy(selectedServerId = serverId) }
+ updateAvailableEntities()
+ }
+
+ /**
+ * Adds the entity identified by [entityId] from the currently selected server to the configured
+ * list, then persists the change immediately. Has no effect if the entity is already in the list.
+ */
+ fun addEntity(entityId: String) {
+ _uiState.update { state ->
+ val config = MediaControlEntityConfig(
+ serverId = state.selectedServerId,
+ entityId = entityId,
+ )
+ if (state.configuredEntities.contains(config)) {
+ state
+ } else {
+ val newConfiguredEntities = state.configuredEntities + config
+ state.copy(
+ configuredEntities = newConfiguredEntities,
+ entityNamesByConfig = buildEntityNamesByConfig(state.entitiesPerServer, newConfiguredEntities),
+ entityIconsByConfig = buildEntityIconsByConfig(state.entitiesPerServer, newConfiguredEntities),
+ )
+ }
+ }
+ updateAvailableEntities()
+ persistAndNotifyService()
+ }
+
+ /** Removes the configured entity at [index] from the list, then persists the change immediately. */
+ fun removeEntity(index: Int) {
+ _uiState.update { state ->
+ val newConfiguredEntities = state.configuredEntities.toMutableList().also { it.removeAt(index) }
+ state.copy(
+ configuredEntities = newConfiguredEntities,
+ entityNamesByConfig = buildEntityNamesByConfig(state.entitiesPerServer, newConfiguredEntities),
+ entityIconsByConfig = buildEntityIconsByConfig(state.entitiesPerServer, newConfiguredEntities),
+ )
+ }
+ updateAvailableEntities()
+ persistAndNotifyService()
+ }
+
+ private fun persistAndNotifyService() {
+ viewModelScope.launch {
+ val entities = _uiState.value.configuredEntities
+ mediaControlRepository.setConfiguredEntities(entities)
+ if (entities.isNotEmpty()) {
+ _serviceEvents.emit(MediaControlServiceEvent.Start)
+ }
+ }
+ }
+
+ private fun updateAvailableEntities() {
+ viewModelScope.launch(backgroundDispatcher) {
+ _uiState.update { state ->
+ val configuredForServer = state.configuredEntities
+ .filter { it.serverId == state.selectedServerId }
+ .mapTo(HashSet()) { it.entityId }
+ state.copy(
+ availableEntities = (state.entitiesPerServer[state.selectedServerId] ?: emptyList())
+ .filter { it.entityId !in configuredForServer },
+ )
+ }
+ }
+ }
+
+ private fun buildEntityIconsByConfig(
+ entitiesPerServer: Map>,
+ configuredEntities: List,
+ ): Map = configuredEntities.mapNotNull { config ->
+ val icon = entitiesPerServer[config.serverId]
+ ?.firstOrNull { it.entityId == config.entityId }
+ ?.getIcon(getApplication())
+ ?: return@mapNotNull null
+ config to icon
+ }.toMap()
+
+ private fun buildEntityNamesByConfig(
+ entitiesPerServer: Map>,
+ configuredEntities: List,
+ ): Map = configuredEntities.mapNotNull { config ->
+ val name = entitiesPerServer[config.serverId]
+ ?.firstOrNull { it.entityId == config.entityId }
+ ?.friendlyName
+ ?: return@mapNotNull null
+ config to name
+ }.toMap()
+
+ private suspend fun loadMediaPlayerEntities(serverId: Int): List = try {
+ serverManager.integrationRepository(serverId).getEntities().orEmpty()
+ .filter { it.domain == MEDIA_PLAYER_DOMAIN }
+ } catch (e: CancellationException) {
+ throw e
+ } catch (e: Exception) {
+ Timber.e(e, "Couldn't load media_player entities for server $serverId")
+ emptyList()
+ }
+
+ private suspend fun loadRegistry(serverId: Int, name: String, loader: suspend (Int) -> List?): List =
+ try {
+ loader(serverId).orEmpty()
+ } catch (e: CancellationException) {
+ throw e
+ } catch (e: Exception) {
+ Timber.e(e, "Couldn't load $name for server")
+ emptyList()
+ }
+}
diff --git a/app/src/main/kotlin/io/homeassistant/companion/android/settings/mediacontrol/views/MediaControlSettingsScreen.kt b/app/src/main/kotlin/io/homeassistant/companion/android/settings/mediacontrol/views/MediaControlSettingsScreen.kt
new file mode 100644
index 00000000000..f048023439e
--- /dev/null
+++ b/app/src/main/kotlin/io/homeassistant/companion/android/settings/mediacontrol/views/MediaControlSettingsScreen.kt
@@ -0,0 +1,242 @@
+package io.homeassistant.companion.android.settings.mediacontrol.views
+
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.PaddingValues
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.heightIn
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.lazy.LazyColumn
+import androidx.compose.foundation.lazy.itemsIndexed
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.Clear
+import androidx.compose.material3.Surface
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.ColorFilter
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.text.style.TextAlign
+import androidx.compose.ui.tooling.preview.Preview
+import androidx.lifecycle.compose.collectAsStateWithLifecycle
+import com.mikepenz.iconics.compose.Image
+import com.mikepenz.iconics.typeface.IIcon
+import io.homeassistant.companion.android.common.R
+import io.homeassistant.companion.android.common.compose.composable.ButtonVariant
+import io.homeassistant.companion.android.common.compose.composable.HADropdownItem
+import io.homeassistant.companion.android.common.compose.composable.HADropdownMenu
+import io.homeassistant.companion.android.common.compose.composable.HAIconButton
+import io.homeassistant.companion.android.common.compose.composable.HALoading
+import io.homeassistant.companion.android.common.compose.theme.HADimens
+import io.homeassistant.companion.android.common.compose.theme.HATextStyle
+import io.homeassistant.companion.android.common.compose.theme.HAThemeForPreview
+import io.homeassistant.companion.android.common.compose.theme.LocalHAColorScheme
+import io.homeassistant.companion.android.common.data.mediacontrol.MediaControlEntityConfig
+import io.homeassistant.companion.android.settings.mediacontrol.MediaControlSettingsUiState
+import io.homeassistant.companion.android.settings.mediacontrol.MediaControlSettingsViewModel
+import io.homeassistant.companion.android.util.compose.entity.EntityPicker
+import io.homeassistant.companion.android.util.plus
+import io.homeassistant.companion.android.util.safeBottomPaddingValues
+
+/** Outer composable that extracts state from the ViewModel and delegates to the stateless content. */
+@Composable
+fun MediaControlSettingsScreen(viewModel: MediaControlSettingsViewModel, modifier: Modifier = Modifier) {
+ val uiState by viewModel.uiState.collectAsStateWithLifecycle()
+ MediaControlSettingsContent(
+ uiState = uiState,
+ onServerSelected = viewModel::selectServerId,
+ onEntitySelected = viewModel::addEntity,
+ onRemoveEntity = viewModel::removeEntity,
+ modifier = modifier,
+ )
+}
+
+@Composable
+internal fun MediaControlSettingsContent(
+ uiState: MediaControlSettingsUiState,
+ onServerSelected: (Int) -> Unit,
+ onEntitySelected: (String) -> Unit,
+ onRemoveEntity: (Int) -> Unit,
+ modifier: Modifier = Modifier,
+) {
+ val colorScheme = LocalHAColorScheme.current
+
+ LazyColumn(
+ contentPadding = PaddingValues(vertical = HADimens.SPACE4) + safeBottomPaddingValues(applyHorizontal = false),
+ modifier = modifier,
+ ) {
+ item {
+ Text(
+ text = stringResource(R.string.media_control_description),
+ style = HATextStyle.Body,
+ color = colorScheme.colorTextPrimary,
+ textAlign = TextAlign.Start,
+ modifier = Modifier.padding(horizontal = HADimens.SPACE4),
+ )
+ Spacer(modifier = Modifier.size(HADimens.SPACE4))
+ }
+
+ if (uiState.isLoading) {
+ item {
+ Box(
+ modifier = Modifier.fillMaxWidth().padding(top = HADimens.SPACE4),
+ contentAlignment = Alignment.Center,
+ ) {
+ HALoading()
+ }
+ }
+ } else {
+ if (uiState.servers.size > 1) {
+ item(key = "server_dropdown") {
+ Column(modifier = Modifier.animateItem()) {
+ HADropdownMenu(
+ items = uiState.servers.map { HADropdownItem(key = it.id, label = it.friendlyName) },
+ selectedKey = uiState.selectedServerId,
+ onItemSelected = onServerSelected,
+ label = stringResource(R.string.server),
+ modifier = Modifier.fillMaxWidth().padding(horizontal = HADimens.SPACE4),
+ )
+ Spacer(modifier = Modifier.size(HADimens.SPACE2))
+ }
+ }
+ }
+
+ item(key = "entity_picker") {
+ EntityPicker(
+ entities = uiState.availableEntities,
+ selectedEntityId = null,
+ onEntitySelectedId = onEntitySelected,
+ onEntityCleared = {},
+ addButtonText = stringResource(R.string.media_control_select_entity),
+ entityRegistry = uiState.entityRegistryForServer(uiState.selectedServerId),
+ deviceRegistry = uiState.deviceRegistryForServer(uiState.selectedServerId),
+ areaRegistry = uiState.areaRegistryForServer(uiState.selectedServerId),
+ modifier = Modifier.padding(horizontal = HADimens.SPACE4).animateItem(),
+ )
+ }
+
+ itemsIndexed(
+ items = uiState.configuredEntities,
+ key = { _, config -> config.listKey() },
+ ) { index, config ->
+ ConfiguredEntityRow(
+ config = config,
+ subtitle = config.entityId,
+ entityName = uiState.entityNamesByConfig[config],
+ entityIcon = uiState.entityIconsByConfig[config],
+ onRemove = { onRemoveEntity(index) },
+ modifier = Modifier.animateItem(),
+ )
+ }
+ }
+ }
+}
+
+@Composable
+private fun ConfiguredEntityRow(
+ config: MediaControlEntityConfig,
+ subtitle: String?,
+ entityName: String?,
+ entityIcon: IIcon?,
+ onRemove: () -> Unit,
+ modifier: Modifier = Modifier,
+) {
+ val colorScheme = LocalHAColorScheme.current
+ val displayName = entityName ?: config.entityId
+
+ Surface(modifier = modifier, color = colorScheme.colorSurfaceLow, shadowElevation = HADimens.SPACE0) {
+ Row(
+ verticalAlignment = Alignment.CenterVertically,
+ modifier = Modifier
+ .fillMaxWidth()
+ .heightIn(min = HADimens.SPACE18)
+ .padding(vertical = HADimens.SPACE1),
+ ) {
+ if (entityIcon != null) {
+ Image(
+ asset = entityIcon,
+ colorFilter = ColorFilter.tint(colorScheme.colorTextSecondary),
+ contentDescription = null,
+ modifier = Modifier
+ .padding(start = HADimens.SPACE4)
+ .size(HADimens.SPACE6),
+ )
+ }
+ Column(
+ modifier = Modifier
+ .weight(1f)
+ .padding(start = if (entityIcon != null) HADimens.SPACE2 else HADimens.SPACE4),
+ ) {
+ Text(
+ text = displayName,
+ style = HATextStyle.Body,
+ color = colorScheme.colorTextPrimary,
+ textAlign = TextAlign.Start,
+ )
+ if (subtitle != null) {
+ Text(
+ text = subtitle,
+ style = HATextStyle.BodyMedium,
+ color = colorScheme.colorTextSecondary,
+ textAlign = TextAlign.Start,
+ )
+ }
+ }
+ HAIconButton(
+ icon = Icons.Default.Clear,
+ onClick = onRemove,
+ contentDescription = stringResource(R.string.media_control_remove_entity),
+ variant = ButtonVariant.NEUTRAL,
+ )
+ }
+ }
+}
+
+@Preview
+@Composable
+private fun MediaControlSettingsContentLoadingPreview() {
+ HAThemeForPreview {
+ MediaControlSettingsContent(
+ uiState = MediaControlSettingsUiState(isLoading = true),
+ onServerSelected = {},
+ onEntitySelected = {},
+ onRemoveEntity = {},
+ )
+ }
+}
+
+@Preview
+@Composable
+private fun MediaControlSettingsContentEmptyPreview() {
+ HAThemeForPreview {
+ MediaControlSettingsContent(
+ uiState = MediaControlSettingsUiState(isLoading = false),
+ onServerSelected = {},
+ onEntitySelected = {},
+ onRemoveEntity = {},
+ )
+ }
+}
+
+@Preview
+@Composable
+private fun MediaControlSettingsContentWithEntitiesPreview() {
+ HAThemeForPreview {
+ MediaControlSettingsContent(
+ uiState = MediaControlSettingsUiState(
+ configuredEntities = listOf(
+ MediaControlEntityConfig(serverId = 1, entityId = "media_player.living_room"),
+ MediaControlEntityConfig(serverId = 1, entityId = "media_player.bedroom"),
+ ),
+ ),
+ onServerSelected = {},
+ onEntitySelected = {},
+ onRemoveEntity = {},
+ )
+ }
+}
diff --git a/app/src/main/kotlin/io/homeassistant/companion/android/webview/WebViewActivity.kt b/app/src/main/kotlin/io/homeassistant/companion/android/webview/WebViewActivity.kt
index 6a1ede4c700..9935ef9d2f0 100644
--- a/app/src/main/kotlin/io/homeassistant/companion/android/webview/WebViewActivity.kt
+++ b/app/src/main/kotlin/io/homeassistant/companion/android/webview/WebViewActivity.kt
@@ -130,6 +130,7 @@ import io.homeassistant.companion.android.frontend.js.FrontendJsBridge.Companion
import io.homeassistant.companion.android.improv.ui.ImprovPermissionDialog
import io.homeassistant.companion.android.improv.ui.ImprovSetupDialog
import io.homeassistant.companion.android.launch.LaunchActivity
+import io.homeassistant.companion.android.mediacontrol.HaMediaSessionService
import io.homeassistant.companion.android.nfc.WriteNfcTag
import io.homeassistant.companion.android.sensors.SensorReceiver
import io.homeassistant.companion.android.sensors.SensorWorker
@@ -1354,6 +1355,7 @@ class WebViewActivity :
lifecycleScope.launch {
SensorWorker.start(this@WebViewActivity)
WebsocketManager.start(this@WebViewActivity)
+ HaMediaSessionService.start(this@WebViewActivity)
WebView.setWebContentsDebuggingEnabled(BuildConfig.DEBUG || presenter.isWebViewDebugEnabled())
diff --git a/app/src/main/res/drawable/ic_play_circle_outline.xml b/app/src/main/res/drawable/ic_play_circle_outline.xml
new file mode 100644
index 00000000000..136420a14e8
--- /dev/null
+++ b/app/src/main/res/drawable/ic_play_circle_outline.xml
@@ -0,0 +1,7 @@
+
+
+
diff --git a/app/src/main/res/xml/changelog_master.xml b/app/src/main/res/xml/changelog_master.xml
index adff1239a8d..acec42247f3 100755
--- a/app/src/main/res/xml/changelog_master.xml
+++ b/app/src/main/res/xml/changelog_master.xml
@@ -2,6 +2,7 @@
+ Add native media controls in notification shade for HA media player entities
Bug fixes and dependency updates
diff --git a/app/src/main/res/xml/preferences.xml b/app/src/main/res/xml/preferences.xml
index 90faa41c188..cc1243ec614 100644
--- a/app/src/main/res/xml/preferences.xml
+++ b/app/src/main/res/xml/preferences.xml
@@ -155,6 +155,16 @@
android:title="@string/aa_favorites"
android:summary="@string/aa_favorites_summary" />
+
+
+
>
+
+ // A non-completing SharedFlow: observeEntityState() suspends indefinitely by default so that
+ // HaMediaSession.observe() doesn't exit normally, keeping the session alive in getSessions().
+ // MediaSession.release() auto-removes the session from getSessions(), so we need it alive.
+ private val entityStateFlow = MutableSharedFlow(replay = 0)
+
+ private lateinit var observationScope: CoroutineScope
+ private lateinit var service: HaMediaSessionService
+
+ @Before
+ fun setUp() {
+ configuredEntitiesFlow = MutableSharedFlow(replay = 1)
+ observationScope = CoroutineScope(SupervisorJob() + UnconfinedTestDispatcher())
+
+ every { mediaControlRepository.observeConfiguredEntities() } returns configuredEntitiesFlow
+ coEvery { mediaControlRepository.observeEntityState(any()) } returns entityStateFlow
+
+ // Each session is created without a scope — HaMediaSession.observe() derives its scope
+ // from the coroutine that calls it (observationScope with UnconfinedTestDispatcher).
+ every { haMediaSessionFactory.create(any()) } answers {
+ HaMediaSession(
+ context = ApplicationProvider.getApplicationContext(),
+ config = firstArg(),
+ mediaControlRepository = mediaControlRepository,
+ serverManager = serverManager,
+ )
+ }
+
+ service = Robolectric.buildService(HaMediaSessionService::class.java).get()
+ service.mediaControlRepository = mediaControlRepository
+ service.haMediaSessionFactory = haMediaSessionFactory
+
+ // Replace the private serviceScope field with the test-controlled scope so that
+ // startObservingEntities() and all session coroutines run on the UnconfinedTestDispatcher.
+ val scopeField = HaMediaSessionService::class.java.getDeclaredField("serviceScope")
+ scopeField.isAccessible = true
+ scopeField.set(service, observationScope)
+ }
+
+ @After
+ fun tearDown() {
+ // Cancelling observationScope cancels all session observation coroutines, which triggers
+ // each HaMediaSession.observe() finally block → session.release() → auto-removed from
+ // getSessions(). onDestroy() is not called here to avoid double-calling it in tests that
+ // explicitly invoke it (e.g. the onDestroy lifecycle test).
+ observationScope.cancel()
+ // Drain the main looper so that the withContext(NonCancellable + Dispatchers.Main) calls
+ // in the observe() finally blocks complete and session.release() runs before the next test
+ // class starts. Without this, MediaSession IDs linger in Media3's global registry and
+ // cause "Session ID must be unique" failures in subsequent test classes.
+ idleMainLooper()
+ }
+
+ /**
+ * Starts [HaMediaSessionService.startObservingEntities] with the test-controlled
+ * [observationScope] (already set via reflection in [setUp]) as the service scope. Because
+ * [configuredEntitiesFlow] uses replay=1 and [observationScope] uses [UnconfinedTestDispatcher],
+ * the subscriber receives any pre-emitted value immediately and reconciliation runs synchronously.
+ * Call [idleMainLooper] after this to flush any Main-thread tasks posted by [HaMediaSession]
+ * (e.g. [HaRemoteMediaPlayer.updateState]).
+ */
+ private fun startObserving() {
+ service.startObservingEntities()
+ }
+
+ /**
+ * Drains the Robolectric main looper so that tasks posted via [withContext(Dispatchers.Main)]
+ * from within [HaMediaSession] (e.g. [HaRemoteMediaPlayer.updateState] dispatched by
+ * [HaMediaSession.startObservingState]) take effect before assertions.
+ *
+ * Robolectric's [shadowOf(Looper.getMainLooper()).idle()] processes nested posts too, so a
+ * single call is sufficient even when multiple tasks are queued in sequence.
+ */
+ private fun idleMainLooper() {
+ shadowOf(Looper.getMainLooper()).idle()
+ }
+
+ private fun uniqueConfig(): MediaControlEntityConfig {
+ val id = sessionCounter.incrementAndGet()
+ return MediaControlEntityConfig(serverId = 1, entityId = "media_player.test_$id")
+ }
+
+ private fun createPlayingState(entityId: String) = MediaControlState(
+ entityId = entityId,
+ serverId = 1,
+ playbackState = MediaPlaybackState.Playing,
+ title = "Test Track",
+ artist = null,
+ albumName = null,
+ entityPictureUrl = null,
+ mediaDuration = null,
+ mediaPosition = null,
+ supportsPause = true,
+ supportsPlay = true,
+ supportsSeek = false,
+ supportsPreviousTrack = false,
+ supportsNextTrack = false,
+ supportsVolumeSet = false,
+ supportsStop = false,
+ supportsMute = false,
+ supportsShuffleSet = false,
+ supportsRepeatSet = false,
+ volumeLevel = null,
+ isVolumeMuted = false,
+ shuffle = false,
+ repeatMode = MediaRepeatMode.Off,
+ entityFriendlyName = null,
+ )
+
+ // -- Reconciliation via flow emissions --
+
+ @Test
+ fun `Given new entity in config when flow emits then session is added`() {
+ val config = uniqueConfig()
+ configuredEntitiesFlow.tryEmit(listOf(config))
+ startObserving()
+ idleMainLooper()
+
+ assertEquals(1, service.getSessions().size)
+ assertTrue(service.getSessions().any { it.id == "1_${config.entityId}" })
+ }
+
+ @Test
+ fun `Given two entities in config when flow emits then sessions are added for each`() {
+ val configA = uniqueConfig()
+ val configB = uniqueConfig()
+ configuredEntitiesFlow.tryEmit(listOf(configA, configB))
+ startObserving()
+ idleMainLooper()
+
+ assertEquals(2, service.getSessions().size)
+ assertTrue(service.getSessions().any { it.id == "1_${configA.entityId}" })
+ assertTrue(service.getSessions().any { it.id == "1_${configB.entityId}" })
+ }
+
+ @Test
+ fun `Given active session when entity removed from config then session is removed`() {
+ val configA = uniqueConfig()
+ val configB = uniqueConfig()
+ configuredEntitiesFlow.tryEmit(listOf(configA, configB))
+ startObserving()
+ idleMainLooper()
+
+ configuredEntitiesFlow.tryEmit(listOf(configB))
+ idleMainLooper()
+
+ assertEquals(1, service.getSessions().size)
+ assertTrue(service.getSessions().any { it.id == "1_${configB.entityId}" })
+ }
+
+ @Test
+ fun `Given existing session when entity remains in config then session is not recreated`() {
+ val config = uniqueConfig()
+ configuredEntitiesFlow.tryEmit(listOf(config))
+ startObserving()
+ idleMainLooper()
+ val sessionBefore = service.getSessions().first()
+
+ configuredEntitiesFlow.tryEmit(listOf(config))
+ idleMainLooper()
+
+ assertEquals(1, service.getSessions().size)
+ assertSame(sessionBefore, service.getSessions().first())
+ }
+
+ @Test
+ fun `Given empty config when flow emits then service stops itself`() {
+ configuredEntitiesFlow.tryEmit(emptyList())
+ startObserving()
+ idleMainLooper()
+
+ assertTrue(Shadows.shadowOf(service).isStoppedBySelf)
+ }
+
+ // -- onTaskRemoved --
+
+ @Test
+ fun `Given no active sessions when onTaskRemoved then service stops`() {
+ service.onTaskRemoved(rootIntent = null)
+
+ assertTrue(Shadows.shadowOf(service).isStoppedBySelf)
+ }
+
+ @Test
+ fun `Given active session not playing when onTaskRemoved then service stops`() {
+ val config = uniqueConfig()
+ // Session starts in idle state: playWhenReady=false, mediaItemCount=0
+ configuredEntitiesFlow.tryEmit(listOf(config))
+ startObserving()
+ idleMainLooper()
+
+ service.onTaskRemoved(rootIntent = null)
+
+ assertTrue(Shadows.shadowOf(service).isStoppedBySelf)
+ }
+
+ @Test
+ fun `Given active session playing when onTaskRemoved then service does not stop`() {
+ val config = uniqueConfig()
+ // Pre-load a Playing state with replay=1 so the session's startObservingState() receives
+ // it immediately when it subscribes, before idleMainLooper flushes player.updateState().
+ val stateFlow = MutableSharedFlow(replay = 1)
+ stateFlow.tryEmit(createPlayingState(config.entityId))
+ coEvery { mediaControlRepository.observeEntityState(any()) } returns stateFlow
+
+ configuredEntitiesFlow.tryEmit(listOf(config))
+ startObserving()
+ // idleMainLooper processes both reconcileSessions (from the entities flow) and
+ // player.updateState (posted back to Main by loadArtworkAndUpdatePlayer inside
+ // startObservingState). Robolectric's idle() drains all queued and nested tasks.
+ idleMainLooper()
+
+ service.onTaskRemoved(rootIntent = null)
+
+ assertFalse(Shadows.shadowOf(service).isStoppedBySelf)
+ }
+
+ // -- onDestroy --
+
+ @Test
+ fun `Given active sessions when onDestroy then all sessions are released and map is cleared`() {
+ val config = uniqueConfig()
+ configuredEntitiesFlow.tryEmit(listOf(config))
+ startObserving()
+ idleMainLooper()
+ assertEquals(1, service.getSessions().size)
+
+ // onDestroy() calls removeSession() explicitly for each active session before cancelling
+ // the observation jobs, so getSessions() is empty immediately after the call.
+ service.onDestroy()
+ idleMainLooper()
+
+ assertTrue(service.getSessions().isEmpty())
+ }
+}
diff --git a/app/src/test/kotlin/io/homeassistant/companion/android/mediacontrol/HaMediaSessionTest.kt b/app/src/test/kotlin/io/homeassistant/companion/android/mediacontrol/HaMediaSessionTest.kt
new file mode 100644
index 00000000000..834669d618a
--- /dev/null
+++ b/app/src/test/kotlin/io/homeassistant/companion/android/mediacontrol/HaMediaSessionTest.kt
@@ -0,0 +1,504 @@
+package io.homeassistant.companion.android.mediacontrol
+
+import android.os.Looper
+import androidx.media3.common.Player
+import androidx.test.core.app.ApplicationProvider
+import dagger.hilt.android.testing.HiltTestApplication
+import io.homeassistant.companion.android.common.data.integration.IntegrationDomains.MEDIA_PLAYER_DOMAIN
+import io.homeassistant.companion.android.common.data.integration.IntegrationRepository
+import io.homeassistant.companion.android.common.data.mediacontrol.MediaControlEntityConfig
+import io.homeassistant.companion.android.common.data.mediacontrol.MediaControlRepository
+import io.homeassistant.companion.android.common.data.mediacontrol.MediaControlState
+import io.homeassistant.companion.android.common.data.mediacontrol.MediaPlaybackState
+import io.homeassistant.companion.android.common.data.mediacontrol.MediaRepeatMode
+import io.homeassistant.companion.android.common.data.servers.ServerManager
+import io.mockk.coEvery
+import io.mockk.coVerify
+import io.mockk.mockk
+import io.mockk.slot
+import java.util.concurrent.atomic.AtomicInteger
+import kotlin.time.Duration.Companion.seconds
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.SupervisorJob
+import kotlinx.coroutines.cancel
+import kotlinx.coroutines.flow.MutableSharedFlow
+import kotlinx.coroutines.flow.flowOf
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.test.UnconfinedTestDispatcher
+import org.junit.After
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertNotNull
+import org.junit.Assert.assertNull
+import org.junit.Before
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.robolectric.RobolectricTestRunner
+import org.robolectric.Shadows.shadowOf
+import org.robolectric.annotation.Config
+
+private const val SERVER_ID = 1
+
+/** Counter used to generate unique MediaSession IDs across tests within the same JVM process. */
+private val sessionCounter = AtomicInteger(0)
+
+@RunWith(RobolectricTestRunner::class)
+@Config(application = HiltTestApplication::class)
+class HaMediaSessionTest {
+
+ private lateinit var testScope: CoroutineScope
+ private lateinit var mediaControlRepository: MediaControlRepository
+ private lateinit var serverManager: ServerManager
+ private lateinit var integrationRepository: IntegrationRepository
+ private lateinit var config: MediaControlEntityConfig
+
+ @After
+ fun tearDown() {
+ // Cancel all test coroutines and drain the main looper so that the observe() finally
+ // block's withContext(NonCancellable + Dispatchers.Main) call completes and
+ // session.release() runs. Without this, MediaSession IDs linger in Media3's global
+ // registry and cause "Session ID must be unique" failures in subsequent test classes.
+ testScope.cancel()
+ idleMainLooper()
+ }
+
+ @Before
+ fun setUp() {
+ @OptIn(ExperimentalCoroutinesApi::class)
+ testScope = CoroutineScope(SupervisorJob() + UnconfinedTestDispatcher())
+ mediaControlRepository = mockk()
+ serverManager = mockk()
+ integrationRepository = mockk(relaxed = true)
+
+ val uniqueEntityId = "media_player.test_${sessionCounter.incrementAndGet()}"
+ config = MediaControlEntityConfig(serverId = SERVER_ID, entityId = uniqueEntityId)
+
+ coEvery { mediaControlRepository.observeEntityState(config) } returns flowOf()
+ coEvery { serverManager.integrationRepository(SERVER_ID) } returns integrationRepository
+ }
+
+ private fun createState(
+ playbackState: MediaPlaybackState = MediaPlaybackState.Playing,
+ title: String? = "Test Title",
+ entityPictureUrl: String? = null,
+ ) = MediaControlState(
+ entityId = config.entityId,
+ serverId = SERVER_ID,
+ playbackState = playbackState,
+ title = title,
+ artist = null,
+ albumName = null,
+ entityPictureUrl = entityPictureUrl,
+ mediaDuration = 300.0.seconds,
+ mediaPosition = 60.0.seconds,
+ supportsPause = true,
+ supportsPlay = true,
+ supportsSeek = false,
+ supportsPreviousTrack = false,
+ supportsNextTrack = false,
+ supportsVolumeSet = false,
+ supportsStop = false,
+ supportsMute = false,
+ supportsShuffleSet = false,
+ supportsRepeatSet = false,
+ volumeLevel = null,
+ isVolumeMuted = false,
+ shuffle = false,
+ repeatMode = MediaRepeatMode.Off,
+ entityFriendlyName = null,
+ )
+
+ private fun buildSession(): HaMediaSession = HaMediaSession(
+ context = ApplicationProvider.getApplicationContext(),
+ config = config,
+ mediaControlRepository = mediaControlRepository,
+ serverManager = serverManager,
+ )
+
+ /**
+ * Drains the Robolectric main looper so that `player.updateState` calls dispatched via
+ * `withContext(Dispatchers.Main)` take effect.
+ *
+ * `testScope` uses [UnconfinedTestDispatcher], so coroutines run eagerly on the calling
+ * thread until they reach a `withContext(Dispatchers.Main)` suspension point. A single
+ * `idle()` is enough to flush those pending main-looper tasks and resume the coroutine.
+ */
+ private fun idleMainLooper() {
+ shadowOf(Looper.getMainLooper()).idle()
+ }
+
+ // -- State observation tests --
+
+ /**
+ * Verifies the cold-start recovery path: when `observeEntityState` emits the current state
+ * first (as a REST pre-fetch inside the repository) followed by null (WebSocket not ready)
+ * but stays open, the player retains the emitted state and does not drop to idle.
+ *
+ * Uses a `MutableSharedFlow` with `replay=1` so emissions are received by the Default
+ * dispatcher collector without racing, and the flow stays open.
+ */
+ @Test
+ fun `Given observeEntityState emits state then null when startObservingState then player retains initial state`() {
+ val stateFlow = MutableSharedFlow(replay = 1)
+ stateFlow.tryEmit(createState(playbackState = MediaPlaybackState.Playing))
+ coEvery { mediaControlRepository.observeEntityState(config) } returns stateFlow
+
+ val session = buildSession()
+ var capturedSession: androidx.media3.session.MediaSession? = null
+ val job = testScope.launch {
+ session.observe { capturedSession = it }
+ }
+ idleMainLooper()
+
+ val player = capturedSession?.player
+ assertEquals(Player.STATE_READY, player?.playbackState)
+ assertEquals(true, player?.playWhenReady)
+
+ // Emitting null afterwards (simulating WebSocket-not-ready) should not clear state
+ stateFlow.tryEmit(null)
+ idleMainLooper()
+
+ assertEquals(Player.STATE_READY, player?.playbackState)
+ assertEquals(true, player?.playWhenReady)
+
+ job.cancel()
+ }
+
+ /**
+ * Verifies that when `observeEntityState` emits a playing state, the player transitions
+ * to STATE_READY with `playWhenReady = true`.
+ *
+ * Uses `replay=1` so the emission is cached and replayed to the collector on
+ * [UnconfinedTestDispatcher] regardless of when it subscribes. The flow stays open.
+ */
+ @Test
+ fun `Given observeEntityState emits playing state when startObservingState then player is ready and playing`() {
+ val stateFlow = MutableSharedFlow(replay = 1)
+ stateFlow.tryEmit(createState(playbackState = MediaPlaybackState.Playing))
+ coEvery { mediaControlRepository.observeEntityState(config) } returns stateFlow
+
+ val session = buildSession()
+ var capturedSession: androidx.media3.session.MediaSession? = null
+ val job = testScope.launch {
+ session.observe { capturedSession = it }
+ }
+ idleMainLooper()
+
+ val player = capturedSession?.player
+ assertEquals(Player.STATE_READY, player?.playbackState)
+ assertEquals(true, player?.playWhenReady)
+
+ job.cancel()
+ }
+
+ /**
+ * Verifies that when `observeEntityState` emits a paused state, the player transitions
+ * to STATE_READY with `playWhenReady = false`.
+ *
+ * Uses `replay=1` so the emission is cached and replayed to the late collector.
+ */
+ @Test
+ fun `Given observeEntityState emits paused state when startObservingState then player is ready and not playing`() {
+ val stateFlow = MutableSharedFlow(replay = 1)
+ stateFlow.tryEmit(createState(playbackState = MediaPlaybackState.Paused))
+ coEvery { mediaControlRepository.observeEntityState(config) } returns stateFlow
+
+ val session = buildSession()
+ var capturedSession: androidx.media3.session.MediaSession? = null
+ val job = testScope.launch {
+ session.observe { capturedSession = it }
+ }
+ idleMainLooper()
+
+ val player = capturedSession?.player
+ assertEquals(Player.STATE_READY, player?.playbackState)
+ assertEquals(false, player?.playWhenReady)
+
+ job.cancel()
+ }
+
+ /**
+ * Verifies that when `observeEntityState` flow completes naturally (e.g. WebSocket disconnected),
+ * the session stays alive — `observe()` keeps running via `awaitCancellation()` and
+ * `mediaSession` remains non-null so the notification is not removed.
+ */
+ @Test
+ fun `Given observeEntityState flow completes when startObservingState then session stays alive`() {
+ coEvery { mediaControlRepository.observeEntityState(config) } returns flowOf(
+ createState(playbackState = MediaPlaybackState.Playing),
+ )
+
+ val session = buildSession()
+ val job = testScope.launch {
+ session.observe { }
+ }
+ idleMainLooper()
+
+ // The observation job completed naturally but observe() is still suspended in
+ // awaitCancellation(), so the session and its notification remain active.
+ assertNotNull(session.buildNotification())
+ org.junit.Assert.assertTrue(job.isActive)
+
+ job.cancel()
+ }
+
+ /**
+ * Verifies that after the observation flow ends (simulating a WebSocket disconnect),
+ * a successful media command restarts state observation so future state updates are received.
+ *
+ * This covers the reconnection path: user taps play → REST call succeeds → `callMediaAction`
+ * sees `observationJob.isActive == false` and re-launches `startObservingState()`.
+ */
+ @Test
+ fun `Given observation ended when play requested and action succeeds then observation is restarted`() {
+ // First call: immediately-completing flow (simulates WebSocket disconnect).
+ // Second call: open flow that stays alive so we can verify it was subscribed to.
+ val reopenedFlow = MutableSharedFlow(replay = 1)
+ reopenedFlow.tryEmit(createState(playbackState = MediaPlaybackState.Paused))
+ var callCount = 0
+ coEvery { mediaControlRepository.observeEntityState(config) } answers {
+ callCount++
+ if (callCount == 1) {
+ flowOf(createState(playbackState = MediaPlaybackState.Playing))
+ } else {
+ reopenedFlow
+ }
+ }
+
+ val session = buildSession()
+ var capturedSession: androidx.media3.session.MediaSession? = null
+ val job = testScope.launch {
+ session.observe { capturedSession = it }
+ }
+ idleMainLooper()
+
+ // After the first flow completes, observation is inactive but observe() is still alive.
+ assertNotNull(session.buildNotification())
+
+ // Trigger a play command; the action succeeds (integrationRepository is relaxed).
+ capturedSession?.player?.play()
+ shadowOf(Looper.getMainLooper()).idle()
+
+ // observeEntityState should have been called a second time (observation restarted).
+ assertEquals(2, callCount)
+
+ job.cancel()
+ }
+
+ // -- Artwork caching tests --
+
+ /**
+ * Verifies that when the emitted state has a null artwork URL, the player's media metadata
+ * contains no artwork bytes.
+ *
+ * Uses `replay=1` so the emission is available immediately when the collector starts.
+ */
+ @Test
+ fun `Given state with null artwork URL when startObservingState then player artwork is null`() {
+ val stateFlow = MutableSharedFlow(replay = 1)
+ stateFlow.tryEmit(createState(entityPictureUrl = null))
+ coEvery { mediaControlRepository.observeEntityState(config) } returns stateFlow
+
+ val session = buildSession()
+ var capturedSession: androidx.media3.session.MediaSession? = null
+ val job = testScope.launch {
+ session.observe { capturedSession = it }
+ }
+ idleMainLooper()
+
+ val player = capturedSession?.player
+ assertNull(player?.mediaMetadata?.artworkData)
+
+ job.cancel()
+ }
+
+ /**
+ * Verifies that when a second state emission arrives with a null artwork URL, the player
+ * state still updates — the second state's title is applied and artwork stays null.
+ *
+ * Uses `replay=1` for reliable delivery to the collector. The second emission is made after
+ * the first is confirmed to be processed.
+ */
+ @Test
+ fun `Given two consecutive states both with null artwork URL when startObservingState then title updates and artwork stays null`() {
+ val stateFlow = MutableSharedFlow(replay = 1)
+ stateFlow.tryEmit(createState(entityPictureUrl = null, title = "Track 1"))
+ coEvery { mediaControlRepository.observeEntityState(config) } returns stateFlow
+
+ val session = buildSession()
+ var capturedSession: androidx.media3.session.MediaSession? = null
+ val job = testScope.launch {
+ session.observe { capturedSession = it }
+ }
+ idleMainLooper()
+
+ stateFlow.tryEmit(createState(entityPictureUrl = null, title = "Track 2"))
+ idleMainLooper()
+
+ val player = capturedSession?.player
+ assertNull(player?.mediaMetadata?.artworkData)
+ assertEquals("Track 2", player?.mediaMetadata?.title?.toString())
+
+ job.cancel()
+ }
+
+ // -- callMediaAction tests --
+
+ /**
+ * Verifies that triggering play on the media session player causes `callMediaAction` to
+ * dispatch a `media_play` action to the integration repository for the configured entity.
+ *
+ * Uses `replay=1` so the paused state is reliably received by the collector before
+ * `player.play()` is invoked. `callMediaAction` launches on [UnconfinedTestDispatcher] and
+ * runs eagerly inside the main looper drain, so no additional wait is required.
+ */
+ @Test
+ fun `Given paused player when play requested then media_play action is called`() {
+ val stateFlow = MutableSharedFlow(replay = 1)
+ stateFlow.tryEmit(createState(playbackState = MediaPlaybackState.Paused))
+ coEvery { mediaControlRepository.observeEntityState(config) } returns stateFlow
+
+ val session = buildSession()
+ var capturedSession: androidx.media3.session.MediaSession? = null
+ val job = testScope.launch {
+ session.observe { capturedSession = it }
+ }
+ idleMainLooper()
+
+ capturedSession?.player?.play()
+ shadowOf(Looper.getMainLooper()).idle()
+
+ val capturedDomain = slot()
+ val capturedAction = slot()
+ coVerify {
+ integrationRepository.callAction(
+ domain = capture(capturedDomain),
+ action = capture(capturedAction),
+ actionData = any(),
+ )
+ }
+ assertEquals(MEDIA_PLAYER_DOMAIN, capturedDomain.captured)
+ assertEquals("media_play", capturedAction.captured)
+
+ job.cancel()
+ }
+
+ /**
+ * Verifies that triggering pause dispatches a `media_pause` action to the integration
+ * repository.
+ *
+ * Uses `replay=1` so the playing state is reliably received before `player.pause()` is called.
+ */
+ @Test
+ fun `Given playing player when pause requested then media_pause action is called`() {
+ val stateFlow = MutableSharedFlow(replay = 1)
+ stateFlow.tryEmit(createState(playbackState = MediaPlaybackState.Playing))
+ coEvery { mediaControlRepository.observeEntityState(config) } returns stateFlow
+
+ val session = buildSession()
+ var capturedSession: androidx.media3.session.MediaSession? = null
+ val job = testScope.launch {
+ session.observe { capturedSession = it }
+ }
+ idleMainLooper()
+
+ capturedSession?.player?.pause()
+ shadowOf(Looper.getMainLooper()).idle()
+
+ val capturedAction = slot()
+ coVerify {
+ integrationRepository.callAction(
+ domain = any(),
+ action = capture(capturedAction),
+ actionData = any(),
+ )
+ }
+ assertEquals("media_pause", capturedAction.captured)
+
+ job.cancel()
+ }
+
+ /**
+ * Verifies that when `callAction` throws an exception, `callMediaAction` catches it and does
+ * not propagate the crash, while still having attempted the call.
+ *
+ * This guards the `catch (e: Exception)` branch at the end of `callMediaAction`, which ensures
+ * a transient network or server error never terminates the media session coroutine.
+ */
+ @Test
+ fun `Given callAction throws when play requested then exception is caught and does not crash`() {
+ val stateFlow = MutableSharedFlow(replay = 1)
+ stateFlow.tryEmit(createState(playbackState = MediaPlaybackState.Paused))
+ coEvery { mediaControlRepository.observeEntityState(config) } returns stateFlow
+ coEvery {
+ integrationRepository.callAction(any(), any(), any())
+ } throws RuntimeException("Simulated server error")
+
+ val session = buildSession()
+ var capturedSession: androidx.media3.session.MediaSession? = null
+ val job = testScope.launch {
+ session.observe { capturedSession = it }
+ }
+ idleMainLooper()
+
+ capturedSession?.player?.play()
+ shadowOf(Looper.getMainLooper()).idle()
+
+ coVerify {
+ integrationRepository.callAction(
+ domain = MEDIA_PLAYER_DOMAIN,
+ action = "media_play",
+ actionData = any(),
+ )
+ }
+
+ job.cancel()
+ }
+
+ // -- observe() lifecycle tests --
+
+ /**
+ * Verifies that the session is active (produces a notification) during observation and
+ * becomes inactive after the observing job is cancelled, confirming Media3 resources are released.
+ */
+ @Test
+ fun `Given observing session when job cancelled then session is no longer active`() {
+ val stateFlow = MutableSharedFlow(replay = 1)
+ stateFlow.tryEmit(createState(playbackState = MediaPlaybackState.Playing))
+ coEvery { mediaControlRepository.observeEntityState(config) } returns stateFlow
+
+ val session = buildSession()
+ val job = testScope.launch {
+ session.observe { }
+ }
+ idleMainLooper()
+
+ assertNotNull(session.buildNotification())
+
+ job.cancel()
+ idleMainLooper()
+
+ assertNull(session.buildNotification())
+ }
+
+ /**
+ * Verifies that [HaMediaSession.observe] calls [onSessionReady] with a non-null session
+ * before starting state observation.
+ */
+ @Test
+ fun `Given session when observe called then onSessionReady is invoked with the session`() {
+ val stateFlow = MutableSharedFlow()
+ coEvery { mediaControlRepository.observeEntityState(config) } returns stateFlow
+
+ val session = buildSession()
+ var capturedSession: androidx.media3.session.MediaSession? = null
+ val job = testScope.launch {
+ session.observe { capturedSession = it }
+ }
+ idleMainLooper()
+
+ assertNotNull(capturedSession)
+
+ job.cancel()
+ }
+}
diff --git a/app/src/test/kotlin/io/homeassistant/companion/android/mediacontrol/HaRemoteMediaPlayerTest.kt b/app/src/test/kotlin/io/homeassistant/companion/android/mediacontrol/HaRemoteMediaPlayerTest.kt
new file mode 100644
index 00000000000..d04044c8b8a
--- /dev/null
+++ b/app/src/test/kotlin/io/homeassistant/companion/android/mediacontrol/HaRemoteMediaPlayerTest.kt
@@ -0,0 +1,676 @@
+package io.homeassistant.companion.android.mediacontrol
+
+import android.os.Looper
+import androidx.media3.common.Player
+import io.homeassistant.companion.android.common.data.mediacontrol.MediaControlState
+import io.homeassistant.companion.android.common.data.mediacontrol.MediaPlaybackState
+import io.homeassistant.companion.android.common.data.mediacontrol.MediaRepeatMode
+import io.mockk.mockk
+import io.mockk.verify
+import kotlin.time.Duration
+import kotlin.time.Duration.Companion.seconds
+import org.junit.After
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertFalse
+import org.junit.Assert.assertNull
+import org.junit.Assert.assertTrue
+import org.junit.Before
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.robolectric.RobolectricTestRunner
+import org.robolectric.Shadows.shadowOf
+import org.robolectric.annotation.Config
+
+@RunWith(RobolectricTestRunner::class)
+@Config(application = dagger.hilt.android.testing.HiltTestApplication::class)
+class HaRemoteMediaPlayerTest {
+
+ private val commandCallback: HaRemoteMediaPlayer.CommandCallback = mockk(relaxed = true)
+ private lateinit var player: HaRemoteMediaPlayer
+
+ @After
+ fun tearDown() {
+ player.release()
+ shadowOf(Looper.getMainLooper()).idle()
+ }
+
+ @Before
+ fun setUp() {
+ player = HaRemoteMediaPlayer(Looper.getMainLooper(), commandCallback)
+ }
+
+ private fun createState(
+ playbackState: MediaPlaybackState = MediaPlaybackState.Playing,
+ title: String? = "Test Title",
+ artist: String? = "Test Artist",
+ albumName: String? = "Test Album",
+ entityPictureUrl: String? = null,
+ mediaDuration: Duration? = 300.0.seconds,
+ mediaPosition: Duration? = 120.0.seconds,
+ supportsPause: Boolean = true,
+ supportsPlay: Boolean = true,
+ supportsSeek: Boolean = true,
+ supportsPreviousTrack: Boolean = true,
+ supportsNextTrack: Boolean = true,
+ supportsVolumeSet: Boolean = false,
+ supportsStop: Boolean = false,
+ supportsMute: Boolean = false,
+ supportsShuffleSet: Boolean = false,
+ supportsRepeatSet: Boolean = false,
+ volumeLevel: Float? = null,
+ isVolumeMuted: Boolean = false,
+ shuffle: Boolean = false,
+ repeatMode: MediaRepeatMode = MediaRepeatMode.Off,
+ entityFriendlyName: String? = null,
+ albumArtist: String? = null,
+ mediaContentType: String? = null,
+ mediaTrack: Int? = null,
+ mediaChannel: String? = null,
+ mediaSeriesTitle: String? = null,
+ appName: String? = null,
+ ) = MediaControlState(
+ entityId = "media_player.test",
+ serverId = 1,
+ playbackState = playbackState,
+ title = title,
+ artist = artist,
+ albumName = albumName,
+ entityPictureUrl = entityPictureUrl,
+ mediaDuration = mediaDuration,
+ mediaPosition = mediaPosition,
+ supportsPause = supportsPause,
+ supportsPlay = supportsPlay,
+ supportsSeek = supportsSeek,
+ supportsPreviousTrack = supportsPreviousTrack,
+ supportsNextTrack = supportsNextTrack,
+ supportsVolumeSet = supportsVolumeSet,
+ supportsStop = supportsStop,
+ supportsMute = supportsMute,
+ supportsShuffleSet = supportsShuffleSet,
+ supportsRepeatSet = supportsRepeatSet,
+ volumeLevel = volumeLevel,
+ isVolumeMuted = isVolumeMuted,
+ shuffle = shuffle,
+ repeatMode = repeatMode,
+ entityFriendlyName = entityFriendlyName,
+ albumArtist = albumArtist,
+ mediaContentType = mediaContentType,
+ mediaTrack = mediaTrack,
+ mediaChannel = mediaChannel,
+ mediaSeriesTitle = mediaSeriesTitle,
+ appName = appName,
+ )
+
+ // -- getState tests --
+
+ @Test
+ fun `Given null state when getState then return idle state`() {
+ player.updateState(state = null, artworkPngBytes = null)
+ shadowOf(Looper.getMainLooper()).idle()
+
+ assertEquals(Player.STATE_IDLE, player.playbackState)
+ assertFalse(player.playWhenReady)
+ }
+
+ @Test
+ fun `Given playing state when getState then return ready with playWhenReady true`() {
+ player.updateState(state = createState(playbackState = MediaPlaybackState.Playing), artworkPngBytes = null)
+ shadowOf(Looper.getMainLooper()).idle()
+
+ assertEquals(Player.STATE_READY, player.playbackState)
+ assertTrue(player.playWhenReady)
+ }
+
+ @Test
+ fun `Given paused state when getState then return ready with playWhenReady false`() {
+ player.updateState(state = createState(playbackState = MediaPlaybackState.Paused), artworkPngBytes = null)
+ shadowOf(Looper.getMainLooper()).idle()
+
+ assertEquals(Player.STATE_READY, player.playbackState)
+ assertFalse(player.playWhenReady)
+ }
+
+ @Test
+ fun `Given buffering state when getState then return buffering`() {
+ player.updateState(state = createState(playbackState = MediaPlaybackState.Buffering), artworkPngBytes = null)
+ shadowOf(Looper.getMainLooper()).idle()
+
+ assertEquals(Player.STATE_BUFFERING, player.playbackState)
+ }
+
+ @Test
+ fun `Given idle state when getState then return ended`() {
+ player.updateState(state = createState(playbackState = MediaPlaybackState.Idle), artworkPngBytes = null)
+ shadowOf(Looper.getMainLooper()).idle()
+
+ assertEquals(Player.STATE_ENDED, player.playbackState)
+ }
+
+ @Test
+ fun `Given off state when getState then return idle`() {
+ player.updateState(state = createState(playbackState = MediaPlaybackState.Off), artworkPngBytes = null)
+ shadowOf(Looper.getMainLooper()).idle()
+
+ assertEquals(Player.STATE_IDLE, player.playbackState)
+ }
+
+ @Test
+ fun `Given state with metadata when getState then metadata is populated`() {
+ player.updateState(
+ state = createState(title = "My Song", artist = "My Artist", albumName = "My Album"),
+ artworkPngBytes = null,
+ )
+ shadowOf(Looper.getMainLooper()).idle()
+
+ val metadata = player.mediaMetadata
+ assertEquals("My Song", metadata.title?.toString())
+ assertEquals("My Artist", metadata.artist?.toString())
+ assertEquals("My Album", metadata.albumTitle?.toString())
+ }
+
+ @Test
+ fun `Given state with album artist when getState then albumArtist is populated`() {
+ player.updateState(
+ state = createState(albumArtist = "Various Artists"),
+ artworkPngBytes = null,
+ )
+ shadowOf(Looper.getMainLooper()).idle()
+
+ assertEquals("Various Artists", player.mediaMetadata.albumArtist?.toString())
+ }
+
+ @Test
+ fun `Given state with track number when getState then trackNumber is populated`() {
+ player.updateState(
+ state = createState(mediaTrack = 5),
+ artworkPngBytes = null,
+ )
+ shadowOf(Looper.getMainLooper()).idle()
+
+ assertEquals(5, player.mediaMetadata.trackNumber)
+ }
+
+ @Test
+ fun `Given state with channel when getState then station is populated`() {
+ player.updateState(
+ state = createState(mediaChannel = "BBC Radio 4"),
+ artworkPngBytes = null,
+ )
+ shadowOf(Looper.getMainLooper()).idle()
+
+ assertEquals("BBC Radio 4", player.mediaMetadata.station?.toString())
+ }
+
+ @Test
+ fun `Given state with series title when getState then subtitle is series title`() {
+ player.updateState(
+ state = createState(mediaSeriesTitle = "Breaking Bad", appName = "Plex"),
+ artworkPngBytes = null,
+ )
+ shadowOf(Looper.getMainLooper()).idle()
+
+ assertEquals("Breaking Bad", player.mediaMetadata.subtitle?.toString())
+ }
+
+ @Test
+ fun `Given state with app name but no series title when getState then subtitle is app name`() {
+ player.updateState(
+ state = createState(mediaSeriesTitle = null, appName = "Spotify"),
+ artworkPngBytes = null,
+ )
+ shadowOf(Looper.getMainLooper()).idle()
+
+ assertEquals("Spotify", player.mediaMetadata.subtitle?.toString())
+ }
+
+ @Test
+ fun `Given state with music content type when getState then mediaType is MEDIA_TYPE_MUSIC`() {
+ player.updateState(
+ state = createState(mediaContentType = "music"),
+ artworkPngBytes = null,
+ )
+ shadowOf(Looper.getMainLooper()).idle()
+
+ assertEquals(androidx.media3.common.MediaMetadata.MEDIA_TYPE_MUSIC, player.mediaMetadata.mediaType)
+ }
+
+ @Test
+ fun `Given state with tvshow content type when getState then mediaType is MEDIA_TYPE_TV_SHOW`() {
+ player.updateState(
+ state = createState(mediaContentType = "tvshow"),
+ artworkPngBytes = null,
+ )
+ shadowOf(Looper.getMainLooper()).idle()
+
+ assertEquals(androidx.media3.common.MediaMetadata.MEDIA_TYPE_TV_SHOW, player.mediaMetadata.mediaType)
+ }
+
+ @Test
+ fun `Given state with episode content type when getState then mediaType is MEDIA_TYPE_TV_SHOW`() {
+ player.updateState(
+ state = createState(mediaContentType = "episode"),
+ artworkPngBytes = null,
+ )
+ shadowOf(Looper.getMainLooper()).idle()
+
+ assertEquals(androidx.media3.common.MediaMetadata.MEDIA_TYPE_TV_SHOW, player.mediaMetadata.mediaType)
+ }
+
+ @Test
+ fun `Given state with unknown content type when getState then mediaType is null`() {
+ player.updateState(
+ state = createState(mediaContentType = "game"),
+ artworkPngBytes = null,
+ )
+ shadowOf(Looper.getMainLooper()).idle()
+
+ assertNull(player.mediaMetadata.mediaType)
+ }
+
+ @Test
+ fun `Given state with duration and position when getState then timeline has correct values`() {
+ player.updateState(
+ state = createState(mediaDuration = 300.0.seconds, mediaPosition = 120.0.seconds),
+ artworkPngBytes = null,
+ )
+ shadowOf(Looper.getMainLooper()).idle()
+
+ assertEquals(300_000L, player.duration)
+ assertEquals(120_000L, player.currentPosition)
+ }
+
+ // -- Available commands tests --
+
+ @Test
+ fun `Given play and pause supported when getState then play_pause command available`() {
+ player.updateState(state = createState(supportsPlay = true, supportsPause = true), artworkPngBytes = null)
+ shadowOf(Looper.getMainLooper()).idle()
+
+ assertTrue(player.availableCommands.contains(Player.COMMAND_PLAY_PAUSE))
+ }
+
+ @Test
+ fun `Given seek supported when getState then seek commands available`() {
+ player.updateState(state = createState(supportsSeek = true), artworkPngBytes = null)
+ shadowOf(Looper.getMainLooper()).idle()
+
+ assertTrue(player.availableCommands.contains(Player.COMMAND_SEEK_IN_CURRENT_MEDIA_ITEM))
+ }
+
+ @Test
+ fun `Given any state when getState then GET_CURRENT_MEDIA_ITEM always available`() {
+ player.updateState(state = createState(supportsSeek = false, mediaDuration = null), artworkPngBytes = null)
+ shadowOf(Looper.getMainLooper()).idle()
+
+ assertTrue(player.availableCommands.contains(Player.COMMAND_GET_CURRENT_MEDIA_ITEM))
+ }
+
+ @Test
+ fun `Given seek not supported when getState then seek command not available`() {
+ player.updateState(state = createState(supportsSeek = false, mediaDuration = 300.0.seconds), artworkPngBytes = null)
+ shadowOf(Looper.getMainLooper()).idle()
+
+ assertFalse(player.availableCommands.contains(Player.COMMAND_SEEK_IN_CURRENT_MEDIA_ITEM))
+ }
+
+ @Test
+ fun `Given next track supported when getState then next command available`() {
+ player.updateState(state = createState(supportsNextTrack = true), artworkPngBytes = null)
+ shadowOf(Looper.getMainLooper()).idle()
+
+ assertTrue(player.availableCommands.contains(Player.COMMAND_SEEK_TO_NEXT))
+ }
+
+ @Test
+ fun `Given previous track supported when getState then previous command available`() {
+ player.updateState(state = createState(supportsPreviousTrack = true), artworkPngBytes = null)
+ shadowOf(Looper.getMainLooper()).idle()
+
+ assertTrue(player.availableCommands.contains(Player.COMMAND_SEEK_TO_PREVIOUS))
+ }
+
+ // -- Command callback tests --
+
+ @Test
+ fun `Given player when play requested then callback onPlayRequested called`() {
+ player.updateState(state = createState(playbackState = MediaPlaybackState.Paused), artworkPngBytes = null)
+ shadowOf(Looper.getMainLooper()).idle()
+
+ player.play()
+ shadowOf(Looper.getMainLooper()).idle()
+
+ verify { commandCallback.onPlayRequested() }
+ }
+
+ @Test
+ fun `Given player when pause requested then callback onPauseRequested called`() {
+ player.updateState(state = createState(playbackState = MediaPlaybackState.Playing), artworkPngBytes = null)
+ shadowOf(Looper.getMainLooper()).idle()
+
+ player.pause()
+ shadowOf(Looper.getMainLooper()).idle()
+
+ verify { commandCallback.onPauseRequested() }
+ }
+
+ @Test
+ fun `Given player when seek requested then callback onSeekRequested called with position`() {
+ player.updateState(state = createState(), artworkPngBytes = null)
+ shadowOf(Looper.getMainLooper()).idle()
+
+ player.seekTo(60_000L)
+ shadowOf(Looper.getMainLooper()).idle()
+
+ verify { commandCallback.onSeekRequested(positionMs = 60_000L) }
+ }
+
+ @Test
+ fun `Given player when next track requested then callback onNextRequested called`() {
+ player.updateState(state = createState(), artworkPngBytes = null)
+ shadowOf(Looper.getMainLooper()).idle()
+
+ player.seekToNext()
+ shadowOf(Looper.getMainLooper()).idle()
+
+ verify { commandCallback.onNextRequested() }
+ }
+
+ @Test
+ fun `Given player when previous track requested then callback onPreviousRequested called`() {
+ player.updateState(state = createState(), artworkPngBytes = null)
+ shadowOf(Looper.getMainLooper()).idle()
+
+ player.seekToPrevious()
+ shadowOf(Looper.getMainLooper()).idle()
+
+ verify { commandCallback.onPreviousRequested() }
+ }
+
+ @Test
+ fun `Given active state when getState then playback speed is 1 for seek bar tracking`() {
+ player.updateState(state = createState(playbackState = MediaPlaybackState.Playing), artworkPngBytes = null)
+ shadowOf(Looper.getMainLooper()).idle()
+
+ assertEquals(1.0f, player.playbackParameters.speed)
+ }
+
+ // -- Volume command tests --
+
+ @Suppress("DEPRECATION")
+ @Test
+ fun `Given volume supported when getState then volume commands available`() {
+ player.updateState(state = createState(supportsVolumeSet = true, volumeLevel = 0.5f), artworkPngBytes = null)
+ shadowOf(Looper.getMainLooper()).idle()
+
+ assertTrue(player.availableCommands.contains(Player.COMMAND_GET_DEVICE_VOLUME))
+ assertTrue(player.availableCommands.contains(Player.COMMAND_SET_DEVICE_VOLUME))
+ assertTrue(player.availableCommands.contains(Player.COMMAND_SET_DEVICE_VOLUME_WITH_FLAGS))
+ assertTrue(player.availableCommands.contains(Player.COMMAND_ADJUST_DEVICE_VOLUME))
+ assertTrue(player.availableCommands.contains(Player.COMMAND_ADJUST_DEVICE_VOLUME_WITH_FLAGS))
+ }
+
+ @Suppress("DEPRECATION")
+ @Test
+ fun `Given volume not supported when getState then volume commands not available`() {
+ player.updateState(state = createState(supportsVolumeSet = false), artworkPngBytes = null)
+ shadowOf(Looper.getMainLooper()).idle()
+
+ assertFalse(player.availableCommands.contains(Player.COMMAND_GET_DEVICE_VOLUME))
+ assertFalse(player.availableCommands.contains(Player.COMMAND_SET_DEVICE_VOLUME))
+ assertFalse(player.availableCommands.contains(Player.COMMAND_SET_DEVICE_VOLUME_WITH_FLAGS))
+ assertFalse(player.availableCommands.contains(Player.COMMAND_ADJUST_DEVICE_VOLUME))
+ assertFalse(player.availableCommands.contains(Player.COMMAND_ADJUST_DEVICE_VOLUME_WITH_FLAGS))
+ }
+
+ @Test
+ fun `Given volumeLevel 0_5 when getState then deviceVolume is 50`() {
+ player.updateState(state = createState(supportsVolumeSet = true, volumeLevel = 0.5f), artworkPngBytes = null)
+ shadowOf(Looper.getMainLooper()).idle()
+
+ assertEquals(50, player.deviceVolume)
+ }
+
+ @Test
+ fun `Given isVolumeMuted true when getState then deviceMuted is true`() {
+ player.updateState(
+ state = createState(supportsVolumeSet = true, volumeLevel = 0.5f, isVolumeMuted = true),
+ artworkPngBytes = null,
+ )
+ shadowOf(Looper.getMainLooper()).idle()
+
+ assertTrue(player.isDeviceMuted)
+ }
+
+ @Test
+ fun `Given player when setDeviceVolume 50 then onSetVolumeRequested called with 0_5`() {
+ player.updateState(state = createState(supportsVolumeSet = true, volumeLevel = 0.5f), artworkPngBytes = null)
+ shadowOf(Looper.getMainLooper()).idle()
+
+ player.setDeviceVolume(50, 0)
+ shadowOf(Looper.getMainLooper()).idle()
+
+ verify { commandCallback.onSetVolumeRequested(volume = 0.5f) }
+ }
+
+ @Test
+ fun `Given player when increaseDeviceVolume then onIncreaseVolumeRequested called`() {
+ player.updateState(state = createState(supportsVolumeSet = true, volumeLevel = 0.5f), artworkPngBytes = null)
+ shadowOf(Looper.getMainLooper()).idle()
+
+ player.increaseDeviceVolume(0)
+ shadowOf(Looper.getMainLooper()).idle()
+
+ verify { commandCallback.onIncreaseVolumeRequested() }
+ }
+
+ @Test
+ fun `Given player when decreaseDeviceVolume then onDecreaseVolumeRequested called`() {
+ player.updateState(state = createState(supportsVolumeSet = true, volumeLevel = 0.5f), artworkPngBytes = null)
+ shadowOf(Looper.getMainLooper()).idle()
+
+ player.decreaseDeviceVolume(0)
+ shadowOf(Looper.getMainLooper()).idle()
+
+ verify { commandCallback.onDecreaseVolumeRequested() }
+ }
+
+ // -- Stop command tests --
+
+ @Test
+ fun `Given stop supported when getState then stop command available`() {
+ player.updateState(state = createState(supportsStop = true), artworkPngBytes = null)
+ shadowOf(Looper.getMainLooper()).idle()
+
+ assertTrue(player.availableCommands.contains(Player.COMMAND_STOP))
+ }
+
+ @Test
+ fun `Given stop not supported when getState then stop command not available`() {
+ player.updateState(state = createState(supportsStop = false), artworkPngBytes = null)
+ shadowOf(Looper.getMainLooper()).idle()
+
+ assertFalse(player.availableCommands.contains(Player.COMMAND_STOP))
+ }
+
+ @Test
+ fun `Given stop supported when stop requested then onStopRequested called`() {
+ player.updateState(state = createState(supportsStop = true), artworkPngBytes = null)
+ shadowOf(Looper.getMainLooper()).idle()
+
+ player.stop()
+ shadowOf(Looper.getMainLooper()).idle()
+
+ verify { commandCallback.onStopRequested() }
+ }
+
+ // -- Mute command tests --
+
+ @Test
+ fun `Given mute supported when mute requested then onMuteRequested called with true`() {
+ player.updateState(
+ state = createState(supportsVolumeSet = true, supportsMute = true, isVolumeMuted = false),
+ artworkPngBytes = null,
+ )
+ shadowOf(Looper.getMainLooper()).idle()
+
+ player.setDeviceMuted(true, 0)
+ shadowOf(Looper.getMainLooper()).idle()
+
+ verify { commandCallback.onMuteRequested(muted = true) }
+ }
+
+ @Test
+ fun `Given mute not supported when mute requested then onMuteRequested not called`() {
+ player.updateState(
+ state = createState(supportsVolumeSet = true, supportsMute = false),
+ artworkPngBytes = null,
+ )
+ shadowOf(Looper.getMainLooper()).idle()
+
+ player.setDeviceMuted(true, 0)
+ shadowOf(Looper.getMainLooper()).idle()
+
+ verify(exactly = 0) { commandCallback.onMuteRequested(any()) }
+ }
+
+ // -- Shuffle command tests --
+
+ @Test
+ fun `Given shuffle supported when getState then shuffle command available`() {
+ player.updateState(state = createState(supportsShuffleSet = true), artworkPngBytes = null)
+ shadowOf(Looper.getMainLooper()).idle()
+
+ assertTrue(player.availableCommands.contains(Player.COMMAND_SET_SHUFFLE_MODE))
+ }
+
+ @Test
+ fun `Given shuffle not supported when getState then shuffle command not available`() {
+ player.updateState(state = createState(supportsShuffleSet = false), artworkPngBytes = null)
+ shadowOf(Looper.getMainLooper()).idle()
+
+ assertFalse(player.availableCommands.contains(Player.COMMAND_SET_SHUFFLE_MODE))
+ }
+
+ @Test
+ fun `Given shuffle enabled in state when getState then shuffleModeEnabled is true`() {
+ player.updateState(state = createState(shuffle = true), artworkPngBytes = null)
+ shadowOf(Looper.getMainLooper()).idle()
+
+ assertTrue(player.shuffleModeEnabled)
+ }
+
+ @Test
+ fun `Given shuffle supported when shuffle enabled then onShuffleRequested called with true`() {
+ player.updateState(state = createState(supportsShuffleSet = true, shuffle = false), artworkPngBytes = null)
+ shadowOf(Looper.getMainLooper()).idle()
+
+ player.shuffleModeEnabled = true
+ shadowOf(Looper.getMainLooper()).idle()
+
+ verify { commandCallback.onShuffleRequested(shuffle = true) }
+ }
+
+ // -- Repeat command tests --
+
+ @Test
+ fun `Given repeat supported when getState then repeat command available`() {
+ player.updateState(state = createState(supportsRepeatSet = true), artworkPngBytes = null)
+ shadowOf(Looper.getMainLooper()).idle()
+
+ assertTrue(player.availableCommands.contains(Player.COMMAND_SET_REPEAT_MODE))
+ }
+
+ @Test
+ fun `Given repeat not supported when getState then repeat command not available`() {
+ player.updateState(state = createState(supportsRepeatSet = false), artworkPngBytes = null)
+ shadowOf(Looper.getMainLooper()).idle()
+
+ assertFalse(player.availableCommands.contains(Player.COMMAND_SET_REPEAT_MODE))
+ }
+
+ private fun assertRepeatModeRoundTrip(mediaRepeatMode: MediaRepeatMode, media3RepeatMode: Int) {
+ player.updateState(state = createState(supportsRepeatSet = true, repeatMode = mediaRepeatMode), artworkPngBytes = null)
+ shadowOf(Looper.getMainLooper()).idle()
+
+ assertEquals(media3RepeatMode, player.repeatMode)
+
+ player.repeatMode = media3RepeatMode
+ shadowOf(Looper.getMainLooper()).idle()
+
+ verify { commandCallback.onRepeatRequested(repeatMode = mediaRepeatMode) }
+ }
+
+ @Test
+ fun `Given repeat mode Off when getState then maps to REPEAT_MODE_OFF and set triggers callback`() {
+ assertRepeatModeRoundTrip(mediaRepeatMode = MediaRepeatMode.Off, media3RepeatMode = Player.REPEAT_MODE_OFF)
+ }
+
+ @Test
+ fun `Given repeat mode One when getState then maps to REPEAT_MODE_ONE and set triggers callback`() {
+ assertRepeatModeRoundTrip(mediaRepeatMode = MediaRepeatMode.One, media3RepeatMode = Player.REPEAT_MODE_ONE)
+ }
+
+ @Test
+ fun `Given repeat mode All when getState then maps to REPEAT_MODE_ALL and set triggers callback`() {
+ assertRepeatModeRoundTrip(mediaRepeatMode = MediaRepeatMode.All, media3RepeatMode = Player.REPEAT_MODE_ALL)
+ }
+
+ // -- setConnecting tests --
+
+ @Test
+ fun `Given prior state when setConnecting then playback state is buffering`() {
+ player.updateState(state = createState(playbackState = MediaPlaybackState.Playing), artworkPngBytes = null)
+ shadowOf(Looper.getMainLooper()).idle()
+
+ player.setConnecting()
+ shadowOf(Looper.getMainLooper()).idle()
+
+ assertEquals(Player.STATE_BUFFERING, player.playbackState)
+ }
+
+ @Test
+ fun `Given prior state when setConnecting then all media commands are disabled`() {
+ player.updateState(
+ state = createState(
+ supportsPlay = true,
+ supportsPause = true,
+ supportsSeek = true,
+ supportsPreviousTrack = true,
+ supportsNextTrack = true,
+ supportsVolumeSet = true,
+ volumeLevel = 0.5f,
+ ),
+ artworkPngBytes = null,
+ )
+ shadowOf(Looper.getMainLooper()).idle()
+
+ player.setConnecting()
+ shadowOf(Looper.getMainLooper()).idle()
+
+ assertFalse(player.availableCommands.contains(Player.COMMAND_PLAY_PAUSE))
+ assertFalse(player.availableCommands.contains(Player.COMMAND_SEEK_IN_CURRENT_MEDIA_ITEM))
+ assertFalse(player.availableCommands.contains(Player.COMMAND_SEEK_TO_NEXT))
+ assertFalse(player.availableCommands.contains(Player.COMMAND_SEEK_TO_PREVIOUS))
+ @Suppress("DEPRECATION")
+ assertFalse(player.availableCommands.contains(Player.COMMAND_SET_DEVICE_VOLUME))
+ @Suppress("DEPRECATION")
+ assertFalse(player.availableCommands.contains(Player.COMMAND_ADJUST_DEVICE_VOLUME))
+ }
+
+ @Test
+ fun `Given prior metadata when setConnecting then metadata is retained in player state`() {
+ player.updateState(
+ state = createState(title = "Retained Title", artist = "Retained Artist", albumName = "Retained Album"),
+ artworkPngBytes = null,
+ )
+ shadowOf(Looper.getMainLooper()).idle()
+
+ player.setConnecting()
+ shadowOf(Looper.getMainLooper()).idle()
+
+ val metadata = player.mediaMetadata
+ assertEquals("Retained Title", metadata.title?.toString())
+ assertEquals("Retained Artist", metadata.artist?.toString())
+ assertEquals("Retained Album", metadata.albumTitle?.toString())
+ }
+}
diff --git a/app/src/test/kotlin/io/homeassistant/companion/android/settings/mediacontrol/MediaControlSettingsViewModelTest.kt b/app/src/test/kotlin/io/homeassistant/companion/android/settings/mediacontrol/MediaControlSettingsViewModelTest.kt
new file mode 100644
index 00000000000..7b0885941be
--- /dev/null
+++ b/app/src/test/kotlin/io/homeassistant/companion/android/settings/mediacontrol/MediaControlSettingsViewModelTest.kt
@@ -0,0 +1,203 @@
+package io.homeassistant.companion.android.settings.mediacontrol
+
+import android.app.Application
+import app.cash.turbine.test
+import io.homeassistant.companion.android.common.data.mediacontrol.MediaControlEntityConfig
+import io.homeassistant.companion.android.common.data.mediacontrol.MediaControlRepository
+import io.homeassistant.companion.android.common.data.servers.ServerManager
+import io.homeassistant.companion.android.testing.unit.ConsoleLogExtension
+import io.homeassistant.companion.android.testing.unit.MainDispatcherJUnit5Extension
+import io.mockk.coEvery
+import io.mockk.coVerify
+import io.mockk.mockk
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.test.advanceUntilIdle
+import kotlinx.coroutines.test.runTest
+import org.junit.jupiter.api.Assertions.assertEquals
+import org.junit.jupiter.api.BeforeEach
+import org.junit.jupiter.api.Nested
+import org.junit.jupiter.api.Test
+import org.junit.jupiter.api.extension.ExtendWith
+import org.junit.jupiter.api.extension.RegisterExtension
+
+@OptIn(ExperimentalCoroutinesApi::class)
+@ExtendWith(ConsoleLogExtension::class)
+class MediaControlSettingsViewModelTest {
+
+ @RegisterExtension
+ val mainDispatcherExtension = MainDispatcherJUnit5Extension()
+
+ private val testDispatcher get() = mainDispatcherExtension.testDispatcher
+ private val serverManager: ServerManager = mockk(relaxed = true)
+ private val mediaControlRepository: MediaControlRepository = mockk(relaxed = true)
+
+ private lateinit var viewModel: MediaControlSettingsViewModel
+
+ @BeforeEach
+ fun setUp() {
+ coEvery { serverManager.servers() } returns emptyList()
+ coEvery { serverManager.getServer(any()) } returns null
+ coEvery { serverManager.integrationRepository(any()) } returns mockk(relaxed = true)
+ coEvery { serverManager.webSocketRepository(any()) } returns mockk(relaxed = true)
+ coEvery { mediaControlRepository.getConfiguredEntities() } returns emptyList()
+ }
+
+ private fun createViewModel(): MediaControlSettingsViewModel {
+ return MediaControlSettingsViewModel(
+ application = mockk(relaxed = true),
+ serverManager = serverManager,
+ mediaControlRepository = mediaControlRepository,
+ backgroundDispatcher = testDispatcher,
+ )
+ }
+
+ @Nested
+ inner class InitializationTest {
+
+ @Test
+ fun `Given no configured entities when viewModel created then configuredEntities is empty`() = runTest(testDispatcher) {
+ viewModel = createViewModel()
+ advanceUntilIdle()
+
+ assertEquals(emptyList(), viewModel.uiState.value.configuredEntities)
+ }
+
+ @Test
+ fun `Given configured entities when viewModel created then configuredEntities reflects repo`() = runTest(testDispatcher) {
+ val entities = listOf(MediaControlEntityConfig(serverId = 1, entityId = "media_player.tv"))
+ coEvery { mediaControlRepository.getConfiguredEntities() } returns entities
+
+ viewModel = createViewModel()
+ advanceUntilIdle()
+
+ assertEquals(entities, viewModel.uiState.value.configuredEntities)
+ }
+ }
+
+ @Nested
+ inner class AddEntityTest {
+
+ @Test
+ fun `Given viewModel when addEntity called then entity appended to list`() = runTest(testDispatcher) {
+ viewModel = createViewModel()
+
+ viewModel.addEntity("media_player.living_room")
+
+ assertEquals(1, viewModel.uiState.value.configuredEntities.size)
+ assertEquals("media_player.living_room", viewModel.uiState.value.configuredEntities.first().entityId)
+ }
+
+ @Test
+ fun `Given entity already in list when addEntity called with same entity then not duplicated`() = runTest(testDispatcher) {
+ viewModel = createViewModel()
+ viewModel.addEntity("media_player.tv")
+
+ viewModel.addEntity("media_player.tv")
+
+ assertEquals(1, viewModel.uiState.value.configuredEntities.size)
+ }
+
+ @Test
+ fun `Given viewModel when addEntity called then repository updated and start event emitted`() = runTest(testDispatcher) {
+ viewModel = createViewModel()
+ advanceUntilIdle()
+
+ viewModel.serviceEvents.test {
+ viewModel.addEntity("media_player.living_room")
+ advanceUntilIdle()
+
+ coVerify {
+ mediaControlRepository.setConfiguredEntities(
+ match { it.size == 1 && it[0].entityId == "media_player.living_room" },
+ )
+ }
+ assertEquals(MediaControlServiceEvent.Start, awaitItem())
+ cancelAndIgnoreRemainingEvents()
+ }
+ }
+
+ @Test
+ fun `Given viewModel when selectServerId called then selectedServerId updated`() = runTest(testDispatcher) {
+ viewModel = createViewModel()
+
+ viewModel.selectServerId(42)
+
+ assertEquals(42, viewModel.uiState.value.selectedServerId)
+ }
+
+ @Test
+ fun `Given non-default server selected when addEntity called then entity config has that server's id`() = runTest(testDispatcher) {
+ val serverBId = 99
+ viewModel = createViewModel()
+
+ viewModel.selectServerId(serverBId)
+ viewModel.addEntity("media_player.bedroom")
+
+ val addedEntity = viewModel.uiState.value.configuredEntities.first()
+ assertEquals(serverBId, addedEntity.serverId)
+ assertEquals("media_player.bedroom", addedEntity.entityId)
+ }
+ }
+
+ @Nested
+ inner class RemoveEntityTest {
+
+ @Test
+ fun `Given configured entity when removeEntity called then entity removed`() = runTest(testDispatcher) {
+ viewModel = createViewModel()
+ viewModel.addEntity("media_player.tv")
+ viewModel.addEntity("media_player.radio")
+
+ viewModel.removeEntity(0)
+
+ assertEquals(1, viewModel.uiState.value.configuredEntities.size)
+ assertEquals("media_player.radio", viewModel.uiState.value.configuredEntities.first().entityId)
+ }
+
+ @Test
+ fun `Given one entity when removeEntity called then repository cleared and no event emitted`() = runTest(testDispatcher) {
+ viewModel = createViewModel()
+ advanceUntilIdle()
+ viewModel.addEntity("media_player.tv")
+
+ viewModel.serviceEvents.test {
+ // Drain the Start event from addEntity
+ advanceUntilIdle()
+ awaitItem()
+
+ viewModel.removeEntity(0)
+ advanceUntilIdle()
+
+ coVerify { mediaControlRepository.setConfiguredEntities(emptyList()) }
+ expectNoEvents()
+ cancelAndIgnoreRemainingEvents()
+ }
+ }
+
+ @Test
+ fun `Given two entities when removeEntity called then repository updated and start event emitted`() = runTest(testDispatcher) {
+ viewModel = createViewModel()
+ advanceUntilIdle()
+ viewModel.addEntity("media_player.tv")
+ viewModel.addEntity("media_player.radio")
+
+ viewModel.serviceEvents.test {
+ // Drain the Start events from addEntity calls
+ advanceUntilIdle()
+ awaitItem()
+ awaitItem()
+
+ viewModel.removeEntity(0)
+ advanceUntilIdle()
+
+ coVerify {
+ mediaControlRepository.setConfiguredEntities(
+ match { it.size == 1 && it[0].entityId == "media_player.radio" },
+ )
+ }
+ assertEquals(MediaControlServiceEvent.Start, awaitItem())
+ cancelAndIgnoreRemainingEvents()
+ }
+ }
+ }
+}
diff --git a/automotive/src/main/AndroidManifest.xml b/automotive/src/main/AndroidManifest.xml
index cf8aaf6abf9..f904f72c869 100644
--- a/automotive/src/main/AndroidManifest.xml
+++ b/automotive/src/main/AndroidManifest.xml
@@ -242,6 +242,7 @@
+
state == "streaming"
else -> true
}
+
+/** Returns the bitmask of supported features for this entity, or 0 if unavailable. */
+private fun Entity.supportedFeatures(): Int = (attributes["supported_features"] as? Number)?.toInt() ?: 0
+
+/** Whether this media_player entity supports the given feature flag from [EntityExt]. */
+internal fun Entity.supportsMediaFeature(feature: Int): Boolean =
+ domain == MEDIA_PLAYER_DOMAIN && (supportedFeatures() and feature != 0)
+
+/** Whether this media_player entity supports pause. */
+internal fun Entity.supportsPause(): Boolean = supportsMediaFeature(EntityExt.MEDIA_PLAYER_SUPPORT_PAUSE)
+
+/** Whether this media_player entity supports seek. */
+internal fun Entity.supportsSeek(): Boolean = supportsMediaFeature(EntityExt.MEDIA_PLAYER_SUPPORT_SEEK)
+
+/** Whether this media_player entity supports previous track. */
+internal fun Entity.supportsPreviousTrack(): Boolean =
+ supportsMediaFeature(EntityExt.MEDIA_PLAYER_SUPPORT_PREVIOUS_TRACK)
+
+/** Whether this media_player entity supports next track. */
+internal fun Entity.supportsNextTrack(): Boolean = supportsMediaFeature(EntityExt.MEDIA_PLAYER_SUPPORT_NEXT_TRACK)
+
+/** Whether this media_player entity supports play. */
+internal fun Entity.supportsPlay(): Boolean = supportsMediaFeature(EntityExt.MEDIA_PLAYER_SUPPORT_PLAY)
+
+/** Returns the media title, if available. */
+internal fun Entity.getMediaTitle(): String? =
+ if (domain == MEDIA_PLAYER_DOMAIN) attributes["media_title"]?.toString() else null
+
+/** Returns the media artist, falling back to album artist if available. */
+internal fun Entity.getMediaArtist(): String? = if (domain == MEDIA_PLAYER_DOMAIN) {
+ (attributes["media_artist"] ?: attributes["media_album_artist"])?.toString()
+} else {
+ null
+}
+
+/** Returns the media album name, if available. */
+internal fun Entity.getMediaAlbumName(): String? =
+ if (domain == MEDIA_PLAYER_DOMAIN) attributes["media_album_name"]?.toString() else null
+
+/** Returns the current media position, if available. */
+internal fun Entity.getMediaPosition(): Duration? =
+ if (domain == MEDIA_PLAYER_DOMAIN) attributes["media_position"]?.toString()?.toDoubleOrNull()?.seconds else null
+
+/** Returns the media duration, if available. */
+internal fun Entity.getMediaDuration(): Duration? =
+ if (domain == MEDIA_PLAYER_DOMAIN) attributes["media_duration"]?.toString()?.toDoubleOrNull()?.seconds else null
+
+/** Returns the entity_picture attribute URL, if available. */
+internal fun Entity.getEntityPictureUrl(): String? =
+ if (domain == MEDIA_PLAYER_DOMAIN) attributes["entity_picture"]?.toString() else null
+
+/** Whether this media_player entity supports stop. */
+internal fun Entity.supportsStop(): Boolean = supportsMediaFeature(EntityExt.MEDIA_PLAYER_SUPPORT_STOP)
+
+/** Whether this media_player entity supports explicit mute toggling via the volume_mute service. */
+internal fun Entity.supportsVolumeMute(): Boolean = supportsMediaFeature(EntityExt.MEDIA_PLAYER_SUPPORT_VOLUME_MUTE)
+
+/** Whether this media_player entity supports setting shuffle mode. */
+internal fun Entity.supportsShuffleSet(): Boolean = supportsMediaFeature(EntityExt.MEDIA_PLAYER_SUPPORT_SHUFFLE_SET)
+
+/** Whether this media_player entity supports setting repeat mode. */
+internal fun Entity.supportsRepeatSet(): Boolean = supportsMediaFeature(EntityExt.MEDIA_PLAYER_SUPPORT_REPEAT_SET)
+
+/** Returns whether shuffle mode is currently enabled. */
+internal fun Entity.getShuffle(): Boolean =
+ if (domain == MEDIA_PLAYER_DOMAIN) attributes["shuffle"] as? Boolean ?: false else false
+
+/** Returns the album artist attribute directly, without falling back to media_artist. */
+internal fun Entity.getMediaAlbumArtist(): String? =
+ if (domain == MEDIA_PLAYER_DOMAIN) attributes["media_album_artist"]?.toString() else null
+
+/** Returns the media content type (e.g. "music", "tvshow", "movie"), if available. */
+internal fun Entity.getMediaContentType(): String? =
+ if (domain == MEDIA_PLAYER_DOMAIN) attributes["media_content_type"]?.toString() else null
+
+/** Returns the track number within the album, if available. */
+internal fun Entity.getMediaTrack(): Int? =
+ if (domain == MEDIA_PLAYER_DOMAIN) attributes["media_track"]?.toString()?.toIntOrNull() else null
+
+/** Returns the TV or radio channel name, if available. */
+internal fun Entity.getMediaChannel(): String? =
+ if (domain == MEDIA_PLAYER_DOMAIN) attributes["media_channel"]?.toString() else null
+
+/** Returns the TV series title when playing an episode, if available. */
+internal fun Entity.getMediaSeriesTitle(): String? =
+ if (domain == MEDIA_PLAYER_DOMAIN) attributes["media_series_title"]?.toString() else null
+
+/** Returns the name of the app currently active on this media player, if available. */
+internal fun Entity.getAppName(): String? =
+ if (domain == MEDIA_PLAYER_DOMAIN) attributes["app_name"]?.toString() else null
diff --git a/common/src/main/kotlin/io/homeassistant/companion/android/common/data/mediacontrol/MediaControlEntityConfig.kt b/common/src/main/kotlin/io/homeassistant/companion/android/common/data/mediacontrol/MediaControlEntityConfig.kt
new file mode 100644
index 00000000000..cf811de966c
--- /dev/null
+++ b/common/src/main/kotlin/io/homeassistant/companion/android/common/data/mediacontrol/MediaControlEntityConfig.kt
@@ -0,0 +1,7 @@
+package io.homeassistant.companion.android.common.data.mediacontrol
+
+/** Identifies a single `media_player` entity to expose as a native media control. */
+data class MediaControlEntityConfig(val serverId: Int, val entityId: String) {
+ /** Stable string key suitable for use as a Compose list item key. */
+ fun listKey(): String = "${serverId}_$entityId"
+}
diff --git a/common/src/main/kotlin/io/homeassistant/companion/android/common/data/mediacontrol/MediaControlModule.kt b/common/src/main/kotlin/io/homeassistant/companion/android/common/data/mediacontrol/MediaControlModule.kt
new file mode 100644
index 00000000000..5fb1003b062
--- /dev/null
+++ b/common/src/main/kotlin/io/homeassistant/companion/android/common/data/mediacontrol/MediaControlModule.kt
@@ -0,0 +1,14 @@
+package io.homeassistant.companion.android.common.data.mediacontrol
+
+import dagger.Binds
+import dagger.Module
+import dagger.hilt.InstallIn
+import dagger.hilt.components.SingletonComponent
+
+@Module
+@InstallIn(SingletonComponent::class)
+internal abstract class MediaControlModule {
+
+ @Binds
+ abstract fun bindMediaControlRepository(impl: MediaControlRepositoryImpl): MediaControlRepository
+}
diff --git a/common/src/main/kotlin/io/homeassistant/companion/android/common/data/mediacontrol/MediaControlRepository.kt b/common/src/main/kotlin/io/homeassistant/companion/android/common/data/mediacontrol/MediaControlRepository.kt
new file mode 100644
index 00000000000..6bc8cb9f4cd
--- /dev/null
+++ b/common/src/main/kotlin/io/homeassistant/companion/android/common/data/mediacontrol/MediaControlRepository.kt
@@ -0,0 +1,26 @@
+package io.homeassistant.companion.android.common.data.mediacontrol
+
+import kotlinx.coroutines.flow.Flow
+
+/**
+ * Manages configuration and state observation for media_player entities
+ * exposed as native Android media controls in the notification shade.
+ */
+interface MediaControlRepository {
+
+ /**
+ * Emits the current [MediaControlState] for a single entity on subscription (via REST),
+ * then continues emitting whenever its state changes via WebSocket.
+ * Emits null when the entity is unavailable or the WebSocket subscription fails.
+ */
+ fun observeEntityState(config: MediaControlEntityConfig): Flow
+
+ /** Returns the list of all configured media_player entities. */
+ suspend fun getConfiguredEntities(): List
+
+ /** Emits the list of configured entities whenever it changes in the database. */
+ fun observeConfiguredEntities(): Flow>
+
+ /** Replaces the full list of configured media_player entities. */
+ suspend fun setConfiguredEntities(entities: List)
+}
diff --git a/common/src/main/kotlin/io/homeassistant/companion/android/common/data/mediacontrol/MediaControlRepositoryImpl.kt b/common/src/main/kotlin/io/homeassistant/companion/android/common/data/mediacontrol/MediaControlRepositoryImpl.kt
new file mode 100644
index 00000000000..08e5ffdd1d4
--- /dev/null
+++ b/common/src/main/kotlin/io/homeassistant/companion/android/common/data/mediacontrol/MediaControlRepositoryImpl.kt
@@ -0,0 +1,186 @@
+package io.homeassistant.companion.android.common.data.mediacontrol
+
+import io.homeassistant.companion.android.common.data.integration.Entity
+import io.homeassistant.companion.android.common.data.integration.applyCompressedStateDiff
+import io.homeassistant.companion.android.common.data.integration.getAppName
+import io.homeassistant.companion.android.common.data.integration.getEntityPictureUrl
+import io.homeassistant.companion.android.common.data.integration.getMediaAlbumArtist
+import io.homeassistant.companion.android.common.data.integration.getMediaAlbumName
+import io.homeassistant.companion.android.common.data.integration.getMediaArtist
+import io.homeassistant.companion.android.common.data.integration.getMediaChannel
+import io.homeassistant.companion.android.common.data.integration.getMediaContentType
+import io.homeassistant.companion.android.common.data.integration.getMediaDuration
+import io.homeassistant.companion.android.common.data.integration.getMediaPosition
+import io.homeassistant.companion.android.common.data.integration.getMediaSeriesTitle
+import io.homeassistant.companion.android.common.data.integration.getMediaTitle
+import io.homeassistant.companion.android.common.data.integration.getMediaTrack
+import io.homeassistant.companion.android.common.data.integration.getShuffle
+import io.homeassistant.companion.android.common.data.integration.getVolumeMuted
+import io.homeassistant.companion.android.common.data.integration.supportsNextTrack
+import io.homeassistant.companion.android.common.data.integration.supportsPause
+import io.homeassistant.companion.android.common.data.integration.supportsPlay
+import io.homeassistant.companion.android.common.data.integration.supportsPreviousTrack
+import io.homeassistant.companion.android.common.data.integration.supportsRepeatSet
+import io.homeassistant.companion.android.common.data.integration.supportsSeek
+import io.homeassistant.companion.android.common.data.integration.supportsShuffleSet
+import io.homeassistant.companion.android.common.data.integration.supportsStop
+import io.homeassistant.companion.android.common.data.integration.supportsVolumeMute
+import io.homeassistant.companion.android.common.data.integration.supportsVolumeSet
+import io.homeassistant.companion.android.common.data.servers.ServerManager
+import io.homeassistant.companion.android.database.mediacontrol.MediaControlConfig
+import io.homeassistant.companion.android.database.mediacontrol.MediaControlDao
+import javax.inject.Inject
+import kotlinx.coroutines.CancellationException
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.distinctUntilChanged
+import kotlinx.coroutines.flow.flow
+import kotlinx.coroutines.flow.map
+import timber.log.Timber
+
+internal class MediaControlRepositoryImpl @Inject constructor(
+ private val dao: MediaControlDao,
+ private val serverManager: ServerManager,
+) : MediaControlRepository {
+
+ private suspend fun getEntityState(config: MediaControlEntityConfig): MediaControlState? = try {
+ serverManager.integrationRepository(config.serverId)
+ .getEntity(config.entityId)
+ ?.toMediaControlState(serverId = config.serverId)
+ } catch (e: CancellationException) {
+ throw e
+ } catch (e: Exception) {
+ Timber.e(e, "Failed to fetch entity state for ${config.entityId}")
+ null
+ }
+
+ override fun observeEntityState(config: MediaControlEntityConfig): Flow = flow {
+ Timber.d("observeEntityState: starting for ${config.entityId}")
+
+ // Emit current state via REST so the caller has something to show immediately.
+ // The WebSocket added event delivers the same state again; distinctUntilChanged()
+ // at the end suppresses the duplicate.
+ getEntityState(config)?.let {
+ Timber.d("observeEntityState: emitting REST state for ${config.entityId}")
+ emit(it)
+ }
+
+ try {
+ val stateFlow = serverManager.webSocketRepository(config.serverId)
+ .getCompressedStateAndChanges(listOf(config.entityId))
+ if (stateFlow == null) {
+ Timber.w(
+ "observeEntityState: WebSocket subscription returned null for entity ${config.entityId}, flow will complete",
+ )
+ emit(null)
+ return@flow
+ }
+
+ Timber.d("observeEntityState: WebSocket subscription established for ${config.entityId}, collecting events")
+ var currentEntity: Entity? = null
+ stateFlow.collect { event ->
+ event.added?.get(config.entityId)?.let {
+ Timber.d("observeEntityState: 'added' event for ${config.entityId}")
+ currentEntity = it.toEntity(config.entityId)
+ }
+ event.changed?.get(config.entityId)?.let { diff ->
+ Timber.d("observeEntityState: 'changed' event for ${config.entityId}")
+ currentEntity = currentEntity?.applyCompressedStateDiff(diff)
+ }
+ event.removed?.let { removed ->
+ if (config.entityId in removed) {
+ Timber.d("observeEntityState: 'removed' event for ${config.entityId}")
+ currentEntity = null
+ }
+ }
+
+ val entity = currentEntity
+ if (entity != null) {
+ emit(entity.toMediaControlState(serverId = config.serverId))
+ } else {
+ emit(null)
+ }
+ }
+ Timber.d("observeEntityState: WebSocket stateFlow collection ended for ${config.entityId}")
+ } catch (e: CancellationException) {
+ throw e
+ } catch (e: Exception) {
+ Timber.e(e, "Failed to subscribe to media control entity ${config.entityId}")
+ emit(null)
+ }
+ }.distinctUntilChanged()
+
+ override suspend fun getConfiguredEntities(): List =
+ dao.getAll().map { it.toEntityConfig() }
+
+ override fun observeConfiguredEntities(): Flow> = dao.getAllFlow().map { list ->
+ val configs = list.map { it.toEntityConfig() }
+ Timber.d("observeConfiguredEntities: DB emitted ${configs.size} entities=${configs.map { it.entityId }}")
+ configs
+ }
+
+ override suspend fun setConfiguredEntities(entities: List) {
+ dao.replaceAll(
+ entities.mapIndexed { index, config ->
+ MediaControlConfig(
+ serverId = config.serverId,
+ entityId = config.entityId,
+ index = index,
+ )
+ },
+ )
+ }
+}
+
+private fun MediaControlConfig.toEntityConfig() = MediaControlEntityConfig(
+ serverId = serverId,
+ entityId = entityId,
+)
+
+private fun Entity.toMediaControlState(serverId: Int): MediaControlState {
+ val playbackState = when (state) {
+ "playing" -> MediaPlaybackState.Playing
+ "paused" -> MediaPlaybackState.Paused
+ "buffering" -> MediaPlaybackState.Buffering
+ "idle", "standby" -> MediaPlaybackState.Idle
+ else -> MediaPlaybackState.Off
+ }
+
+ val repeatMode = when (attributes["repeat"]?.toString()) {
+ "one" -> MediaRepeatMode.One
+ "all" -> MediaRepeatMode.All
+ else -> MediaRepeatMode.Off
+ }
+
+ return MediaControlState(
+ entityId = entityId,
+ serverId = serverId,
+ playbackState = playbackState,
+ title = getMediaTitle(),
+ artist = getMediaArtist(),
+ albumName = getMediaAlbumName(),
+ entityPictureUrl = getEntityPictureUrl(),
+ mediaDuration = getMediaDuration(),
+ mediaPosition = getMediaPosition(),
+ supportsPause = supportsPause(),
+ supportsPlay = supportsPlay(),
+ supportsSeek = supportsSeek(),
+ supportsPreviousTrack = supportsPreviousTrack(),
+ supportsNextTrack = supportsNextTrack(),
+ supportsVolumeSet = supportsVolumeSet(),
+ supportsStop = supportsStop(),
+ supportsMute = supportsVolumeMute(),
+ supportsShuffleSet = supportsShuffleSet(),
+ supportsRepeatSet = supportsRepeatSet(),
+ volumeLevel = if (supportsVolumeSet()) (attributes["volume_level"] as? Number)?.toFloat() else null,
+ isVolumeMuted = getVolumeMuted(),
+ shuffle = getShuffle(),
+ repeatMode = repeatMode,
+ entityFriendlyName = attributes["friendly_name"] as? String,
+ albumArtist = getMediaAlbumArtist(),
+ mediaContentType = getMediaContentType(),
+ mediaTrack = getMediaTrack(),
+ mediaChannel = getMediaChannel(),
+ mediaSeriesTitle = getMediaSeriesTitle(),
+ appName = getAppName(),
+ )
+}
diff --git a/common/src/main/kotlin/io/homeassistant/companion/android/common/data/mediacontrol/MediaControlState.kt b/common/src/main/kotlin/io/homeassistant/companion/android/common/data/mediacontrol/MediaControlState.kt
new file mode 100644
index 00000000000..4c1424d81fa
--- /dev/null
+++ b/common/src/main/kotlin/io/homeassistant/companion/android/common/data/mediacontrol/MediaControlState.kt
@@ -0,0 +1,61 @@
+package io.homeassistant.companion.android.common.data.mediacontrol
+
+import kotlin.time.Duration
+
+/**
+ * Represents the playback state of a media player entity used for native Android media controls.
+ */
+sealed interface MediaPlaybackState {
+ data object Playing : MediaPlaybackState
+ data object Paused : MediaPlaybackState
+ data object Idle : MediaPlaybackState
+ data object Buffering : MediaPlaybackState
+ data object Off : MediaPlaybackState
+}
+
+/**
+ * Represents the repeat mode of a media player entity, matching Home Assistant's repeat attribute
+ * values: "off", "one", and "all".
+ */
+sealed interface MediaRepeatMode {
+ data object Off : MediaRepeatMode
+ data object One : MediaRepeatMode
+ data object All : MediaRepeatMode
+}
+
+/**
+ * Captures all the information from a Home Assistant media_player entity that is needed
+ * to populate an Android MediaSession.
+ */
+data class MediaControlState(
+ val entityId: String,
+ val serverId: Int,
+ val playbackState: MediaPlaybackState,
+ val title: String?,
+ val artist: String?,
+ val albumName: String?,
+ val entityPictureUrl: String?,
+ val mediaDuration: Duration?,
+ val mediaPosition: Duration?,
+ val supportsPause: Boolean,
+ val supportsPlay: Boolean,
+ val supportsSeek: Boolean,
+ val supportsPreviousTrack: Boolean,
+ val supportsNextTrack: Boolean,
+ val supportsVolumeSet: Boolean,
+ val supportsStop: Boolean,
+ val supportsMute: Boolean,
+ val supportsShuffleSet: Boolean,
+ val supportsRepeatSet: Boolean,
+ val volumeLevel: Float?,
+ val isVolumeMuted: Boolean,
+ val shuffle: Boolean,
+ val repeatMode: MediaRepeatMode,
+ val entityFriendlyName: String?,
+ val albumArtist: String? = null,
+ val mediaContentType: String? = null,
+ val mediaTrack: Int? = null,
+ val mediaChannel: String? = null,
+ val mediaSeriesTitle: String? = null,
+ val appName: String? = null,
+)
diff --git a/common/src/main/kotlin/io/homeassistant/companion/android/common/data/servers/ServerManagerImpl.kt b/common/src/main/kotlin/io/homeassistant/companion/android/common/data/servers/ServerManagerImpl.kt
index 325f2c5a9dd..10d544dee0a 100644
--- a/common/src/main/kotlin/io/homeassistant/companion/android/common/data/servers/ServerManagerImpl.kt
+++ b/common/src/main/kotlin/io/homeassistant/companion/android/common/data/servers/ServerManagerImpl.kt
@@ -11,6 +11,7 @@ import io.homeassistant.companion.android.common.data.servers.ServerManager.Comp
import io.homeassistant.companion.android.common.data.websocket.WebSocketRepository
import io.homeassistant.companion.android.common.data.websocket.WebSocketRepositoryFactory
import io.homeassistant.companion.android.common.util.FailFast
+import io.homeassistant.companion.android.database.mediacontrol.MediaControlDao
import io.homeassistant.companion.android.database.sensor.SensorDao
import io.homeassistant.companion.android.database.server.Server
import io.homeassistant.companion.android.database.server.ServerDao
@@ -75,6 +76,7 @@ internal class ServerManagerImpl @Inject constructor(
private val serverDao: ServerDao,
private val sensorDao: SensorDao,
private val settingsDao: SettingsDao,
+ private val mediaControlDao: MediaControlDao,
@NamedSessionStorage private val localStorage: LocalStorage,
) : ServerManager {
@@ -143,6 +145,7 @@ internal class ServerManagerImpl @Inject constructor(
if (localStorage.getInt(PREF_ACTIVE_SERVER) == id) localStorage.remove(PREF_ACTIVE_SERVER)
settingsDao.delete(id)
sensorDao.removeServer(id)
+ mediaControlDao.deleteByServerId(id)
serverDao.delete(id)
}
diff --git a/common/src/main/kotlin/io/homeassistant/companion/android/database/AppDatabase.kt b/common/src/main/kotlin/io/homeassistant/companion/android/database/AppDatabase.kt
index 2829621f4fd..73d33c6b32f 100644
--- a/common/src/main/kotlin/io/homeassistant/companion/android/database/AppDatabase.kt
+++ b/common/src/main/kotlin/io/homeassistant/companion/android/database/AppDatabase.kt
@@ -8,6 +8,8 @@ import io.homeassistant.companion.android.database.authentication.Authentication
import io.homeassistant.companion.android.database.authentication.AuthenticationDao
import io.homeassistant.companion.android.database.location.LocationHistoryDao
import io.homeassistant.companion.android.database.location.LocationHistoryItem
+import io.homeassistant.companion.android.database.mediacontrol.MediaControlConfig
+import io.homeassistant.companion.android.database.mediacontrol.MediaControlDao
import io.homeassistant.companion.android.database.migration.Migration27to28
import io.homeassistant.companion.android.database.migration.Migration36to37
import io.homeassistant.companion.android.database.notification.NotificationDao
@@ -73,8 +75,9 @@ import io.homeassistant.companion.android.database.widget.WidgetTapActionConvert
EntityStateComplications::class,
Server::class,
Setting::class,
+ MediaControlConfig::class,
],
- version = 51,
+ version = 52,
autoMigrations = [
AutoMigration(from = 24, to = 25),
AutoMigration(from = 25, to = 26),
@@ -101,6 +104,7 @@ import io.homeassistant.companion.android.database.widget.WidgetTapActionConvert
AutoMigration(from = 48, to = 49),
AutoMigration(from = 49, to = 50),
AutoMigration(from = 50, to = 51),
+ AutoMigration(from = 51, to = 52),
],
)
@TypeConverters(
@@ -130,4 +134,5 @@ internal abstract class AppDatabase : RoomDatabase() {
abstract fun entityStateComplicationsDao(): EntityStateComplicationsDao
abstract fun serverDao(): ServerDao
abstract fun settingsDao(): SettingsDao
+ abstract fun mediaControlDao(): MediaControlDao
}
diff --git a/common/src/main/kotlin/io/homeassistant/companion/android/database/DatabaseModule.kt b/common/src/main/kotlin/io/homeassistant/companion/android/database/DatabaseModule.kt
index 51154b29a31..2e0a1e977b7 100644
--- a/common/src/main/kotlin/io/homeassistant/companion/android/database/DatabaseModule.kt
+++ b/common/src/main/kotlin/io/homeassistant/companion/android/database/DatabaseModule.kt
@@ -9,6 +9,7 @@ import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.components.SingletonComponent
import io.homeassistant.companion.android.database.authentication.AuthenticationDao
import io.homeassistant.companion.android.database.location.LocationHistoryDao
+import io.homeassistant.companion.android.database.mediacontrol.MediaControlDao
import io.homeassistant.companion.android.database.migration.migrationPath
import io.homeassistant.companion.android.database.notification.NotificationDao
import io.homeassistant.companion.android.database.qs.TileDao
@@ -97,4 +98,7 @@ internal object DatabaseModule {
@Provides
fun provideEntityStateComplicationsDao(database: AppDatabase): EntityStateComplicationsDao =
database.entityStateComplicationsDao()
+
+ @Provides
+ fun provideMediaControlDao(database: AppDatabase): MediaControlDao = database.mediaControlDao()
}
diff --git a/common/src/main/kotlin/io/homeassistant/companion/android/database/mediacontrol/MediaControlConfig.kt b/common/src/main/kotlin/io/homeassistant/companion/android/database/mediacontrol/MediaControlConfig.kt
new file mode 100644
index 00000000000..7ca3d4644d9
--- /dev/null
+++ b/common/src/main/kotlin/io/homeassistant/companion/android/database/mediacontrol/MediaControlConfig.kt
@@ -0,0 +1,19 @@
+package io.homeassistant.companion.android.database.mediacontrol
+
+import androidx.room.ColumnInfo
+import androidx.room.Entity
+import androidx.room.PrimaryKey
+
+/** Stores a single `media_player` entity configured to be exposed as a native media control. */
+@Entity(tableName = "media_control_entity_config")
+data class MediaControlConfig(
+ @PrimaryKey(autoGenerate = true)
+ @ColumnInfo(name = "id")
+ val id: Int = 0,
+ @ColumnInfo(name = "server_id")
+ val serverId: Int,
+ @ColumnInfo(name = "entity_id")
+ val entityId: String,
+ @ColumnInfo(name = "index")
+ val index: Int,
+)
diff --git a/common/src/main/kotlin/io/homeassistant/companion/android/database/mediacontrol/MediaControlDao.kt b/common/src/main/kotlin/io/homeassistant/companion/android/database/mediacontrol/MediaControlDao.kt
new file mode 100644
index 00000000000..29c9eb8c40a
--- /dev/null
+++ b/common/src/main/kotlin/io/homeassistant/companion/android/database/mediacontrol/MediaControlDao.kt
@@ -0,0 +1,33 @@
+package io.homeassistant.companion.android.database.mediacontrol
+
+import androidx.room.Dao
+import androidx.room.Insert
+import androidx.room.OnConflictStrategy
+import androidx.room.Query
+import androidx.room.Transaction
+import kotlinx.coroutines.flow.Flow
+
+@Dao
+interface MediaControlDao {
+
+ @Query("SELECT * FROM media_control_entity_config ORDER BY `index` ASC")
+ fun getAllFlow(): Flow>
+
+ @Query("SELECT * FROM media_control_entity_config ORDER BY `index` ASC")
+ suspend fun getAll(): List
+
+ @Insert(onConflict = OnConflictStrategy.REPLACE)
+ suspend fun insertAll(entities: List)
+
+ @Query("DELETE FROM media_control_entity_config")
+ suspend fun deleteAll()
+
+ @Query("DELETE FROM media_control_entity_config WHERE server_id = :serverId")
+ suspend fun deleteByServerId(serverId: Int)
+
+ @Transaction
+ suspend fun replaceAll(entities: List) {
+ deleteAll()
+ insertAll(entities)
+ }
+}
diff --git a/common/src/main/res/values/strings.xml b/common/src/main/res/values/strings.xml
index 8f2e214a324..b07436281e4 100644
--- a/common/src/main/res/values/strings.xml
+++ b/common/src/main/res/values/strings.xml
@@ -453,6 +453,13 @@
Enter address manually
What is your Home Assistant address?
Unable to add Matter device?
+ Media controls
+ Control media players from the notification shade
+ Select one or more media player entities to show as native media controls in the notification shade. You can control playback without opening the app.
+ Add media player
+ Add entity
+ Remove entity
+ Clear all
Matter is currently unavailable
Add Matter device
Please connect to your Home Assistant server before adding a Matter device.
diff --git a/common/src/test/kotlin/io/homeassistant/companion/android/common/data/mediacontrol/MediaControlRepositoryImplTest.kt b/common/src/test/kotlin/io/homeassistant/companion/android/common/data/mediacontrol/MediaControlRepositoryImplTest.kt
new file mode 100644
index 00000000000..dd1180c56c4
--- /dev/null
+++ b/common/src/test/kotlin/io/homeassistant/companion/android/common/data/mediacontrol/MediaControlRepositoryImplTest.kt
@@ -0,0 +1,579 @@
+package io.homeassistant.companion.android.common.data.mediacontrol
+
+import app.cash.turbine.test
+import io.homeassistant.companion.android.common.data.integration.EntityExt
+import io.homeassistant.companion.android.common.data.integration.IntegrationRepository
+import io.homeassistant.companion.android.common.data.servers.ServerManager
+import io.homeassistant.companion.android.common.data.websocket.WebSocketRepository
+import io.homeassistant.companion.android.common.data.websocket.impl.entities.CompressedEntityState
+import io.homeassistant.companion.android.common.data.websocket.impl.entities.CompressedStateChangedEvent
+import io.homeassistant.companion.android.database.mediacontrol.MediaControlConfig
+import io.homeassistant.companion.android.database.mediacontrol.MediaControlDao
+import io.homeassistant.companion.android.testing.unit.ConsoleLogExtension
+import io.mockk.coEvery
+import io.mockk.coVerify
+import io.mockk.mockk
+import kotlin.time.Duration.Companion.seconds
+import kotlinx.coroutines.flow.MutableSharedFlow
+import kotlinx.coroutines.flow.flowOf
+import kotlinx.coroutines.test.runTest
+import kotlinx.serialization.json.JsonPrimitive
+import org.junit.jupiter.api.Assertions.assertEquals
+import org.junit.jupiter.api.Assertions.assertFalse
+import org.junit.jupiter.api.Assertions.assertNull
+import org.junit.jupiter.api.Assertions.assertTrue
+import org.junit.jupiter.api.BeforeEach
+import org.junit.jupiter.api.Nested
+import org.junit.jupiter.api.Test
+import org.junit.jupiter.api.extension.ExtendWith
+
+@ExtendWith(ConsoleLogExtension::class)
+class MediaControlRepositoryImplTest {
+
+ private val dao: MediaControlDao = mockk(relaxed = true)
+ private val serverManager: ServerManager = mockk(relaxed = true)
+ private val webSocketRepository: WebSocketRepository = mockk(relaxed = true)
+ private val integrationRepository: IntegrationRepository = mockk(relaxed = true)
+
+ private lateinit var repository: MediaControlRepositoryImpl
+
+ private val testConfig = MediaControlEntityConfig(serverId = 1, entityId = "media_player.test")
+
+ @BeforeEach
+ fun setUp() {
+ coEvery { serverManager.webSocketRepository(any()) } returns webSocketRepository
+ coEvery { serverManager.integrationRepository(any()) } returns integrationRepository
+ coEvery { integrationRepository.getEntity(any()) } returns null
+ repository = MediaControlRepositoryImpl(
+ dao = dao,
+ serverManager = serverManager,
+ )
+ }
+
+ @Nested
+ inner class ObserveEntityStateTest {
+
+ @Test
+ fun `Given entity when state arrives then emit MediaControlState`() = runTest {
+ val entityState = CompressedEntityState(
+ state = JsonPrimitive("playing"),
+ attributes = mapOf(
+ "friendly_name" to "Test Player",
+ "media_title" to "Test Song",
+ "media_artist" to "Test Artist",
+ "supported_features" to
+ (EntityExt.MEDIA_PLAYER_SUPPORT_PLAY or EntityExt.MEDIA_PLAYER_SUPPORT_PAUSE),
+ ),
+ lastChanged = 1000.0,
+ lastUpdated = 1000.0,
+ )
+ val event = CompressedStateChangedEvent(
+ added = mapOf("media_player.test" to entityState),
+ )
+ coEvery {
+ webSocketRepository.getCompressedStateAndChanges(listOf("media_player.test"))
+ } returns flowOf(event)
+
+ repository.observeEntityState(testConfig).test {
+ val state = awaitItem()
+ assertEquals("media_player.test", state?.entityId)
+ assertEquals(1, state?.serverId)
+ assertEquals(MediaPlaybackState.Playing, state?.playbackState)
+ assertEquals("Test Song", state?.title)
+ assertEquals("Test Artist", state?.artist)
+ awaitComplete()
+ }
+ }
+
+ @Test
+ fun `Given entity when entity removed then emit null`() = runTest {
+ val event = CompressedStateChangedEvent(
+ removed = listOf("media_player.test"),
+ )
+ coEvery {
+ webSocketRepository.getCompressedStateAndChanges(listOf("media_player.test"))
+ } returns flowOf(event)
+
+ repository.observeEntityState(testConfig).test {
+ assertNull(awaitItem())
+ awaitComplete()
+ }
+ }
+
+ @Test
+ fun `Given entity when websocket returns null then emit null`() = runTest {
+ coEvery {
+ webSocketRepository.getCompressedStateAndChanges(listOf("media_player.test"))
+ } returns null
+
+ repository.observeEntityState(testConfig).test {
+ assertNull(awaitItem())
+ awaitComplete()
+ }
+ }
+ }
+
+ @Nested
+ inner class PlaybackStateMappingTest {
+
+ private fun entityWithState(state: String, attributes: Map = emptyMap()) = CompressedEntityState(
+ state = JsonPrimitive(state),
+ attributes = attributes,
+ lastChanged = 1000.0,
+ lastUpdated = 1000.0,
+ )
+
+ private fun configureWebSocketWith(state: String) {
+ coEvery {
+ webSocketRepository.getCompressedStateAndChanges(any())
+ } returns flowOf(CompressedStateChangedEvent(added = mapOf("media_player.test" to entityWithState(state))))
+ }
+
+ @Test
+ fun `Given paused state then maps to Paused`() = runTest {
+ configureWebSocketWith("paused")
+
+ repository.observeEntityState(testConfig).test {
+ assertEquals(MediaPlaybackState.Paused, awaitItem()?.playbackState)
+ awaitComplete()
+ }
+ }
+
+ @Test
+ fun `Given buffering state then maps to Buffering`() = runTest {
+ configureWebSocketWith("buffering")
+
+ repository.observeEntityState(testConfig).test {
+ assertEquals(MediaPlaybackState.Buffering, awaitItem()?.playbackState)
+ awaitComplete()
+ }
+ }
+
+ @Test
+ fun `Given idle state then maps to Idle`() = runTest {
+ configureWebSocketWith("idle")
+
+ repository.observeEntityState(testConfig).test {
+ assertEquals(MediaPlaybackState.Idle, awaitItem()?.playbackState)
+ awaitComplete()
+ }
+ }
+
+ @Test
+ fun `Given standby state then maps to Idle`() = runTest {
+ configureWebSocketWith("standby")
+
+ repository.observeEntityState(testConfig).test {
+ assertEquals(MediaPlaybackState.Idle, awaitItem()?.playbackState)
+ awaitComplete()
+ }
+ }
+
+ @Test
+ fun `Given off state then maps to Off`() = runTest {
+ configureWebSocketWith("off")
+
+ repository.observeEntityState(testConfig).test {
+ assertEquals(MediaPlaybackState.Off, awaitItem()?.playbackState)
+ awaitComplete()
+ }
+ }
+
+ @Test
+ fun `Given unknown state then maps to Off`() = runTest {
+ configureWebSocketWith("unavailable")
+
+ repository.observeEntityState(testConfig).test {
+ assertEquals(MediaPlaybackState.Off, awaitItem()?.playbackState)
+ awaitComplete()
+ }
+ }
+
+ @Test
+ fun `Given entity with partial attributes then null fields are null`() = runTest {
+ val entityState = entityWithState("playing", attributes = mapOf("media_title" to "Only Title"))
+ coEvery {
+ webSocketRepository.getCompressedStateAndChanges(any())
+ } returns flowOf(CompressedStateChangedEvent(added = mapOf("media_player.test" to entityState)))
+
+ repository.observeEntityState(testConfig).test {
+ val state = awaitItem()!!
+ assertEquals("Only Title", state.title)
+ assertNull(state.artist)
+ assertNull(state.albumName)
+ assertNull(state.entityPictureUrl)
+ assertNull(state.mediaDuration)
+ assertNull(state.mediaPosition)
+ awaitComplete()
+ }
+ }
+
+ @Test
+ fun `Given entity with all attributes then all fields populated`() = runTest {
+ val entityState = entityWithState(
+ "playing",
+ attributes = mapOf(
+ "media_title" to "Song",
+ "media_artist" to "Artist",
+ "media_album_name" to "Album",
+ "entity_picture" to "/api/picture",
+ "media_duration" to 300.0,
+ "media_position" to 120.5,
+ "supported_features" to (
+ EntityExt.MEDIA_PLAYER_SUPPORT_PAUSE or
+ EntityExt.MEDIA_PLAYER_SUPPORT_SEEK or
+ EntityExt.MEDIA_PLAYER_SUPPORT_PREVIOUS_TRACK or
+ EntityExt.MEDIA_PLAYER_SUPPORT_NEXT_TRACK or
+ EntityExt.MEDIA_PLAYER_SUPPORT_PLAY
+ ),
+ ),
+ )
+ coEvery {
+ webSocketRepository.getCompressedStateAndChanges(any())
+ } returns flowOf(CompressedStateChangedEvent(added = mapOf("media_player.test" to entityState)))
+
+ repository.observeEntityState(testConfig).test {
+ val state = awaitItem()!!
+ assertEquals("Song", state.title)
+ assertEquals("Artist", state.artist)
+ assertEquals("Album", state.albumName)
+ assertEquals("/api/picture", state.entityPictureUrl)
+ assertEquals(300.0.seconds, state.mediaDuration)
+ assertEquals(120.5.seconds, state.mediaPosition)
+ assertTrue(state.supportsPause)
+ assertTrue(state.supportsPlay)
+ assertTrue(state.supportsSeek)
+ assertTrue(state.supportsPreviousTrack)
+ assertTrue(state.supportsNextTrack)
+ awaitComplete()
+ }
+ }
+ }
+
+ @Nested
+ inner class VolumeMappingTest {
+
+ private fun entityWithVolumeAttributes(attributes: Map) = CompressedEntityState(
+ state = JsonPrimitive("playing"),
+ attributes = attributes,
+ lastChanged = 1000.0,
+ lastUpdated = 1000.0,
+ )
+
+ @Test
+ fun `Given entity with volume support and volume_level then volumeLevel and supportsVolumeSet are set`() = runTest {
+ val entityState = entityWithVolumeAttributes(
+ mapOf(
+ "supported_features" to EntityExt.MEDIA_PLAYER_SUPPORT_VOLUME_SET,
+ "volume_level" to 0.7,
+ ),
+ )
+ coEvery {
+ webSocketRepository.getCompressedStateAndChanges(any())
+ } returns flowOf(CompressedStateChangedEvent(added = mapOf("media_player.test" to entityState)))
+
+ repository.observeEntityState(testConfig).test {
+ val state = awaitItem()!!
+ assertTrue(state.supportsVolumeSet)
+ assertEquals(0.7f, state.volumeLevel)
+ awaitComplete()
+ }
+ }
+
+ @Test
+ fun `Given entity without volume support then volumeLevel is null and supportsVolumeSet is false`() = runTest {
+ val entityState = entityWithVolumeAttributes(
+ mapOf("supported_features" to EntityExt.MEDIA_PLAYER_SUPPORT_PLAY),
+ )
+ coEvery {
+ webSocketRepository.getCompressedStateAndChanges(any())
+ } returns flowOf(CompressedStateChangedEvent(added = mapOf("media_player.test" to entityState)))
+
+ repository.observeEntityState(testConfig).test {
+ val state = awaitItem()!!
+ assertFalse(state.supportsVolumeSet)
+ assertNull(state.volumeLevel)
+ awaitComplete()
+ }
+ }
+
+ @Test
+ fun `Given entity with is_volume_muted true then isVolumeMuted is true`() = runTest {
+ val entityState = entityWithVolumeAttributes(
+ mapOf(
+ "supported_features" to EntityExt.MEDIA_PLAYER_SUPPORT_VOLUME_SET,
+ "volume_level" to 0.5,
+ "is_volume_muted" to true,
+ ),
+ )
+ coEvery {
+ webSocketRepository.getCompressedStateAndChanges(any())
+ } returns flowOf(CompressedStateChangedEvent(added = mapOf("media_player.test" to entityState)))
+
+ repository.observeEntityState(testConfig).test {
+ val state = awaitItem()!!
+ assertTrue(state.isVolumeMuted)
+ awaitComplete()
+ }
+ }
+ }
+
+ @Nested
+ inner class FeatureSupportMappingTest {
+
+ private fun entityWith(attributes: Map) = CompressedEntityState(
+ state = JsonPrimitive("playing"),
+ attributes = attributes,
+ lastChanged = 1000.0,
+ lastUpdated = 1000.0,
+ )
+
+ private fun emitWith(attributes: Map) {
+ coEvery {
+ webSocketRepository.getCompressedStateAndChanges(any())
+ } returns flowOf(CompressedStateChangedEvent(added = mapOf("media_player.test" to entityWith(attributes))))
+ }
+
+ @Test
+ fun `Given entity with STOP support then supportsStop is true`() = runTest {
+ emitWith(mapOf("supported_features" to EntityExt.MEDIA_PLAYER_SUPPORT_STOP))
+
+ repository.observeEntityState(testConfig).test {
+ assertTrue(awaitItem()!!.supportsStop)
+ awaitComplete()
+ }
+ }
+
+ @Test
+ fun `Given entity with VOLUME_MUTE support then supportsMute is true`() = runTest {
+ emitWith(mapOf("supported_features" to EntityExt.MEDIA_PLAYER_SUPPORT_VOLUME_MUTE))
+
+ repository.observeEntityState(testConfig).test {
+ assertTrue(awaitItem()!!.supportsMute)
+ awaitComplete()
+ }
+ }
+
+ @Test
+ fun `Given entity with SHUFFLE_SET support and shuffle true then supportsShuffleSet and shuffle are true`() = runTest {
+ emitWith(
+ mapOf(
+ "supported_features" to EntityExt.MEDIA_PLAYER_SUPPORT_SHUFFLE_SET,
+ "shuffle" to true,
+ ),
+ )
+
+ repository.observeEntityState(testConfig).test {
+ val state = awaitItem()!!
+ assertTrue(state.supportsShuffleSet)
+ assertTrue(state.shuffle)
+ awaitComplete()
+ }
+ }
+
+ @Test
+ fun `Given entity with REPEAT_SET support and repeat all then supportsRepeatSet is true and repeatMode is All`() = runTest {
+ emitWith(
+ mapOf(
+ "supported_features" to EntityExt.MEDIA_PLAYER_SUPPORT_REPEAT_SET,
+ "repeat" to "all",
+ ),
+ )
+
+ repository.observeEntityState(testConfig).test {
+ val state = awaitItem()!!
+ assertTrue(state.supportsRepeatSet)
+ assertEquals(MediaRepeatMode.All, state.repeatMode)
+ awaitComplete()
+ }
+ }
+
+ @Test
+ fun `Given entity with repeat one then repeatMode is One`() = runTest {
+ emitWith(mapOf("repeat" to "one"))
+
+ repository.observeEntityState(testConfig).test {
+ assertEquals(MediaRepeatMode.One, awaitItem()!!.repeatMode)
+ awaitComplete()
+ }
+ }
+
+ @Test
+ fun `Given entity with no repeat attribute then repeatMode is Off`() = runTest {
+ emitWith(emptyMap())
+
+ repository.observeEntityState(testConfig).test {
+ assertEquals(MediaRepeatMode.Off, awaitItem()!!.repeatMode)
+ awaitComplete()
+ }
+ }
+ }
+
+ @Nested
+ inner class MetadataMappingTest {
+
+ private fun entityWithAttributes(attributes: Map) = CompressedEntityState(
+ state = JsonPrimitive("playing"),
+ attributes = attributes,
+ lastChanged = 1000.0,
+ lastUpdated = 1000.0,
+ )
+
+ private fun emitEntity(attributes: Map) {
+ coEvery {
+ webSocketRepository.getCompressedStateAndChanges(any())
+ } returns flowOf(CompressedStateChangedEvent(added = mapOf("media_player.test" to entityWithAttributes(attributes))))
+ }
+
+ @Test
+ fun `Given entity with media_album_artist then albumArtist is set`() = runTest {
+ emitEntity(mapOf("media_album_artist" to "Various Artists"))
+
+ repository.observeEntityState(testConfig).test {
+ assertEquals("Various Artists", awaitItem()?.albumArtist)
+ awaitComplete()
+ }
+ }
+
+ @Test
+ fun `Given entity with media_content_type then mediaContentType is set`() = runTest {
+ emitEntity(mapOf("media_content_type" to "music"))
+
+ repository.observeEntityState(testConfig).test {
+ assertEquals("music", awaitItem()?.mediaContentType)
+ awaitComplete()
+ }
+ }
+
+ @Test
+ fun `Given entity with media_track then mediaTrack is set`() = runTest {
+ emitEntity(mapOf("media_track" to 3))
+
+ repository.observeEntityState(testConfig).test {
+ assertEquals(3, awaitItem()?.mediaTrack)
+ awaitComplete()
+ }
+ }
+
+ @Test
+ fun `Given entity with media_channel then mediaChannel is set`() = runTest {
+ emitEntity(mapOf("media_channel" to "BBC Radio 4"))
+
+ repository.observeEntityState(testConfig).test {
+ assertEquals("BBC Radio 4", awaitItem()?.mediaChannel)
+ awaitComplete()
+ }
+ }
+
+ @Test
+ fun `Given entity with media_series_title then mediaSeriesTitle is set`() = runTest {
+ emitEntity(mapOf("media_series_title" to "Breaking Bad"))
+
+ repository.observeEntityState(testConfig).test {
+ assertEquals("Breaking Bad", awaitItem()?.mediaSeriesTitle)
+ awaitComplete()
+ }
+ }
+
+ @Test
+ fun `Given entity with app_name then appName is set`() = runTest {
+ emitEntity(mapOf("app_name" to "Netflix"))
+
+ repository.observeEntityState(testConfig).test {
+ assertEquals("Netflix", awaitItem()?.appName)
+ awaitComplete()
+ }
+ }
+
+ @Test
+ fun `Given entity with friendly_name then entityFriendlyName is set`() = runTest {
+ emitEntity(mapOf("friendly_name" to "Living Room TV"))
+
+ repository.observeEntityState(testConfig).test {
+ assertEquals("Living Room TV", awaitItem()?.entityFriendlyName)
+ awaitComplete()
+ }
+ }
+
+ @Test
+ fun `Given entity without new metadata attributes then all new fields are null`() = runTest {
+ emitEntity(mapOf("media_title" to "Song"))
+
+ repository.observeEntityState(testConfig).test {
+ val state = awaitItem()!!
+ assertNull(state.albumArtist)
+ assertNull(state.mediaContentType)
+ assertNull(state.mediaTrack)
+ assertNull(state.mediaChannel)
+ assertNull(state.mediaSeriesTitle)
+ assertNull(state.appName)
+ awaitComplete()
+ }
+ }
+ }
+
+ @Nested
+ inner class DistinctUntilChangedTest {
+
+ @Test
+ fun `Given duplicate state emissions then only first is emitted`() = runTest {
+ val entityState = CompressedEntityState(
+ state = JsonPrimitive("playing"),
+ attributes = mapOf("media_title" to "Song"),
+ lastChanged = 1000.0,
+ lastUpdated = 1000.0,
+ )
+ val stateFlow = MutableSharedFlow()
+ coEvery {
+ webSocketRepository.getCompressedStateAndChanges(any())
+ } returns stateFlow
+
+ repository.observeEntityState(testConfig).test {
+ // Emit the same entity state twice — distinctUntilChanged should filter the duplicate
+ stateFlow.emit(CompressedStateChangedEvent(added = mapOf("media_player.test" to entityState)))
+ val first = awaitItem()
+ assertEquals("Song", first?.title)
+
+ stateFlow.emit(CompressedStateChangedEvent(added = mapOf("media_player.test" to entityState)))
+ expectNoEvents()
+
+ cancelAndIgnoreRemainingEvents()
+ }
+ }
+ }
+
+ @Nested
+ inner class ConfigurationTest {
+
+ @Test
+ fun `Given entities in database when getConfiguredEntities then returns mapped list`() = runTest {
+ coEvery { dao.getAll() } returns listOf(
+ MediaControlConfig(id = 1, serverId = 1, entityId = "media_player.tv", index = 0),
+ )
+
+ assertEquals(
+ listOf(MediaControlEntityConfig(serverId = 1, entityId = "media_player.tv")),
+ repository.getConfiguredEntities(),
+ )
+ }
+
+ @Test
+ fun `Given entities when setConfiguredEntities then replaces all in database with positions`() = runTest {
+ val entities = listOf(
+ MediaControlEntityConfig(serverId = 1, entityId = "media_player.tv"),
+ MediaControlEntityConfig(serverId = 2, entityId = "media_player.office"),
+ )
+
+ repository.setConfiguredEntities(entities)
+
+ coVerify {
+ dao.replaceAll(
+ listOf(
+ MediaControlConfig(serverId = 1, entityId = "media_player.tv", index = 0),
+ MediaControlConfig(serverId = 2, entityId = "media_player.office", index = 1),
+ ),
+ )
+ }
+ }
+ }
+}
diff --git a/common/src/test/kotlin/io/homeassistant/companion/android/common/data/servers/ServerManagerImplTest.kt b/common/src/test/kotlin/io/homeassistant/companion/android/common/data/servers/ServerManagerImplTest.kt
index 8ffe93bcbfa..214810bab1b 100644
--- a/common/src/test/kotlin/io/homeassistant/companion/android/common/data/servers/ServerManagerImplTest.kt
+++ b/common/src/test/kotlin/io/homeassistant/companion/android/common/data/servers/ServerManagerImplTest.kt
@@ -10,6 +10,7 @@ import io.homeassistant.companion.android.common.data.prefs.PrefsRepository
import io.homeassistant.companion.android.common.data.servers.ServerManager.Companion.SERVER_ID_ACTIVE
import io.homeassistant.companion.android.common.data.websocket.WebSocketRepository
import io.homeassistant.companion.android.common.data.websocket.WebSocketRepositoryFactory
+import io.homeassistant.companion.android.database.mediacontrol.MediaControlDao
import io.homeassistant.companion.android.database.sensor.SensorDao
import io.homeassistant.companion.android.database.server.Server
import io.homeassistant.companion.android.database.server.ServerConnectionInfo
@@ -56,6 +57,7 @@ class ServerManagerImplTest {
private val serverDao: ServerDao = mockk()
private val sensorDao: SensorDao = mockk()
private val settingsDao: SettingsDao = mockk()
+ private val mediaControlDao: MediaControlDao = mockk()
private val localStorage: LocalStorage = mockk()
private lateinit var serverManager: ServerManagerImpl
@@ -84,6 +86,7 @@ class ServerManagerImplTest {
serverDao = serverDao,
sensorDao = sensorDao,
settingsDao = settingsDao,
+ mediaControlDao = mediaControlDao,
localStorage = localStorage,
)
}
@@ -346,6 +349,7 @@ class ServerManagerImplTest {
coEvery { localStorage.getInt("active_server") } returns null
coEvery { settingsDao.delete(serverId) } just Runs
coEvery { sensorDao.removeServer(serverId) } just Runs
+ coEvery { mediaControlDao.deleteByServerId(serverId) } just Runs
coEvery { serverDao.delete(serverId) } just Runs
coEvery { webSocketRepo.shutdown() } just Runs
@@ -360,6 +364,7 @@ class ServerManagerImplTest {
webSocketRepo.shutdown()
settingsDao.delete(serverId)
sensorDao.removeServer(serverId)
+ mediaControlDao.deleteByServerId(serverId)
serverDao.delete(serverId)
}
}
@@ -380,6 +385,7 @@ class ServerManagerImplTest {
coEvery { localStorage.remove("active_server") } just Runs
coEvery { settingsDao.delete(serverId) } just Runs
coEvery { sensorDao.removeServer(serverId) } just Runs
+ coEvery { mediaControlDao.deleteByServerId(serverId) } just Runs
coEvery { serverDao.delete(serverId) } just Runs
serverManager.removeServer(serverId)
@@ -402,6 +408,7 @@ class ServerManagerImplTest {
coEvery { localStorage.getInt("active_server") } returns 10
coEvery { settingsDao.delete(serverId) } just Runs
coEvery { sensorDao.removeServer(serverId) } just Runs
+ coEvery { mediaControlDao.deleteByServerId(serverId) } just Runs
coEvery { serverDao.delete(serverId) } just Runs
serverManager.removeServer(serverId)
@@ -426,6 +433,7 @@ class ServerManagerImplTest {
coEvery { localStorage.getInt("active_server") } returns null
coEvery { settingsDao.delete(serverId) } just Runs
coEvery { sensorDao.removeServer(serverId) } just Runs
+ coEvery { mediaControlDao.deleteByServerId(serverId) } just Runs
coEvery { serverDao.delete(serverId) } just Runs
coEvery { webSocketRepo.shutdown() } just Runs