Skip to content
Draft
Show file tree
Hide file tree
Changes from 122 commits
Commits
Show all changes
137 commits
Select commit Hold shift + click to select a range
75e9d8b
Add native Android media controls for HA media_player entities
FletcherD Mar 25, 2026
20bb0b1
1. Dispatchers.Main → Dispatchers.Default in service scope
FletcherD Mar 26, 2026
3b3178d
Add volume control to native media controls
FletcherD Mar 26, 2026
ba45502
Decouple session logic from HaMediaSessionService into HaMediaSession
FletcherD Mar 26, 2026
45a112b
Stop media session service when its configured server is deleted
FletcherD Mar 26, 2026
133c278
Support multiple media_player entities as native media controls
FletcherD Mar 26, 2026
3bd574e
add COMMAND_SET_DEVICE_VOLUME and COMMAND_ADJUST_DEVICE_VOLUME back a…
FletcherD Mar 26, 2026
c83de10
Put the player into STATE_BUFFERING on disconnect when the WebSocket …
FletcherD Mar 26, 2026
7028496
Address PR review feedback on media session code
FletcherD Mar 26, 2026
4ee48ed
Use URL for artwork path
FletcherD Mar 26, 2026
2b1cbf2
Refactor Media Control settings UI to use reorderable picker similar …
FletcherD Mar 26, 2026
bbad9c2
Refactor to avoid ComposeUnstableCollections issues, remove stale lin…
FletcherD Mar 27, 2026
f484849
Store media control configuration in the database instead of PrefsRep…
FletcherD Mar 27, 2026
c434c30
Fix crash when adding media control entity; Fix artwork disappearing …
FletcherD Mar 27, 2026
e75df17
Fetch media information and artwork immediately on startObservingState
FletcherD Mar 27, 2026
16805bd
Remove Save and Clear All buttons from media control settings page, s…
FletcherD Mar 27, 2026
eea9e85
Minor cleanup
FletcherD Mar 27, 2026
cf2db98
Fix multi-entity session routing; add @AssistedInject and unit tests
FletcherD Mar 27, 2026
f11db4c
ktlint fixes
FletcherD Mar 27, 2026
2b3209d
Reactively manage sessions via observeConfiguredEntities() flow
FletcherD Mar 28, 2026
52ba3b7
Fix media controls not appearing on cold start
FletcherD Mar 28, 2026
b14c1e5
Fix media controls not appearing on cold start on V2 frontend
FletcherD Mar 29, 2026
1ad0c4e
Expose stop, mute, shuffle, and repeat functions to media controls
FletcherD Mar 29, 2026
5abd769
Merge branch 'main' into feature/native-media-controls
FletcherD Mar 29, 2026
8375610
Expose more metadata fields from media player entity
FletcherD Mar 29, 2026
045ede1
Make media controls settings page more consistent with other settings
FletcherD Mar 29, 2026
1290d3e
Remove setContentBufferedPositionMs() call which is useless
FletcherD Mar 29, 2026
d36ba76
Tapping media controls notification body opens the media player entit…
FletcherD Apr 1, 2026
5563c41
Address mechanical PR review comments from TimoPtr
FletcherD Apr 2, 2026
c5089c0
Address coroutine architecture PR review comments
FletcherD Apr 2, 2026
b65ddb0
Split up HaRemoteMediaPlayer.kt getState(); use entityFriendlyName in…
FletcherD Apr 2, 2026
23c5d38
→ use ; Remove the four fields.
FletcherD Apr 2, 2026
8702016
Address MediaControlSettings PR review comments
FletcherD Apr 2, 2026
70080ba
Fix showing entity name instead of track title; Add small app icon to…
FletcherD Apr 2, 2026
4a4f113
When disconnected, retry only once when opening app instead of in a l…
FletcherD Apr 3, 2026
ebe2801
Simplify startObservingState by moving initial fetch into observeEnti…
FletcherD Apr 3, 2026
9a2cec1
FrontendViewModel.kt: Removed appContext: Context, mediaControlReposi…
FletcherD Apr 3, 2026
09ac27e
ktlint fix
FletcherD Apr 3, 2026
06172da
loadArtworkAndUpdatePlayer now uses state.entityPictureUrl as the cac…
FletcherD Apr 5, 2026
fb0612c
Don't include .size() when requesting artwork image
FletcherD Apr 8, 2026
612a1f8
Remove ServerRegistries in MediaControlSettingsViewModel.kt; Remove '…
FletcherD Apr 8, 2026
5867fe5
HaMediaSessionTest.kt: Remove Thread.sleep
FletcherD Apr 8, 2026
b6af049
HaMediaSessionServiceTest.kt: Don't call reconcileSessions() directly…
FletcherD Apr 9, 2026
5c1251a
Remove fake 3-item playlist from HaRemoteMediaPlayer
FletcherD Apr 9, 2026
2a28179
Remove automotive and Meya Quest support for media controls
FletcherD Apr 9, 2026
0d1bd25
Remove Meta Quest support for media controls
FletcherD Apr 9, 2026
b7d9e0b
Add screenshot tests for MediaControlSettingsScreen
FletcherD Apr 9, 2026
1c9b63f
Merge origin/main into feature/native-media-controls
FletcherD Apr 9, 2026
c3641e6
Remove the reconnect() call from onStartCommand so we don't cancel We…
FletcherD Apr 14, 2026
08b8d6f
Remove retry loop from startObservingState()
FletcherD Apr 14, 2026
4278fb2
Refactor HaMediaSession.kt: Remove scope creation from this class. In…
FletcherD Apr 14, 2026
26a8a83
Code clarity in HaRemoteMediaPlayer.kt
FletcherD Apr 14, 2026
6b823b1
HaRemoteMediaPlayer.kt
FletcherD Apr 14, 2026
7de40bb
Address PR review comments (batch 1)
FletcherD Apr 14, 2026
9d48643
HaMediaSessionService: Document why onTaskRemoved skips stopSelf when…
FletcherD Apr 14, 2026
eda99bf
HaMediaSessionService: Remove repository param from startIfConfigured
FletcherD Apr 14, 2026
5766948
WebViewActivity: Revert unrelated deep-link handling change
FletcherD Apr 14, 2026
c491d11
MediaControlSettings: Move HATheme from Screen composable into Fragme…
FletcherD Apr 14, 2026
1f607e8
HaMediaSessionServiceTest: Remove redundant unmockkAll() — instance m…
FletcherD Apr 14, 2026
3b85483
MediaControlConfig: Rename position column to index
FletcherD Apr 14, 2026
5d53fe6
Revert changes to ServerSettingsPresenterImpl.kt; Revert log message …
FletcherD Apr 15, 2026
20757c4
Fix crash when removing/readding entity in settings
FletcherD Apr 15, 2026
e2c2120
Workaround for notification disappearing after adding two entities an…
FletcherD Apr 15, 2026
fd69a5d
Refactor to fix multiple entitites: Build MediaStyle notifications ou…
FletcherD Apr 15, 2026
a695d13
Set notification when playing so it can't be swiped away (dismissibl…
FletcherD Apr 15, 2026
e75dea8
Remove entity reordering from media controls settings screen
FletcherD Apr 15, 2026
1b38f33
Fix MediaControlRepositoryImplTest tests
FletcherD Apr 15, 2026
3575cfe
Fix subtitle in media control settings entity row; Fix defunct notifi…
FletcherD Apr 16, 2026
a3ffa0a
Improve media control settings screen: Don't show anything until load…
FletcherD Apr 16, 2026
0ad3588
Make activeSessions, reconcileSessions, and startObservingEntities pr…
FletcherD Apr 16, 2026
82895ff
MediaControlSettingsViewModel.kt: inject the Default dispatcher into …
FletcherD Apr 16, 2026
99b6b10
Media controls tests: Clean up properly after tests
FletcherD Apr 17, 2026
3c65412
Update screenshot tests for media control settings changes and regene…
FletcherD Apr 17, 2026
1eda4e4
Merge origin/main into feature/native-media-controls
FletcherD Apr 17, 2026
ad135f5
All `handle*()` methods return a pending `SettableFuture<Void>` (stor…
FletcherD Apr 17, 2026
e0e43d6
Cleanup test structure
FletcherD Apr 23, 2026
3b32629
Merge origin/main into feature/native-media-controls
FletcherD May 1, 2026
adee5e9
Remove unnecessary SDK_INT version check
FletcherD May 5, 2026
cb0f9d5
Merge branch 'main' into feature/native-media-controls
FletcherD May 5, 2026
304ed6c
Move media control service start to LaunchActivity
FletcherD May 5, 2026
30073b4
Use @ApplicationContext in HaMediaSession constructor
FletcherD May 5, 2026
275b4ec
Encapsulate MediaSession inside HaMediaSession, expose id and notific…
FletcherD May 5, 2026
c4b1784
Refactor CommandCallback to return Job, tie ListenableFuture to corou…
FletcherD May 5, 2026
c222e81
Remove actionScope field, pass scope to getCommandCallback, make call…
FletcherD May 5, 2026
d3794a2
Add FailFast guard in HaMediaSession.observe() for double-call
FletcherD May 5, 2026
535cb96
Remove verbose log in observe(), make startObservingState private
FletcherD May 5, 2026
9baa5b1
Use LaunchActivity for media notification tap intent
FletcherD May 5, 2026
c6c4314
Simplify loadArtworkAndUpdatePlayer in HaMediaSession
FletcherD May 5, 2026
486a859
Fix log message in resolveArtworkUrl to reference server ID
FletcherD May 5, 2026
71bb517
Split reconcileSessions into smaller helper functions
FletcherD May 5, 2026
f5a47c4
Remove unnecessary onStartCommand override in HaMediaSessionService
FletcherD May 5, 2026
4f5be54
Confine activeSessions map access to Main thread
FletcherD May 5, 2026
6f6fa04
Pre-scale album art to notification icon size to avoid main thread do…
FletcherD May 5, 2026
fddbcd1
Revert "Encapsulate MediaSession inside HaMediaSession, expose id and…
FletcherD May 6, 2026
623bb57
Restore tests: observe() stays alive after flow ends, restarts on com…
FletcherD May 6, 2026
eaf71d6
Encapsulate MediaSession inside HaMediaSession, expose id and notific…
FletcherD May 6, 2026
7c4b248
Unify session ID format with map key; use key string in promoteForegr…
FletcherD May 6, 2026
f6cd168
Merge remote-tracking branch 'fork/feature/native-media-controls' int…
FletcherD May 6, 2026
b416ec7
Fix HaMediaSessionService constructor and test
FletcherD May 10, 2026
6336c81
add commandScope in HaMediaSession so thrown exceptions are handled g…
FletcherD May 10, 2026
168fd9c
remove restartObservationIfNeeded; remove unregisterFrom
FletcherD May 10, 2026
09e456d
Add test to verify HaMediaSessionService.start is called in LaunchAct…
FletcherD May 10, 2026
cf42a8f
Address trivial PR review comments from @TimoPtr
FletcherD May 12, 2026
e85b81a
Address more PR review comments from @TimoPtr
FletcherD May 13, 2026
f4ff7c5
Address threading PR review comments (threads 8, 9, 10)
FletcherD May 13, 2026
ea35aed
Address PR thread 6: move startObservingState after player creation
FletcherD May 13, 2026
9727ee4
Return failed future for unsupported commands in handleCommand
FletcherD May 13, 2026
aa94487
Fix HaMediaSession coroutineScope hanging on natural flow completion
FletcherD May 13, 2026
f608103
Clarify comment on MediaPlaybackState.Off -> STATE_IDLE mapping
FletcherD May 13, 2026
86d0420
Address trivial PR review comments (threads 33, 35, 48, 51)
FletcherD May 15, 2026
5c19e07
Address PR review comments (threads 29, 30, 32, 36, 52)
FletcherD May 15, 2026
2b399b3
Pre-scale notification album art bitmap on IO to fix StrictMode viola…
FletcherD May 15, 2026
2e620a9
Address PR review comments (threads 34, 39, 40, 41)
FletcherD May 15, 2026
32ff7da
Address PR review comments (threads 42–47): refactor MediaControlSett…
FletcherD May 15, 2026
7068ddd
Remove index column from MediaControlConfig (thread 53)
FletcherD May 15, 2026
a8c57a1
Fix seek-bar regression after media commands by completing pendingCom…
FletcherD May 15, 2026
4fc1f1b
Update screenshot reference images for MediaControlSettingsScreen
FletcherD May 15, 2026
38935d8
Merge origin/main into feature/native-media-controls
FletcherD May 16, 2026
3765fc8
Fix media notification lag when skipping songs rapidly
FletcherD May 21, 2026
2ac0868
Correct prescaling of notification bitmap
FletcherD May 21, 2026
c2ddecd
Improve album artwork handling in media notifications
FletcherD May 21, 2026
35d78e9
Remove excess logging
FletcherD May 21, 2026
52f511b
Fix progress bar stutter when volume button is pressed
FletcherD May 26, 2026
72150f8
Restore shuffle and repeat controls in media notification
FletcherD May 26, 2026
68bf762
Address trivial PR review feedback
FletcherD May 26, 2026
057a9b3
Address PR review feedback in HaMediaSessionService
FletcherD May 27, 2026
b263ef0
Allow native media controls on Android Automotive
FletcherD May 27, 2026
2588857
Use MediaControlEntityConfig instead of index in removeEntity
FletcherD May 27, 2026
a47518f
Fix crash: Add display names to custom CommandButtons
FletcherD May 27, 2026
913dbf5
Apply ktlint formatting
FletcherD May 27, 2026
b0ae9c1
Merge remote-tracking branch 'origin/main' into feature/native-media-…
FletcherD Jul 4, 2026
579349f
Add Room schema 53 for media_control_entity_config after merge renumber
FletcherD Jul 4, 2026
961975c
Fix compilation after merging main
FletcherD Jul 4, 2026
7af253c
Revert "Allow native media controls on Android Automotive"
FletcherD Jul 4, 2026
8b757fe
Extract registry loading helper into shared loadListOrEmpty util
FletcherD Jul 4, 2026
74a352c
Trim duplicated StrictMode rationale from loadArtworkData KDoc
FletcherD Jul 4, 2026
1ec7313
Use SdkVersion.isAtLeast for notification channel version check
FletcherD Jul 4, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions app/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC"/>
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_LOCATION"/>
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MICROPHONE"/>
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK"/>
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_REMOTE_MESSAGING"/>
<uses-permission android:name="android.permission.SYSTEM_ALERT_WINDOW"/>
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE"
Expand Down Expand Up @@ -324,6 +325,18 @@
</intent-filter>
</service>

<service
android:name=".mediacontrol.HaMediaSessionService"
android:exported="true"
android:foregroundServiceType="mediaPlayback"
tools:ignore="ExportedService">
<!-- Exported so system media controllers can bind to the session.
Connections are filtered in MediaSession.Callback#onConnect. -->
<intent-filter>
<action android:name="androidx.media3.session.MediaSessionService"/>
</intent-filter>
</service>

<activity android:name=".controls.HaControlsPanelActivity"
android:permission="android.permission.BIND_CONTROLS"
android:exported="true"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ import io.homeassistant.companion.android.authenticator.Authenticator.Companion.
import io.homeassistant.companion.android.common.R as commonR
import io.homeassistant.companion.android.common.compose.theme.HATheme
import io.homeassistant.companion.android.launch.applock.HazeLockOverlay
import io.homeassistant.companion.android.mediacontrol.HaMediaSessionService
import io.homeassistant.companion.android.sensors.SensorReceiver
import io.homeassistant.companion.android.sensors.SensorWorker
import io.homeassistant.companion.android.util.ChangeLog
Expand Down Expand Up @@ -196,6 +197,7 @@ class LaunchActivity : AppCompatActivity() {
SensorWorker.start(this)
lifecycleScope.launch {
WebsocketManager.start(this@LaunchActivity)
HaMediaSessionService.start(this@LaunchActivity)
Comment thread
FletcherD marked this conversation as resolved.
checkLocationDisabled()
changeLog.showChangeLog(this@LaunchActivity, forceShow = false)
}
Expand Down

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
@@ -0,0 +1,291 @@
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
Comment thread
TimoPtr marked this conversation as resolved.

// Keyed by MediaControlEntityConfig.id. Each entry pairs the session with the job running observe().
Comment thread
FletcherD marked this conversation as resolved.
Outdated
private val activeSessions = mutableMapOf<String, Pair<HaMediaSession, Job>>()

/** 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
Comment thread
FletcherD marked this conversation as resolved.

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
Comment thread
FletcherD marked this conversation as resolved.
Outdated
* (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.
Comment thread
FletcherD marked this conversation as resolved.
Outdated
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")

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Let's call this at the end of the method.

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()
Comment on lines +147 to +149

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

FYI If I'm not wrong serviceScope.cancel does cancel the job of each session. It 's fine keeping it like that just mentioning.

super.onDestroy()
}

@VisibleForTesting
internal fun startObservingEntities() {
mediaControlRepository.observeConfiguredEntities()
.onEach { entities -> reconcileSessions(entities) }
.launchIn(serviceScope)
}

private suspend fun reconcileSessions(configuredEntities: List<MediaControlEntityConfig>) {
Comment thread
FletcherD marked this conversation as resolved.
val desiredKeys = configuredEntities.map { it.id }.toSet()
Comment thread
FletcherD marked this conversation as resolved.
Outdated
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) {
Comment thread
FletcherD marked this conversation as resolved.
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.
Comment thread
FletcherD marked this conversation as resolved.
Outdated
*/
private suspend fun tearDownSession(key: String, pair: Pair<HaMediaSession, Job>) {
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 {
Comment on lines +216 to +219

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
* Must be called from the Main thread.
*/
private fun launchSession(key: String, session: HaMediaSession) {
val job = serviceScope.launch {
*/
private suspend fun launchSession(key: String, session: HaMediaSession) = withContext(Dispatchers.Main) {
val job = serviceScope.launch {

It's only called from a coroutine context already so leverage that to make the method main safe.

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)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is it possible that this is call from background and crashing? Shouldn't we pass startInForegroundRequired to the method to be sure?

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() {
Comment thread
FletcherD marked this conversation as resolved.
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")
}
}
}
}
Loading