diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index c84fff808fe..87882c73e62 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). Must be called from the Main thread. */ + @get:MainThread + val hasActiveMedia: Boolean + get() = mediaSession?.player?.let { it.mediaItemCount > 0 } == true + + /** + * 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. + */ + @MainThread + @OptIn(UnstableApi::class) + fun buildNotification(): Notification? { + val session = mediaSession ?: return null + val metadata = session.player.mediaMetadata + return NotificationCompat.Builder(context, CHANNEL_MEDIA_SESSION) + .setStyle(MediaStyleNotificationHelper.MediaStyle(session)) + .setSmallIcon(commonR.drawable.ic_stat_ic_notification) + .setVisibility(NotificationCompat.VISIBILITY_PUBLIC) + .setCategory(NotificationCompat.CATEGORY_TRANSPORT) + .setContentTitle(metadata.title ?: notificationEntityName ?: id) + .setContentText(metadata.artist) + .setLargeIcon(notificationArtwork) + .setOngoing(session.player.isPlaying) + .setContentIntent(session.sessionActivity) + .build() + } + + private fun getCommandCallback(scope: CoroutineScope) = object : HaRemoteMediaPlayer.CommandCallback { + override fun onPlayRequested() = scope.launch { + callMediaAction(ACTION_MEDIA_PLAY) + } + + override fun onPauseRequested() = scope.launch { + callMediaAction(ACTION_MEDIA_PAUSE) + } + + override fun onSeekRequested(positionMs: Long) = scope.launch { + callMediaAction( + action = ACTION_MEDIA_SEEK, + extraData = mapOf("seek_position" to positionMs / 1000.0), + ) + } + + override fun onNextRequested() = scope.launch { + callMediaAction(ACTION_MEDIA_NEXT_TRACK) + } + + override fun onPreviousRequested() = scope.launch { + callMediaAction(ACTION_MEDIA_PREVIOUS_TRACK) + } + + override fun onSetVolumeRequested(volume: Float) = scope.launch { + callMediaAction( + action = ACTION_VOLUME_SET, + extraData = mapOf("volume_level" to volume), + ) + } + + override fun onIncreaseVolumeRequested() = scope.launch { + callMediaAction(ACTION_VOLUME_UP) + } + + override fun onDecreaseVolumeRequested() = scope.launch { + callMediaAction(ACTION_VOLUME_DOWN) + } + + override fun onMuteRequested(muted: Boolean) = scope.launch { + callMediaAction( + action = ACTION_VOLUME_MUTE, + extraData = mapOf("is_volume_muted" to muted), + ) + } + + override fun onStopRequested() = scope.launch { + callMediaAction(ACTION_MEDIA_STOP) + } + + override fun onShuffleRequested(shuffle: Boolean) = scope.launch { + callMediaAction( + action = ACTION_SHUFFLE_SET, + extraData = mapOf("shuffle" to shuffle), + ) + } + + 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), + ) + } + } + } + + /** + * 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}" + } + + // SupervisorJob without a parent: command failures don't propagate to the + // observation scope, and this scope does not block coroutineScope from completing + // when the entity state flow ends naturally. Cancelled explicitly in the finally block. + val commandScope = CoroutineScope( + coroutineContext + SupervisorJob() + CoroutineExceptionHandler { _, e -> + Timber.e(e, "Command failed for ${config.entityId}") + }, + ) + val player = HaRemoteMediaPlayer(Looper.getMainLooper(), getCommandCallback(commandScope)) + val session = buildMediaSession(player) + withContext(Dispatchers.Main) { mediaSession = session } + try { + onSessionReady(session) + startObservingState(player) + } finally { + commandScope.cancel() + Timber.d("observe: finally block running for ${config.entityId}, releasing player and session") + withContext(NonCancellable + Dispatchers.Main) { + mediaSession = null + notificationArtwork = null + notificationEntityName = null + player.release() + session.release() + } + } + } + } + + /** + * Observes entity state for [config] until the flow completes or the coroutine is cancelled. + */ + private suspend fun startObservingState(player: HaRemoteMediaPlayer) { + 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) { + notificationArtwork = null + notificationEntityName = null + player.updateState(state = null, artworkPngBytes = null) + } + } else { + artworkCache = loadArtworkAndUpdatePlayer(state, artworkCache, player) + } + } + Timber.d("startObservingState: flow collection ended for ${config.entityId}") + } + + private fun buildMediaSession(player: HaRemoteMediaPlayer): MediaSession = MediaSession.Builder(context, player) + .setId(id) + .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, + id.hashCode(), + tapIntent, + PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT, + ) + } + + private suspend fun callMediaAction(action: String, extraData: Map = emptyMap()) { + val actionData = buildMap { + put("entity_id", config.entityId) + 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, + player: HaRemoteMediaPlayer, + ): ArtworkCache { + val updatedCache = when (val pictureUrl = state.entityPictureUrl) { + null -> ArtworkCache() + cache.url -> cache + else -> { + val artworkData = resolveArtworkUrl(state)?.let { loadArtworkData(it) } + if (artworkData != null) { + ArtworkCache(url = pictureUrl, bytes = artworkData.first, bitmap = artworkData.second) + } else { + cache + } + } + } + + withContext(Dispatchers.Main) { + notificationArtwork = updatedCache.bitmap + notificationEntityName = state.entityFriendlyName + player.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 at its native resolution and returns full-resolution PNG bytes for media + * metadata alongside a notification-icon-sized bitmap for [setLargeIcon][android.app.Notification.Builder.setLargeIcon]. + * + * The bitmap is explicitly scaled to [android.R.dimen.notification_large_icon_width] on IO so + * that [android.graphics.drawable.Icon.scaleDownIfNecessary] has nothing to do on the Main + * thread, preventing a StrictMode CustomViolation on API 36+. + */ + private suspend fun loadArtworkData(url: String): Pair? = withContext(Dispatchers.IO) { + try { + val request = ImageRequest.Builder(context) + .data(url) + .allowHardware(false) + .build() + val result = context.imageLoader.execute(request) + result.image?.toBitmap()?.let { bitmap -> + val stream = ByteArrayOutputStream() + bitmap.compress(CompressFormat.PNG, 100, stream) + val iconSize = context.resources.getDimensionPixelSize(android.R.dimen.notification_large_icon_width) + val notificationBitmap = Bitmap.createScaledBitmap(bitmap, iconSize, iconSize, /* filter= */ true) + stream.toByteArray() to notificationBitmap + } + } 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, val bitmap: Bitmap? = null) + + companion object { + 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..3ed79ea707a --- /dev/null +++ b/app/src/main/kotlin/io/homeassistant/companion/android/mediacontrol/HaMediaSessionService.kt @@ -0,0 +1,289 @@ +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 io.homeassistant.companion.android.common.util.CHANNEL_MEDIA_SESSION +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 MediaControlEntityConfig.id. 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() + } + + // 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[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.mediaSession?.let { removeSession(it) } + job.cancel() + } + serviceScope.cancel() + super.onDestroy() + } + + @VisibleForTesting + internal fun startObservingEntities() { + mediaControlRepository.observeConfiguredEntities() + .onEach { entities -> reconcileSessions(entities) } + .launchIn(serviceScope) + } + + private suspend fun reconcileSessions(configuredEntities: List) { + val desiredKeys = configuredEntities.map { it.id }.toSet() + Timber.d("reconcileSessions: received ${configuredEntities.size} entities=$desiredKeys") + + 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 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.id == 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.mediaSession?.let { removeSession(it) } + 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 thread to safely mutate [activeSessions]. + */ + private fun launchSession(key: String, session: HaMediaSession) { + val job = serviceScope.launch { + session.observe { mediaSession -> addSession(mediaSession) } + // observe() returned normally (the entity state flow completed rather than suspending + // indefinitely). The finally block in observe() has already released Media3 resources. + // Remove the stale map entry so a subsequent reconcileSessions emission can restart + // the session if the entity is still configured. This path is not taken on cancellation + // (tearDownSession calls job.cancelAndJoin()), because CancellationException propagates + // past the line above and skips this cleanup. + withContext(Dispatchers.Main) { + activeSessions.remove(key) + Timber.d("Session $key observation ended normally, removed stale entry") + } + } + 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( + CHANNEL_MEDIA_SESSION, + 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") + } + } + } +} 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..96c442d2286 --- /dev/null +++ b/app/src/main/kotlin/io/homeassistant/companion/android/mediacontrol/HaRemoteMediaPlayer.kt @@ -0,0 +1,317 @@ +package io.homeassistant.companion.android.mediacontrol + +import android.os.Looper +import androidx.annotation.MainThread +import androidx.annotation.OptIn +import androidx.annotation.VisibleForTesting +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 + + /** + * The pending future from the most recent [handleCommand] call, if any. Completed by + * [updateState] when the server confirms the new state via WebSocket. Exposed for testing only. + */ + @VisibleForTesting + internal var pendingCommandFuture: SettableFuture? = null + + /** + * Updates the internal state from a new [MediaControlState] and triggers a state refresh. + * Completes any in-flight [pendingCommandFuture] so SimpleBasePlayer calls [getState] with + * the fresh data rather than the stale pre-command state. + * 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?) { + mediaState = state + artworkBytes = artworkPngBytes + pendingCommandFuture?.set(null) + pendingCommandFuture = null + invalidateState() + } + + override fun getState(): State { + 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 + // HA "Idle" (on, nothing playing) and Media3 STATE_IDLE share a name but mean different + // things: STATE_IDLE means "not prepared", which suppresses the notification until the + // player plays something. STATE_ENDED keeps the notification visible so the entity + // remains controllable. HA "Off" maps to STATE_IDLE for the opposite reason: the device + // is unavailable, so letting the notification disappear is the right behavior. + 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] that stays + * pending until [updateState] is called with the server-confirmed state. This prevents + * [SimpleBasePlayer] from calling [getState] with the stale pre-command [mediaState] during + * the window between the HTTP response and the WebSocket confirmation, which would cause a + * visible seek-bar regression. + * + * Any previously in-flight future is completed immediately to avoid leaking pending operations + * when commands arrive faster than the server confirms them. + * + * If [block] returns null (command not supported) or throws, returns an immediate failed future. + * If the [Job] completes with a non-[CancellationException] error, the future is failed as a + * safety fallback (though [callMediaAction] already catches all non-cancellation exceptions). + */ + private inline fun handleCommand(block: () -> Job?): ListenableFuture { + val job = try { + block() + } catch (e: Exception) { + return Futures.immediateFailedFuture(e) + } ?: return Futures.immediateFailedFuture(UnsupportedOperationException("Command not supported")) + // Complete any in-flight future so it doesn't stay in SimpleBasePlayer's pendingOperations. + pendingCommandFuture?.set(null) + val future = SettableFuture.create() + pendingCommandFuture = future + job.invokeOnCompletion { cause -> + // Do NOT complete the future on normal success or cancellation: updateState() is the + // primary completion path and runs with fresh server state from the WebSocket. + // Only fail the future on an unrecoverable error so the UI doesn't freeze. + if (cause != null && cause !is CancellationException) { + 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() + + 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..4eba6117600 --- /dev/null +++ b/app/src/main/kotlin/io/homeassistant/companion/android/settings/mediacontrol/MediaControlSettingsViewModel.kt @@ -0,0 +1,232 @@ +package io.homeassistant.companion.android.settings.mediacontrol + +import androidx.annotation.VisibleForTesting +import androidx.compose.runtime.Stable +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +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.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 +} + +/** + * A configured media player entity paired with its resolved display name and entity data. + * [name] always has a value — it falls back to [MediaControlEntityConfig.entityId] if [entity] + * has not yet been loaded from the server. [entity] is null until server data is available; + * the Compose layer uses it to resolve the entity icon via [LocalContext]. + */ +data class ConfiguredEntityItem( + val config: MediaControlEntityConfig, + val name: String, + val entity: Entity?, +) + +@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 configured entities, with names and entity data resolved from server data + val configuredEntityItems: 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, +) { + /** Entities for the selected server that are not yet configured, ready for the entity picker. */ + val availableEntities: List + get() { + val configuredForServer = configuredEntityItems + .filter { it.config.serverId == selectedServerId } + .mapTo(HashSet()) { it.config.entityId } + return (entitiesPerServer[selectedServerId] ?: emptyList()) + .filter { it.entityId !in configuredForServer } + } + + 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( + private val serverManager: ServerManager, + private val mediaControlRepository: MediaControlRepository, + private val backgroundDispatcher: CoroutineDispatcher, +) : ViewModel() { + + @Inject + constructor( + serverManager: ServerManager, + mediaControlRepository: MediaControlRepository, + ) : this(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 { + // Coroutine 1: load server data (entities + registries) from the network + viewModelScope.launch(backgroundDispatcher) { + val loadedServers = serverManager.servers() + val defaultServerId = serverManager.getServer()?.id ?: ServerManager.SERVER_ID_ACTIVE + _uiState.update { it.copy(servers = loadedServers, selectedServerId = defaultServerId) } + + 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 entitiesPerServer = entitiesDeferred.awaitAll().toMap() + _uiState.update { state -> + state.copy( + entitiesPerServer = entitiesPerServer, + entityRegistryPerServer = entityRegistryDeferred.awaitAll().toMap(), + deviceRegistryPerServer = deviceRegistryDeferred.awaitAll().toMap(), + areaRegistryPerServer = areaRegistryDeferred.awaitAll().toMap(), + // Re-resolve items now that entity names and data are available + configuredEntityItems = buildConfiguredItems( + entitiesPerServer, + state.configuredEntityItems.map { it.config }, + ), + isLoading = false, + ) + } + } + + // Coroutine 2: observe the DB-backed configured list; drives configuredEntityItems reactively + viewModelScope.launch { + mediaControlRepository.observeConfiguredEntities().collect { dbConfigs -> + _uiState.update { state -> + state.copy( + configuredEntityItems = buildConfiguredItems(state.entitiesPerServer, dbConfigs), + ) + } + if (dbConfigs.isNotEmpty()) { + _serviceEvents.emit(MediaControlServiceEvent.Start) + } + } + } + } + + /** Updates the selected server in the entity picker. */ + fun selectServerId(serverId: Int) { + _uiState.update { it.copy(selectedServerId = serverId) } + } + + /** + * 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) { + viewModelScope.launch { + val state = _uiState.value + val config = MediaControlEntityConfig( + serverId = state.selectedServerId, + entityId = entityId, + ) + if (state.configuredEntityItems.none { it.config == config }) { + val newConfigs = state.configuredEntityItems.map { it.config } + config + mediaControlRepository.setConfiguredEntities(newConfigs) + } + } + } + + /** Removes the configured entity at [index] from the list, then persists the change immediately. */ + fun removeEntity(index: Int) { + viewModelScope.launch { + val newConfigs = _uiState.value.configuredEntityItems + .map { it.config } + .toMutableList() + .also { it.removeAt(index) } + mediaControlRepository.setConfiguredEntities(newConfigs) + } + } + + private fun buildConfiguredItems( + entitiesPerServer: Map>, + configs: List, + ): List = configs.map { config -> + val entity = entitiesPerServer[config.serverId]?.firstOrNull { it.entityId == config.entityId } + ConfiguredEntityItem( + config = config, + name = entity?.friendlyName ?: config.entityId, + entity = entity, + ) + } + + 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 $serverId") + 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..8405e9506ff --- /dev/null +++ b/app/src/main/kotlin/io/homeassistant/companion/android/settings/mediacontrol/views/MediaControlSettingsScreen.kt @@ -0,0 +1,272 @@ +package io.homeassistant.companion.android.settings.mediacontrol.views + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +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.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.ColorFilter +import androidx.compose.ui.platform.LocalContext +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 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.integration.getIcon +import io.homeassistant.companion.android.common.data.mediacontrol.MediaControlEntityConfig +import io.homeassistant.companion.android.settings.mediacontrol.ConfiguredEntityItem +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 + +/** Displays the media controls settings screen, backed by [MediaControlSettingsViewModel]. */ +@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, +) { + LazyColumn( + contentPadding = PaddingValues(vertical = HADimens.SPACE4) + safeBottomPaddingValues(applyHorizontal = false), + modifier = modifier, + ) { + item { DescriptionSection() } + + 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") { + ServerDropdownSection( + uiState = uiState, + onServerSelected = onServerSelected, + modifier = Modifier.animateItem(), + ) + } + } + + item(key = "entity_picker") { + EntityPickerSection( + uiState = uiState, + onEntitySelected = onEntitySelected, + modifier = Modifier.animateItem(), + ) + } + + itemsIndexed( + items = uiState.configuredEntityItems, + key = { _, item -> item.config.id }, + ) { index, item -> + ConfiguredEntityRow( + item = item, + onRemove = { onRemoveEntity(index) }, + modifier = Modifier.animateItem(), + ) + } + } + } +} + +@Composable +private fun DescriptionSection() { + val colorScheme = LocalHAColorScheme.current + 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)) +} + +@Composable +private fun ServerDropdownSection( + uiState: MediaControlSettingsUiState, + onServerSelected: (Int) -> Unit, + modifier: Modifier = Modifier, +) { + Column(modifier = modifier) { + 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)) + } +} + +@Composable +private fun EntityPickerSection( + uiState: MediaControlSettingsUiState, + onEntitySelected: (String) -> Unit, + modifier: Modifier = Modifier, +) { + 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), + ) +} + +@Composable +private fun ConfiguredEntityRow( + item: ConfiguredEntityItem, + onRemove: () -> Unit, + modifier: Modifier = Modifier, +) { + val colorScheme = LocalHAColorScheme.current + val context = LocalContext.current + val entityIcon = remember(item.entity) { item.entity?.getIcon(context) } + + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(HADimens.SPACE3), + modifier = modifier + .fillMaxWidth() + .background(colorScheme.colorSurfaceLow) + .heightIn(min = HADimens.SPACE18) + .padding(vertical = HADimens.SPACE1, horizontal = HADimens.SPACE4), + ) { + if (entityIcon != null) { + Image( + asset = entityIcon, + colorFilter = ColorFilter.tint(colorScheme.colorTextSecondary), + contentDescription = null, + modifier = Modifier.size(HADimens.SPACE6), + ) + } else { + Spacer(modifier = Modifier.size(HADimens.SPACE6)) + } + Column(modifier = Modifier.weight(1f)) { + Text( + text = item.name, + style = HATextStyle.Body, + color = colorScheme.colorTextPrimary, + textAlign = TextAlign.Start, + ) + Text( + text = item.config.entityId, + 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( + isLoading = false, + configuredEntityItems = listOf( + ConfiguredEntityItem( + config = MediaControlEntityConfig(serverId = 1, entityId = "media_player.living_room"), + name = "Living Room", + entity = null, + ), + ConfiguredEntityItem( + config = MediaControlEntityConfig(serverId = 1, entityId = "media_player.bedroom"), + name = "Bedroom", + entity = null, + ), + ), + ), + 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 a67325e2117..f7704242884 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 @@ -129,6 +129,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 @@ -1353,6 +1354,7 @@ class WebViewActivity : lifecycleScope.launch { SensorWorker.start(this@WebViewActivity) WebsocketManager.start(this@WebViewActivity) + HaMediaSessionService.start(this@WebViewActivity) requestedOrientation = when (presenter.getScreenOrientation()) { getString( 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 24ed2aee086..4945438ec10 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" /> + + + ().showChangeLog(any(), any()) } just Runs @@ -83,14 +86,16 @@ class LaunchActivityTest { unmockkObject(WebsocketManager.Companion) unmockkObject(SensorReceiver.Companion) unmockkObject(DisabledLocationHandler) + unmockkObject(HaMediaSessionService.Companion) unmockkConstructor(ChangeLog::class) } @Test - fun `Given activity resumes then sensor worker and websocket manager are started and changelog is shown`() { + fun `Given activity resumes then sensor worker, websocket manager and media session service are started and changelog is shown`() { ActivityScenario.launch(LaunchActivity::class.java).use { verify { SensorWorker.start(any()) } coVerify { WebsocketManager.start(any()) } + verify { HaMediaSessionService.start(any()) } verify { DisabledLocationHandler.isLocationEnabled(any()) } coVerify { anyConstructed().showChangeLog(any(), eq(false)) } } diff --git a/app/src/test/kotlin/io/homeassistant/companion/android/mediacontrol/HaMediaSessionServiceTest.kt b/app/src/test/kotlin/io/homeassistant/companion/android/mediacontrol/HaMediaSessionServiceTest.kt new file mode 100644 index 00000000000..0160b7ec293 --- /dev/null +++ b/app/src/test/kotlin/io/homeassistant/companion/android/mediacontrol/HaMediaSessionServiceTest.kt @@ -0,0 +1,310 @@ +package io.homeassistant.companion.android.mediacontrol + +import android.os.Looper +import androidx.test.core.app.ApplicationProvider +import dagger.hilt.android.testing.HiltTestApplication +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.homeassistant.companion.android.testing.unit.ConsoleLogRule +import io.mockk.coEvery +import io.mockk.every +import io.mockk.mockk +import java.util.concurrent.atomic.AtomicInteger +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertSame +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.Shadows +import org.robolectric.Shadows.shadowOf +import org.robolectric.android.controller.ServiceController +import org.robolectric.annotation.Config + +/** Module-level counter for unique MediaSession IDs across tests within the same JVM process. */ +private val sessionCounter = AtomicInteger(0) + +/** + * Tests for [HaMediaSessionService] session reconciliation and lifecycle behavior. + * + * Session management is driven through [MediaControlRepository.observeConfiguredEntities] flow + * emissions via [HaMediaSessionService.startObservingEntities], rather than through [onCreate], + * which is intentionally not called in tests: [onCreate] triggers Hilt's field injection, which + * requires a fully-initialized Hilt application component that is not available in this test setup. + * Instead, dependencies are injected manually into the service's [Inject]-annotated fields after + * construction, and observation is started via [HaMediaSessionService.startObservingEntities]. + * + * The service is created via [ServiceController.of] (using [get] rather than [create]) so that + * the service is properly attached to an Android context without triggering [onCreate]. The + * [observationScope] (backed by [UnconfinedTestDispatcher]) is passed directly to the + * [HaMediaSessionService] constructor before observation starts, so that flow collection and + * session coroutines run eagerly and synchronously on the test dispatcher. + * + * Each test pre-populates [configuredEntitiesFlow] (replay=1) before starting observation, so + * the subscriber receives the value immediately upon subscribing. Subsequent emissions are + * delivered to the active subscriber. + * + * Main-looper tasks (such as [HaRemoteMediaPlayer.updateState] dispatched by [HaMediaSession]) + * are flushed with [idleMainLooper]. + */ +@OptIn(ExperimentalCoroutinesApi::class) +@RunWith(RobolectricTestRunner::class) +@Config(application = HiltTestApplication::class) +class HaMediaSessionServiceTest { + + @get:Rule + val consoleLogRule = ConsoleLogRule() + + private val mediaControlRepository: MediaControlRepository = mockk(relaxed = true) + private val serverManager: ServerManager = mockk(relaxed = true) + private val haMediaSessionFactory: HaMediaSession.Factory = mockk() + + // replay=1 ensures tryEmit always succeeds and the value is available to new subscribers. + private lateinit var configuredEntitiesFlow: MutableSharedFlow> + + // 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, + ) + } + + val instance = HaMediaSessionService(observationScope) + service = ServiceController.of(instance, null).get() + service.mediaControlRepository = mediaControlRepository + service.haMediaSessionFactory = haMediaSessionFactory + } + + @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 entity observation on the service using the test-controlled [observationScope] + * (passed to the constructor 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]). + * + * Called directly (not via [onCreate]) to avoid triggering Hilt field injection, which requires + * a fully-initialized Hilt component unavailable in this test setup. + */ + 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 = "media_player.test", + ) + + // -- 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.sessions.size) + assertTrue(service.sessions.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.sessions.size) + assertTrue(service.sessions.any { it.id == "1:${configA.entityId}" }) + assertTrue(service.sessions.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.sessions.size) + assertTrue(service.sessions.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.sessions.first() + + configuredEntitiesFlow.tryEmit(listOf(config)) + idleMainLooper() + + assertEquals(1, service.sessions.size) + assertSame(sessionBefore, service.sessions.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.sessions.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.sessions.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..d6ca01050ad --- /dev/null +++ b/app/src/test/kotlin/io/homeassistant/companion/android/mediacontrol/HaMediaSessionTest.kt @@ -0,0 +1,458 @@ +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 = "media_player.test", + ) + + 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 subscription + * ended), `observe()` returns normally and tears down the session. `mediaSession` becomes null + * and `buildNotification()` returns null, preventing a stale notification from remaining. + */ + @Test + fun `Given observeEntityState flow completes when startObservingState then session is torn down`() { + coEvery { mediaControlRepository.observeEntityState(config) } returns flowOf( + createState(playbackState = MediaPlaybackState.Playing), + ) + + val session = buildSession() + val job = testScope.launch { + session.observe { } + } + idleMainLooper() + + // The flow completed, so observe() exited via its finally block — session is torn down. + assertNull(session.buildNotification()) + org.junit.Assert.assertFalse(job.isActive) + } + + // -- 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..ad3df3ca88a --- /dev/null +++ b/app/src/test/kotlin/io/homeassistant/companion/android/mediacontrol/HaRemoteMediaPlayerTest.kt @@ -0,0 +1,689 @@ +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.every +import io.mockk.mockk +import io.mockk.verify +import kotlin.time.Duration +import kotlin.time.Duration.Companion.seconds +import kotlinx.coroutines.CompletableJob +import kotlinx.coroutines.Job +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 = "media_player.test", + 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) + } + + // -- Pending command future tests -- + + @Test + fun `Given command in progress when coroutine completes normally then future is still pending`() { + val commandJob: CompletableJob = Job() + every { commandCallback.onPauseRequested() } returns commandJob + + player.updateState(state = createState(playbackState = MediaPlaybackState.Playing), artworkPngBytes = null) + shadowOf(Looper.getMainLooper()).idle() + + player.pause() + shadowOf(Looper.getMainLooper()).idle() + + // Simulate the HTTP call returning successfully (before WebSocket update arrives) + commandJob.complete() + shadowOf(Looper.getMainLooper()).idle() + + // Future must still be pending — updateState() hasn't been called yet + assertFalse(player.pendingCommandFuture?.isDone ?: true) + } + + @Test + fun `Given command in progress when updateState called then future is resolved and state is updated`() { + val commandJob: CompletableJob = Job() + every { commandCallback.onPauseRequested() } returns commandJob + + player.updateState(state = createState(playbackState = MediaPlaybackState.Playing), artworkPngBytes = null) + shadowOf(Looper.getMainLooper()).idle() + + player.pause() + shadowOf(Looper.getMainLooper()).idle() + + // WebSocket state confirmation arrives + player.updateState(state = createState(playbackState = MediaPlaybackState.Paused), artworkPngBytes = null) + shadowOf(Looper.getMainLooper()).idle() + + // Future is cleared after completion + assertNull(player.pendingCommandFuture) + // Player reflects the server-confirmed paused state + assertEquals(Player.STATE_READY, player.playbackState) + assertFalse(player.playWhenReady) + } + + @Test + fun `Given command in progress when second command arrives then first future is immediately completed`() { + val firstJob: CompletableJob = Job() + val secondJob: CompletableJob = Job() + val jobs = listOf(firstJob, secondJob) + var callIndex = 0 + every { commandCallback.onPauseRequested() } answers { jobs[callIndex++] } + + player.updateState(state = createState(playbackState = MediaPlaybackState.Playing), artworkPngBytes = null) + shadowOf(Looper.getMainLooper()).idle() + + player.pause() + shadowOf(Looper.getMainLooper()).idle() + val firstFuture = player.pendingCommandFuture + assertFalse(firstFuture?.isDone ?: true) + + // Second command arrives before server confirms the first + player.pause() + shadowOf(Looper.getMainLooper()).idle() + + // First future is completed so it doesn't stay in SimpleBasePlayer's pendingOperations + assertTrue(firstFuture?.isDone ?: false) + // Second future is still pending + assertFalse(player.pendingCommandFuture?.isDone ?: true) + } +} 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..1c549fc9cb0 --- /dev/null +++ b/app/src/test/kotlin/io/homeassistant/companion/android/settings/mediacontrol/MediaControlSettingsViewModelTest.kt @@ -0,0 +1,218 @@ +package io.homeassistant.companion.android.settings.mediacontrol + +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.flow.MutableStateFlow +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 val configuredEntitiesFlow = MutableStateFlow>(emptyList()) + + 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.observeConfiguredEntities() } returns configuredEntitiesFlow + coEvery { mediaControlRepository.setConfiguredEntities(any()) } coAnswers { + configuredEntitiesFlow.value = firstArg() + } + } + + private fun createViewModel(): MediaControlSettingsViewModel { + return MediaControlSettingsViewModel( + serverManager = serverManager, + mediaControlRepository = mediaControlRepository, + backgroundDispatcher = testDispatcher, + ) + } + + @Nested + inner class InitializationTest { + + @Test + fun `Given no configured entities when viewModel created then configuredEntityItems is empty`() = runTest(testDispatcher) { + viewModel = createViewModel() + advanceUntilIdle() + + assertEquals(emptyList(), viewModel.uiState.value.configuredEntityItems) + } + + @Test + fun `Given configured entities when viewModel created then configuredEntityItems reflects repo`() = runTest(testDispatcher) { + configuredEntitiesFlow.value = listOf(MediaControlEntityConfig(serverId = 1, entityId = "media_player.tv")) + + viewModel = createViewModel() + advanceUntilIdle() + + assertEquals(1, viewModel.uiState.value.configuredEntityItems.size) + assertEquals("media_player.tv", viewModel.uiState.value.configuredEntityItems.first().config.entityId) + } + } + + @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") + advanceUntilIdle() + + assertEquals(1, viewModel.uiState.value.configuredEntityItems.size) + assertEquals("media_player.living_room", viewModel.uiState.value.configuredEntityItems.first().config.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") + advanceUntilIdle() + + viewModel.addEntity("media_player.tv") + advanceUntilIdle() + + assertEquals(1, viewModel.uiState.value.configuredEntityItems.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() + // Ensure init coroutines (which reset selectedServerId to default) complete first + advanceUntilIdle() + + viewModel.selectServerId(serverBId) + viewModel.addEntity("media_player.bedroom") + advanceUntilIdle() + + val addedItem = viewModel.uiState.value.configuredEntityItems.first() + assertEquals(serverBId, addedItem.config.serverId) + assertEquals("media_player.bedroom", addedItem.config.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") + // Advance between adds so the second call sees the updated configuredEntityItems + advanceUntilIdle() + viewModel.addEntity("media_player.radio") + advanceUntilIdle() + + viewModel.removeEntity(0) + advanceUntilIdle() + + assertEquals(1, viewModel.uiState.value.configuredEntityItems.size) + assertEquals("media_player.radio", viewModel.uiState.value.configuredEntityItems.first().config.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.serviceEvents.test { + viewModel.addEntity("media_player.tv") + advanceUntilIdle() + awaitItem() // Start for tv + + viewModel.addEntity("media_player.radio") + advanceUntilIdle() + awaitItem() // Start for radio + + 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/common/schemas/io.homeassistant.companion.android.database.AppDatabase/52.json b/common/schemas/io.homeassistant.companion.android.database.AppDatabase/52.json new file mode 100644 index 00000000000..b88ebbc7679 --- /dev/null +++ b/common/schemas/io.homeassistant.companion.android.database.AppDatabase/52.json @@ -0,0 +1,1181 @@ +{ + "formatVersion": 1, + "database": { + "version": 52, + "identityHash": "2001b0522c75782b992b5b833528b0dc", + "entities": [ + { + "tableName": "sensor_attributes", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`sensor_id` TEXT NOT NULL, `name` TEXT NOT NULL, `value` TEXT NOT NULL, `value_type` TEXT NOT NULL, PRIMARY KEY(`sensor_id`, `name`))", + "fields": [ + { + "fieldPath": "sensorId", + "columnName": "sensor_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "value", + "columnName": "value", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "valueType", + "columnName": "value_type", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "sensor_id", + "name" + ] + } + }, + { + "tableName": "authentication_list", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`host` TEXT NOT NULL, `username` TEXT NOT NULL, `password` TEXT NOT NULL, PRIMARY KEY(`host`))", + "fields": [ + { + "fieldPath": "host", + "columnName": "host", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "username", + "columnName": "username", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "password", + "columnName": "password", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "host" + ] + } + }, + { + "tableName": "sensors", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `server_id` INTEGER NOT NULL DEFAULT 0, `enabled` INTEGER NOT NULL, `registered` INTEGER DEFAULT NULL, `state` TEXT NOT NULL, `last_sent_state` TEXT DEFAULT NULL, `last_sent_icon` TEXT DEFAULT NULL, `state_type` TEXT NOT NULL, `type` TEXT NOT NULL, `icon` TEXT NOT NULL, `name` TEXT NOT NULL, `device_class` TEXT, `unit_of_measurement` TEXT, `state_class` TEXT, `entity_category` TEXT, `core_registration` TEXT, `app_registration` TEXT, PRIMARY KEY(`id`, `server_id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "serverId", + "columnName": "server_id", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "enabled", + "columnName": "enabled", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "registered", + "columnName": "registered", + "affinity": "INTEGER", + "defaultValue": "NULL" + }, + { + "fieldPath": "state", + "columnName": "state", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lastSentState", + "columnName": "last_sent_state", + "affinity": "TEXT", + "defaultValue": "NULL" + }, + { + "fieldPath": "lastSentIcon", + "columnName": "last_sent_icon", + "affinity": "TEXT", + "defaultValue": "NULL" + }, + { + "fieldPath": "stateType", + "columnName": "state_type", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "icon", + "columnName": "icon", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "deviceClass", + "columnName": "device_class", + "affinity": "TEXT" + }, + { + "fieldPath": "unitOfMeasurement", + "columnName": "unit_of_measurement", + "affinity": "TEXT" + }, + { + "fieldPath": "stateClass", + "columnName": "state_class", + "affinity": "TEXT" + }, + { + "fieldPath": "entityCategory", + "columnName": "entity_category", + "affinity": "TEXT" + }, + { + "fieldPath": "coreRegistration", + "columnName": "core_registration", + "affinity": "TEXT" + }, + { + "fieldPath": "appRegistration", + "columnName": "app_registration", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id", + "server_id" + ] + } + }, + { + "tableName": "sensor_settings", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`sensor_id` TEXT NOT NULL, `name` TEXT NOT NULL, `value` TEXT NOT NULL, `value_type` TEXT NOT NULL, `enabled` INTEGER NOT NULL, `entries` TEXT NOT NULL, PRIMARY KEY(`sensor_id`, `name`))", + "fields": [ + { + "fieldPath": "sensorId", + "columnName": "sensor_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "value", + "columnName": "value", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "valueType", + "columnName": "value_type", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "enabled", + "columnName": "enabled", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "entries", + "columnName": "entries", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "sensor_id", + "name" + ] + } + }, + { + "tableName": "button_widgets", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `server_id` INTEGER NOT NULL DEFAULT 0, `icon_name` TEXT NOT NULL, `domain` TEXT NOT NULL, `service` TEXT NOT NULL, `service_data` TEXT NOT NULL, `label` TEXT, `background_type` TEXT NOT NULL DEFAULT 'DAYNIGHT', `text_color` TEXT, `require_authentication` INTEGER NOT NULL DEFAULT 0, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "serverId", + "columnName": "server_id", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "iconName", + "columnName": "icon_name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "domain", + "columnName": "domain", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "service", + "columnName": "service", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "serviceData", + "columnName": "service_data", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "label", + "columnName": "label", + "affinity": "TEXT" + }, + { + "fieldPath": "backgroundType", + "columnName": "background_type", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "'DAYNIGHT'" + }, + { + "fieldPath": "textColor", + "columnName": "text_color", + "affinity": "TEXT" + }, + { + "fieldPath": "requireAuthentication", + "columnName": "require_authentication", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + } + }, + { + "tableName": "camera_widgets", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `server_id` INTEGER NOT NULL DEFAULT 0, `entity_id` TEXT NOT NULL, `tap_action` TEXT NOT NULL DEFAULT 'REFRESH', PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "serverId", + "columnName": "server_id", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "entityId", + "columnName": "entity_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "tapAction", + "columnName": "tap_action", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "'REFRESH'" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + } + }, + { + "tableName": "media_player_controls_widgets", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `server_id` INTEGER NOT NULL DEFAULT 0, `entity_id` TEXT NOT NULL, `label` TEXT, `show_skip` INTEGER NOT NULL, `show_seek` INTEGER NOT NULL, `show_volume` INTEGER NOT NULL, `show_source` INTEGER NOT NULL DEFAULT false, `background_type` TEXT NOT NULL DEFAULT 'DAYNIGHT', `text_color` TEXT, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "serverId", + "columnName": "server_id", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "entityId", + "columnName": "entity_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "label", + "columnName": "label", + "affinity": "TEXT" + }, + { + "fieldPath": "showSkip", + "columnName": "show_skip", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "showSeek", + "columnName": "show_seek", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "showVolume", + "columnName": "show_volume", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "showSource", + "columnName": "show_source", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "false" + }, + { + "fieldPath": "backgroundType", + "columnName": "background_type", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "'DAYNIGHT'" + }, + { + "fieldPath": "textColor", + "columnName": "text_color", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + } + }, + { + "tableName": "static_widget", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `server_id` INTEGER NOT NULL DEFAULT 0, `entity_id` TEXT NOT NULL, `attribute_ids` TEXT, `label` TEXT, `text_size` REAL NOT NULL, `state_separator` TEXT NOT NULL, `attribute_separator` TEXT NOT NULL, `tap_action` TEXT NOT NULL DEFAULT 'REFRESH', `last_update` TEXT NOT NULL, `background_type` TEXT NOT NULL DEFAULT 'DAYNIGHT', `text_color` TEXT, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "serverId", + "columnName": "server_id", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "entityId", + "columnName": "entity_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "attributeIds", + "columnName": "attribute_ids", + "affinity": "TEXT" + }, + { + "fieldPath": "label", + "columnName": "label", + "affinity": "TEXT" + }, + { + "fieldPath": "textSize", + "columnName": "text_size", + "affinity": "REAL", + "notNull": true + }, + { + "fieldPath": "stateSeparator", + "columnName": "state_separator", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "attributeSeparator", + "columnName": "attribute_separator", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "tapAction", + "columnName": "tap_action", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "'REFRESH'" + }, + { + "fieldPath": "lastUpdate", + "columnName": "last_update", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "backgroundType", + "columnName": "background_type", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "'DAYNIGHT'" + }, + { + "fieldPath": "textColor", + "columnName": "text_color", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + } + }, + { + "tableName": "todo_widget", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `server_id` INTEGER NOT NULL DEFAULT 0, `entity_id` TEXT NOT NULL, `background_type` TEXT NOT NULL DEFAULT 'DAYNIGHT', `text_color` TEXT, `show_completed` INTEGER NOT NULL DEFAULT true, `latest_update_data` TEXT, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "serverId", + "columnName": "server_id", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "entityId", + "columnName": "entity_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "backgroundType", + "columnName": "background_type", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "'DAYNIGHT'" + }, + { + "fieldPath": "textColor", + "columnName": "text_color", + "affinity": "TEXT" + }, + { + "fieldPath": "showCompleted", + "columnName": "show_completed", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "true" + }, + { + "fieldPath": "latestUpdateData", + "columnName": "latest_update_data", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + } + }, + { + "tableName": "template_widgets", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `server_id` INTEGER NOT NULL DEFAULT 0, `template` TEXT NOT NULL, `text_size` REAL NOT NULL DEFAULT 12.0, `last_update` TEXT NOT NULL, `background_type` TEXT NOT NULL DEFAULT 'DAYNIGHT', `text_color` TEXT, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "serverId", + "columnName": "server_id", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "template", + "columnName": "template", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "textSize", + "columnName": "text_size", + "affinity": "REAL", + "notNull": true, + "defaultValue": "12.0" + }, + { + "fieldPath": "lastUpdate", + "columnName": "last_update", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "backgroundType", + "columnName": "background_type", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "'DAYNIGHT'" + }, + { + "fieldPath": "textColor", + "columnName": "text_color", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + } + }, + { + "tableName": "notification_history", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `received` INTEGER NOT NULL, `message` TEXT NOT NULL, `data` TEXT NOT NULL, `source` TEXT NOT NULL, `server_id` INTEGER)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "received", + "columnName": "received", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "message", + "columnName": "message", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "data", + "columnName": "data", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "source", + "columnName": "source", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "serverId", + "columnName": "server_id", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + } + }, + { + "tableName": "location_history", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `created` INTEGER NOT NULL, `trigger` TEXT NOT NULL, `result` TEXT NOT NULL, `latitude` REAL, `longitude` REAL, `location_name` TEXT, `accuracy` INTEGER, `data` TEXT, `server_id` INTEGER)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "created", + "columnName": "created", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "trigger", + "columnName": "trigger", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "result", + "columnName": "result", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "latitude", + "columnName": "latitude", + "affinity": "REAL" + }, + { + "fieldPath": "longitude", + "columnName": "longitude", + "affinity": "REAL" + }, + { + "fieldPath": "locationName", + "columnName": "location_name", + "affinity": "TEXT" + }, + { + "fieldPath": "accuracy", + "columnName": "accuracy", + "affinity": "INTEGER" + }, + { + "fieldPath": "data", + "columnName": "data", + "affinity": "TEXT" + }, + { + "fieldPath": "serverId", + "columnName": "server_id", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + } + }, + { + "tableName": "qs_tiles", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `tile_id` TEXT NOT NULL, `added` INTEGER NOT NULL DEFAULT 1, `server_id` INTEGER NOT NULL DEFAULT 0, `icon_name` TEXT, `entity_id` TEXT NOT NULL, `label` TEXT NOT NULL, `subtitle` TEXT, `should_vibrate` INTEGER NOT NULL DEFAULT 0, `auth_required` INTEGER NOT NULL DEFAULT 0)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "tileId", + "columnName": "tile_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "added", + "columnName": "added", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "1" + }, + { + "fieldPath": "serverId", + "columnName": "server_id", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "iconName", + "columnName": "icon_name", + "affinity": "TEXT" + }, + { + "fieldPath": "entityId", + "columnName": "entity_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "label", + "columnName": "label", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "subtitle", + "columnName": "subtitle", + "affinity": "TEXT" + }, + { + "fieldPath": "shouldVibrate", + "columnName": "should_vibrate", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "authRequired", + "columnName": "auth_required", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + } + }, + { + "tableName": "favorites", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `position` INTEGER NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "position", + "columnName": "position", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + } + }, + { + "tableName": "favorite_cache", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `friendly_name` TEXT NOT NULL, `icon` TEXT, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "friendlyName", + "columnName": "friendly_name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "icon", + "columnName": "icon", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + } + }, + { + "tableName": "camera_tiles", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `entity_id` TEXT, `refresh_interval` INTEGER, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "entityId", + "columnName": "entity_id", + "affinity": "TEXT" + }, + { + "fieldPath": "refreshInterval", + "columnName": "refresh_interval", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + } + }, + { + "tableName": "thermostat_tiles", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `entity_id` TEXT, `refresh_interval` INTEGER, `target_temperature` REAL, `show_entity_name` INTEGER, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "entityId", + "columnName": "entity_id", + "affinity": "TEXT" + }, + { + "fieldPath": "refreshInterval", + "columnName": "refresh_interval", + "affinity": "INTEGER" + }, + { + "fieldPath": "targetTemperature", + "columnName": "target_temperature", + "affinity": "REAL" + }, + { + "fieldPath": "showEntityName", + "columnName": "show_entity_name", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + } + }, + { + "tableName": "entity_state_complications", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `entity_id` TEXT NOT NULL, `show_title` INTEGER NOT NULL DEFAULT 1, `show_unit` INTEGER NOT NULL DEFAULT 0, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "entityId", + "columnName": "entity_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "showTitle", + "columnName": "show_title", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "1" + }, + { + "fieldPath": "showUnit", + "columnName": "show_unit", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + } + }, + { + "tableName": "servers", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `_name` TEXT NOT NULL, `name_override` TEXT, `_version` TEXT, `device_registry_id` TEXT, `list_order` INTEGER NOT NULL, `device_name` TEXT, `external_url` TEXT NOT NULL, `internal_url` TEXT, `cloud_url` TEXT, `webhook_id` TEXT, `secret` TEXT, `cloudhook_url` TEXT, `use_cloud` INTEGER NOT NULL, `internal_ssids` TEXT NOT NULL, `internal_ethernet` INTEGER, `internal_vpn` INTEGER, `prioritize_internal` INTEGER NOT NULL, `allow_insecure_connection` INTEGER, `access_token` TEXT, `refresh_token` TEXT, `token_expiration` INTEGER, `token_type` TEXT, `install_id` TEXT, `user_id` TEXT, `user_name` TEXT, `user_is_owner` INTEGER, `user_is_admin` INTEGER)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "_name", + "columnName": "_name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "nameOverride", + "columnName": "name_override", + "affinity": "TEXT" + }, + { + "fieldPath": "_version", + "columnName": "_version", + "affinity": "TEXT" + }, + { + "fieldPath": "deviceRegistryId", + "columnName": "device_registry_id", + "affinity": "TEXT" + }, + { + "fieldPath": "listOrder", + "columnName": "list_order", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "deviceName", + "columnName": "device_name", + "affinity": "TEXT" + }, + { + "fieldPath": "connection.externalUrl", + "columnName": "external_url", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "connection.internalUrl", + "columnName": "internal_url", + "affinity": "TEXT" + }, + { + "fieldPath": "connection.cloudUrl", + "columnName": "cloud_url", + "affinity": "TEXT" + }, + { + "fieldPath": "connection.webhookId", + "columnName": "webhook_id", + "affinity": "TEXT" + }, + { + "fieldPath": "connection.secret", + "columnName": "secret", + "affinity": "TEXT" + }, + { + "fieldPath": "connection.cloudhookUrl", + "columnName": "cloudhook_url", + "affinity": "TEXT" + }, + { + "fieldPath": "connection.useCloud", + "columnName": "use_cloud", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "connection.internalSsids", + "columnName": "internal_ssids", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "connection.internalEthernet", + "columnName": "internal_ethernet", + "affinity": "INTEGER" + }, + { + "fieldPath": "connection.internalVpn", + "columnName": "internal_vpn", + "affinity": "INTEGER" + }, + { + "fieldPath": "connection.prioritizeInternal", + "columnName": "prioritize_internal", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "connection.allowInsecureConnection", + "columnName": "allow_insecure_connection", + "affinity": "INTEGER" + }, + { + "fieldPath": "session.accessToken", + "columnName": "access_token", + "affinity": "TEXT" + }, + { + "fieldPath": "session.refreshToken", + "columnName": "refresh_token", + "affinity": "TEXT" + }, + { + "fieldPath": "session.tokenExpiration", + "columnName": "token_expiration", + "affinity": "INTEGER" + }, + { + "fieldPath": "session.tokenType", + "columnName": "token_type", + "affinity": "TEXT" + }, + { + "fieldPath": "session.installId", + "columnName": "install_id", + "affinity": "TEXT" + }, + { + "fieldPath": "user.id", + "columnName": "user_id", + "affinity": "TEXT" + }, + { + "fieldPath": "user.name", + "columnName": "user_name", + "affinity": "TEXT" + }, + { + "fieldPath": "user.isOwner", + "columnName": "user_is_owner", + "affinity": "INTEGER" + }, + { + "fieldPath": "user.isAdmin", + "columnName": "user_is_admin", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + } + }, + { + "tableName": "settings", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `websocket_setting` TEXT NOT NULL, `sensor_update_frequency` TEXT NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "websocketSetting", + "columnName": "websocket_setting", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sensorUpdateFrequency", + "columnName": "sensor_update_frequency", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + } + }, + { + "tableName": "media_control_entity_config", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `server_id` INTEGER NOT NULL, `entity_id` TEXT NOT NULL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "serverId", + "columnName": "server_id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "entityId", + "columnName": "entity_id", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + } + } + ], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '2001b0522c75782b992b5b833528b0dc')" + ] + } +} \ No newline at end of file diff --git a/common/src/main/kotlin/io/homeassistant/companion/android/common/data/integration/Entity.kt b/common/src/main/kotlin/io/homeassistant/companion/android/common/data/integration/Entity.kt index d04a167faee..77b65aef6c5 100644 --- a/common/src/main/kotlin/io/homeassistant/companion/android/common/data/integration/Entity.kt +++ b/common/src/main/kotlin/io/homeassistant/companion/android/common/data/integration/Entity.kt @@ -24,6 +24,8 @@ import java.time.format.DateTimeFormatter import java.time.format.DateTimeParseException import java.util.Locale import kotlin.math.round +import kotlin.time.Duration +import kotlin.time.Duration.Companion.seconds import kotlinx.coroutines.CancellationException import kotlinx.serialization.KSerializer import kotlinx.serialization.Polymorphic @@ -141,7 +143,16 @@ object EntityExt { const val LIGHT_SUPPORT_BRIGHTNESS_DEPR = 1 const val LIGHT_SUPPORT_COLOR_TEMP_DEPR = 2 const val ALARM_CONTROL_PANEL_SUPPORT_ARM_AWAY = 2 + const val MEDIA_PLAYER_SUPPORT_PAUSE = 1 + const val MEDIA_PLAYER_SUPPORT_SEEK = 2 const val MEDIA_PLAYER_SUPPORT_VOLUME_SET = 4 + const val MEDIA_PLAYER_SUPPORT_VOLUME_MUTE = 8 + const val MEDIA_PLAYER_SUPPORT_PREVIOUS_TRACK = 16 + const val MEDIA_PLAYER_SUPPORT_NEXT_TRACK = 32 + const val MEDIA_PLAYER_SUPPORT_STOP = 4096 + const val MEDIA_PLAYER_SUPPORT_PLAY = 16384 + const val MEDIA_PLAYER_SUPPORT_SHUFFLE_SET = 32768 + const val MEDIA_PLAYER_SUPPORT_REPEAT_SET = 262144 val DOMAINS_PRESS = listOf("button", "input_button") val DOMAINS_TOGGLE = listOf( @@ -452,6 +463,15 @@ fun Entity.getVolumeLevel(): EntityPosition? { } } +fun Entity.getVolumeMuted(): Boolean { + return try { + (attributes["is_volume_muted"] as? Boolean) ?: false + } catch (e: Exception) { + Timber.tag(EntityExt.TAG).e(e, "Unable to get getVolumeMuted") + false + } +} + fun Entity.getVolumeStep(): Float { return try { if (!supportsVolumeSet()) return 0.1f @@ -1280,3 +1300,93 @@ fun Entity.isActive() = when { (domain == CAMERA_DOMAIN) -> 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..819fa66cfb0 --- /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 identifier for this config, used as both the Compose list item key and [androidx.media3.session.MediaSession] ID. */ + val id: 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..cdcd07b1ee0 --- /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, + * then continues emitting whenever its state changes. + * Emits null when the entity is unavailable. + */ + 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. */ + 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..5aed608d669 --- /dev/null +++ b/common/src/main/kotlin/io/homeassistant/companion/android/common/data/mediacontrol/MediaControlRepositoryImpl.kt @@ -0,0 +1,180 @@ +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.friendlyName +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 immediately so the caller has something to show right away. + getEntityState(config)?.let { + 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 { + currentEntity = it.toEntity(config.entityId) + } + event.changed?.get(config.entityId)?.let { diff -> + currentEntity = currentEntity?.applyCompressedStateDiff(diff) + } + event.removed?.let { removed -> + if (config.entityId in removed) { + 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.map { config -> + MediaControlConfig( + serverId = config.serverId, + entityId = config.entityId, + ) + }, + ) + } +} + +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 = friendlyName, + 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..0c4d8040ace --- /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/common/util/AppNotifChannels.kt b/common/src/main/kotlin/io/homeassistant/companion/android/common/util/AppNotifChannels.kt index 7b4ae3f0e27..4d45865cc59 100755 --- a/common/src/main/kotlin/io/homeassistant/companion/android/common/util/AppNotifChannels.kt +++ b/common/src/main/kotlin/io/homeassistant/companion/android/common/util/AppNotifChannels.kt @@ -17,6 +17,7 @@ const val CHANNEL_DOWNLOADS = "downloads" const val CHANNEL_GENERAL = "general" const val CHANNEL_BEACON_MONITOR = "beacon" const val CHANNEL_ASSIST_LISTENING = "assist_listening" +const val CHANNEL_MEDIA_SESSION = "media_session" /** * List of all notification channel IDs created by the app. @@ -38,4 +39,5 @@ val appCreatedChannels = listOf( CHANNEL_GENERAL, CHANNEL_BEACON_MONITOR, CHANNEL_ASSIST_LISTENING, + CHANNEL_MEDIA_SESSION, ) 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..dddaa976ad2 --- /dev/null +++ b/common/src/main/kotlin/io/homeassistant/companion/android/database/mediacontrol/MediaControlConfig.kt @@ -0,0 +1,17 @@ +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, +) 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..bdbf717942d --- /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") + fun getAllFlow(): Flow> + + @Query("SELECT * FROM media_control_entity_config") + 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 6d7a2293e65..950f5a05e6c 100644 --- a/common/src/main/res/values/strings.xml +++ b/common/src/main/res/values/strings.xml @@ -455,6 +455,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..b9b4d53ef1a --- /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"), + ) + + assertEquals( + listOf(MediaControlEntityConfig(serverId = 1, entityId = "media_player.tv")), + repository.getConfiguredEntities(), + ) + } + + @Test + fun `Given entities when setConfiguredEntities then replaces all in database`() = 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"), + MediaControlConfig(serverId = 2, entityId = "media_player.office"), + ), + ) + } + } + } +} 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