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