Add native Android media controls for HA media_player entities#6626
Add native Android media controls for HA media_player entities#6626FletcherD wants to merge 98 commits intohome-assistant:mainfrom
Conversation
There was a problem hiding this comment.
Pull request overview
Adds a native Android MediaSession-backed surface for controlling a selected Home Assistant media_player entity from the notification shade, plus companion settings and supporting data plumbing.
Changes:
- Introduces
MediaControlRepository+ state model to observe a configuredmedia_playerand map HA state/attributes into media metadata and supported commands - Adds
HaMediaSessionServiceandHaRemoteMediaPlayerto expose the entity via Android’s media controls and forward transport/seek actions back to HA - Adds a new “Media controls” settings screen and preference entry to select/clear the exposed entity, plus unit tests and changelog entry
Reviewed changes
Copilot reviewed 25 out of 25 changed files in this pull request and generated 8 comments.
Show a summary per file
| File | Description |
|---|---|
| common/src/test/kotlin/io/homeassistant/companion/android/common/data/mediacontrol/MediaControlRepositoryImplTest.kt | Unit tests for repository configuration and HA→media state mapping |
| common/src/main/res/values/strings.xml | New UI strings for the media controls settings |
| common/src/main/kotlin/io/homeassistant/companion/android/common/data/prefs/PrefsRepositoryImpl.kt | Persists configured media control server/entity IDs; clears on server removal |
| common/src/main/kotlin/io/homeassistant/companion/android/common/data/prefs/PrefsRepository.kt | Adds prefs API for media controls configuration |
| common/src/main/kotlin/io/homeassistant/companion/android/common/data/mediacontrol/MediaControlState.kt | New state model + playback state types for media controls |
| common/src/main/kotlin/io/homeassistant/companion/android/common/data/mediacontrol/MediaControlRepositoryImpl.kt | Observes websocket entity updates and emits MediaControlState |
| common/src/main/kotlin/io/homeassistant/companion/android/common/data/mediacontrol/MediaControlRepository.kt | Repository interface for configuration + observation |
| common/src/main/kotlin/io/homeassistant/companion/android/common/data/mediacontrol/MediaControlModule.kt | Hilt binding for the new repository |
| common/src/main/kotlin/io/homeassistant/companion/android/common/data/integration/Entity.kt | Adds media_player supported-features constants and helper accessors |
| automotive/src/main/AndroidManifest.xml | Declares MediaSessionService and media playback FGS permission |
| automotive/lint-baseline.xml | Updates lint baseline (new ComposeUnstableCollections entries) |
| app/src/test/kotlin/io/homeassistant/companion/android/settings/mediacontrol/MediaControlSettingsViewModelTest.kt | Unit tests for settings ViewModel selection/save/clear behaviors |
| app/src/test/kotlin/io/homeassistant/companion/android/mediacontrol/HaRemoteMediaPlayerTest.kt | Robolectric tests for player state mapping, commands, and callbacks |
| app/src/main/res/xml/preferences.xml | Adds preference category/entry for “Media controls” |
| app/src/main/res/xml/changelog_master.xml | Adds user-facing changelog entry for the feature |
| app/src/main/res/drawable/ic_play_circle_outline.xml | New icon for the settings entry |
| app/src/main/kotlin/io/homeassistant/companion/android/webview/WebViewActivity.kt | Starts the media session service on app start when configured |
| app/src/main/kotlin/io/homeassistant/companion/android/settings/mediacontrol/views/MediaControlSettingsView.kt | Compose UI for selecting server/entity and saving/clearing |
| app/src/main/kotlin/io/homeassistant/companion/android/settings/mediacontrol/MediaControlSettingsViewModel.kt | Loads servers/entities/registries, manages selection, and starts/stops service |
| app/src/main/kotlin/io/homeassistant/companion/android/settings/mediacontrol/MediaControlSettingsFragment.kt | Fragment host for the Compose settings screen (+ help link) |
| app/src/main/kotlin/io/homeassistant/companion/android/settings/SettingsFragment.kt | Wires the new preference click to open the media controls settings |
| app/src/main/kotlin/io/homeassistant/companion/android/mediacontrol/HaRemoteMediaPlayer.kt | Media3 SimpleBasePlayer proxy translating commands to HA callbacks |
| app/src/main/kotlin/io/homeassistant/companion/android/mediacontrol/HaMediaSessionService.kt | MediaSessionService that observes HA state, loads artwork, and calls HA actions |
| app/src/main/AndroidManifest.xml | Declares MediaSessionService and media playback FGS permission |
| app/lint-baseline.xml | Updates lint baseline (new ComposeUnstableCollections entries) |
c9a2d04 to
785d6a5
Compare
Screen_recording_20260325_145042.mp4When you open the setting screen, it has a weird animation that shouldn't be there. |
|
@FletcherD it does look promising, I didn't go in details for now but I gave you some comments that are important to look at before going any further. |
|
Thanks for the comments, they're good ideas.
I exposed volume set/adjust which also allows the volume to be adjusted with the hardware buttons. |
| } | ||
|
|
||
| private fun createNotificationChannel() { | ||
| if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { |
TimoPtr
left a comment
There was a problem hiding this comment.
I focused my review on the MediaControl first. Let's make this right and then I'll move to the rest of the PR otherwise it takes too much time to review.
Also I got this StrictMode issue while testing did you?
!!! CRITICAL FAILURE: FAIL-FAST !!!
██████████████████████
An unrecoverable error has occurred, and the FailFast mechanism
has been triggered. The application cannot continue and will now exit.
ACTION REQUIRED: This error must be investigated and resolved.
Review the accompanying stack trace for details.
----------------------------------------------------------------
android.os.strictmode.CustomViolation: Downscaling oversized Icon Bitmap
at android.os.StrictMode$AndroidBlockGuardPolicy.onCustomSlowCall(StrictMode.java:1807)
at android.os.StrictMode.noteSlowCall(StrictMode.java:3073)
at android.graphics.drawable.Icon.scaleDownIfNecessary(Icon.java:1249)
at android.graphics.drawable.Icon.scaleDownIfNecessary(Icon.java:1271)
at android.app.Notification.reduceImageSizes(Notification.java:8782)
at android.app.Notification$Builder.build(Notification.java:8490)
at androidx.core.app.NotificationCompatBuilder.buildInternal(NotificationCompatBuilder.java:375)
at androidx.core.app.NotificationCompatBuilder.build(NotificationCompatBuilder.java:297)
at androidx.core.app.NotificationCompat$Builder.build(NotificationCompat.java:2617)
at io.homeassistant.companion.android.mediacontrol.HaMediaSessionService.buildNotification(HaMediaSessionService.kt:281)
at io.homeassistant.companion.android.mediacontrol.HaMediaSessionService.onUpdateNotification(HaMediaSessionService.kt:135)
at androidx.media3.session.MediaSessionService.onUpdateNotificationInternal(MediaSessionService.java:854)
at androidx.media3.session.MediaNotificationManager$MediaControllerListener.onEvents(MediaNotificationManager.java:495)
at androidx.media3.session.MediaControllerImplBase.lambda$new$0$androidx-media3-session-MediaControllerImplBase(MediaControllerImplBase.java:185)
at androidx.media3.session.MediaControllerImplBase$$ExternalSyntheticLambda86.invoke(D8$$SyntheticClass:0)
at androidx.media3.common.util.ListenerSet$ListenerHolder.iterationFinished(ListenerSet.java:477)
at androidx.media3.common.util.ListenerSet.handleMessage(ListenerSet.java:421)
at androidx.media3.common.util.ListenerSet.$r8$lambda$rFcF5Pkb99AL585p5-2u78YfNkY(ListenerSet.java:0)
at androidx.media3.common.util.ListenerSet$$ExternalSyntheticLambda0.handleMessage(D8$$SyntheticClass:0)
at android.os.Handler.dispatchMessageImpl(Handler.java:138)
at android.os.Handler.dispatchMessage(Handler.java:125)
at android.os.Looper.loopOnce(Looper.java:296)
at android.os.Looper.loop(Looper.java:397)
at android.app.ActivityThread.main(ActivityThread.java:9521)
at java.lang.reflect.Method.invoke(Native Method)
at com.android.internal.os.RuntimeInit$MethodAndArgsCaller.run(RuntimeInit.java:575)
at com.android.internal.os.WrapperInit.main(WrapperInit.java:94)
at com.android.internal.os.RuntimeInit.nativeFinishInit(Native Method)
at com.android.internal.os.RuntimeInit.main(RuntimeInit.java:367)
| @VisibleForTesting | ||
| internal fun startObservingEntities() { |
There was a problem hiding this comment.
| @VisibleForTesting | |
| internal fun startObservingEntities() { | |
| private fun startObservingEntities() { |
Now that the scope is given as parameters it is possible. Move this function after the overrides.
Replaces the MediaControlStarterViewModel + repeatOnLifecycle pattern in FrontendNavigation with a direct HaMediaSessionService.start() call in LaunchActivity.onResume(), consistent with how SensorWorker and WebsocketManager are already started there.
Context is only needed for Coil image loading, MediaSession construction, and PendingIntent creation — all of which work with application context. Hilt now injects it automatically, removing context from the @AssistedFactory interface so callers only need to supply the per-entity config.
…ation builder - Make mediaSession @VisibleForTesting internal (private set) instead of public - Add val id derived from config (matches the MediaSession ID assigned internally) - Add val isPlaying and val hasMediaContent for service-level playback checks - Add addToService/removeFromService to encapsulate MediaSessionService registration - Add buildNotification() so the service no longer needs to reach into the raw MediaSession - Change observe() callback to receive HaMediaSession instead of MediaSession - Update HaMediaSessionService to use all new APIs; remove its private buildNotification() - Fix pre-existing ktlint import order violation in LaunchActivity.kt
…tine completion All CommandCallback methods now return Job instead of Unit. handleCommand ties the returned ListenableFuture<Void> to the Job's completion via invokeOnCompletion, so the future resolves as soon as the HA network call finishes rather than waiting for the next WebSocket state update. The pendingCommandFuture field and its manual completion in updateState() and setConnecting() are removed.
…MediaAction suspend
The stored actionScope field is replaced by passing the coroutineScope{} receiver directly
to getCommandCallback(scope). callMediaAction is now a private suspend fun with an internal
withContext(Dispatchers.IO) switch, removing the need for the actionScope null guard.
Extract tearDownSession and launchSession as private helpers so reconcileSessions is a thin orchestrator responsible only for computing the diff and dispatching to the helpers.
Move all activeSessions reads and writes inside withContext(Dispatchers.Main) in reconcileSessions, eliminating the data race with onUpdateNotification and onTaskRemoved which are always called on Main by Media3 and the OS.
…wnscaling Add a size(256, 256) constraint to the Coil ImageRequest in loadBitmapAsPng so the bitmap is delivered at the notification large icon size before compression. This prevents Android's Icon.scaleDownIfNecessary from running on the main thread and triggering a StrictMode CustomViolation.
… notification builder" This reverts commit 275b4ec.
…mand After the revert of the over-scoped comment-4 change, wire up the two behaviors the unit tests expect: - observe() launches startObservingState() as a child and uses awaitCancellation() so the session stays alive after the WebSocket flow completes (e.g. transient disconnect). - getCommandCallback now accepts an onCommandComplete callback; after each HA action succeeds, restartObservationIfNeeded() re-launches startObservingState() if the observation job has ended. - Removed withContext(Dispatchers.IO) from callMediaAction: Retrofit suspend functions are non-blocking so Dispatchers.Default suffices, and this keeps the call synchronous under UnconfinedTestDispatcher.
…ation builder Address review comment: mediaSession should not be exposed directly to the service. HaMediaSession now exposes a stable val id, isPlaying/hasActiveMedia properties, unregisterFrom() for teardown, and buildNotification() instead of the raw session. NOTIFICATION_CHANNEL_ID moves to the HaMediaSession companion. Tests are updated to capture the MediaSession via the observe() callback and use buildNotification() for lifecycle assertions.
…oundOrStop HaMediaSession.id and the activeSessions map key both identified the same entity but used different separators (_ vs :). Align the separator to : so they are identical, then pass the key string directly to promoteForegroundOrStop instead of a hashCode int, removing the hash collision risk and the redundant identity derivation.
…o feature/native-media-controls
| SensorWorker.start(this) | ||
| lifecycleScope.launch { | ||
| WebsocketManager.start(this@LaunchActivity) | ||
| HaMediaSessionService.start(this@LaunchActivity) |
There was a problem hiding this comment.
You can probably update the LaunchActivitTest (unit test) to verify that this is properly called in onResume.
| /** | ||
| * Unregisters this session from [service] by calling | ||
| * [MediaSessionService.removeSession]. Has no effect if the session is not currently active. | ||
| */ | ||
| fun unregisterFrom(service: MediaSessionService) { | ||
| mediaSession?.let { service.removeSession(it) } | ||
| } |
There was a problem hiding this comment.
Let's not do that, we don't want to know the existence of the service within the session.
| if (!observationJob.isActive) { | ||
| Timber.d("observe: restarting observation after command for ${config.entityId}") | ||
| observationJob = launch { startObservingState() } | ||
| } |
There was a problem hiding this comment.
You shouldn't do that, the repository should make sure that you always are connected to the WS, you don't want the session to handle the internal logic of potentially losing the WS connection.
| } | ||
|
|
||
| private fun buildMediaSession(player: HaRemoteMediaPlayer): MediaSession = MediaSession.Builder(context, player) | ||
| .setId("${config.serverId}:${config.entityId}") |
There was a problem hiding this comment.
| .setId("${config.serverId}:${config.entityId}") | |
| .setId(id) |
| } | ||
| session.sessionActivity = PendingIntent.getActivity( | ||
| context, | ||
| "${config.serverId}:${config.entityId}".hashCode(), |
There was a problem hiding this comment.
| "${config.serverId}:${config.entityId}".hashCode(), | |
| id.hashCode(), |
| @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 |
There was a problem hiding this comment.
| @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 | |
| @AndroidEntryPoint | |
| class HaMediaSessionService @VisibleForTesting constructor(private val serviceScope: CoroutineScope, | |
| val mediaControlRepository: MediaControlRepository, val haMediaSessionFactory: HaMediaSession.Factory) : | |
| MediaSessionService() { | |
| @Inject constructor(val mediaControlRepository: MediaControlRepository, val haMediaSessionFactory: HaMediaSession.Factory) : this(CoroutineScope(SupervisorJob() + Dispatchers.Default), mediaControlRepository, haMediaSessionFactory) |
It allows you a better control and no more lateinit var

Summary
I wanted to be able to control a media player entity natively on Android without having to open the app or navigate to a widget. So this feature exposes one or more Home Assistant
media_playerentities as native Android Media Controls (described here) in the notification shade, the same UI used by other media players on Android.The media controls show the currently playing track info and play position with album art. Prev/next track, play/pause and seek controls work and are forwarded to the media_player entity (if the entity supports them).
A new "Media controls" setting is added under "Companion app" to choose which media_player entities to expose in the media controls, if any. You can choose more than one entity, in which case a notification will be created for each one.
Unit tests are added to test playback state mapping, state flow, settings and everything else I could think of.
Checklist
Screenshots
Link to pull request in documentation repositories
User Documentation PR: home-assistant/companion.home-assistant#1304