Inside Android 17's MediaSession: How the System Finds, Controls, and Syncs Your Player
AOSP Reflections | Android 17 MediaSession: How the System Finds the Player, Controls Playback, and Syncs State
Author: 哲鲤
The player app knows how to play a song, but the system UI still needs to answer another set of questions: which app should it control right now? Who gets the play key? Where do the track name, artwork, and progress come from? How should local volume and cast-device volume be adjusted?
MediaSession connects a player to the system media control framework. The app uses it to publish state and control capabilities; controllers send requests through a unified interface; the system service handles session ownership, media key selection, notifications, and lifecycle management. The actual decoding and playback remain the player's job.
To understand this mechanism, you can follow three questions: how to find the player, how to control playback, and how to sync state. This article starts from the platform android.media.session.MediaSession source code, then places volume, metadata, queue, permissions, and destruction back into one collaboration diagram. MediaSession2 and AndroidX Media3 have their own implementations and adaptations; the article ends with a comparison of their design directions, and does not mix them with the platform session as one interface.
First, an Overview: The Session Is Both a Control Protocol and an Information Publication Interface
Figure 1: The left side ①–⑥ shows the control main path; the right side ⑦–⑨ shows information publication and notifications; horizontal arrows connect registration, audio observation, and information linking. Gray boxes group by responsibility; process ownership is shown in node text; repeated MediaSession and MediaSessionRecord are the same objects. Blue indicates requests, orange indicates information reports, green dashed lines indicate audio activity notifications. The right side shows query results and change notifications; queries are initiated by controllers; numbering is for reading, not to indicate strict serial execution of each path.
| Layer | Core Objects | Responsibility |
|---|---|---|
| Session owner, player app process | MediaSession / Callback / VolumeProvider | Receive commands, publish playback information, handle remote volume |
| Unified control interface, controller process | MediaController / TransportControls / Callback | Control a specified session via Token, query and subscribe to information |
| System session management, system_server | MediaSessionService / user records / MediaSessionStack | Create records, determine ownership, organize media key target selection and lifecycle |
| Single-session record, system_server | MediaSessionRecord | Hold owner and controller interfaces, save information, forward requests and publish updates |
| Audio observation and volume collaboration | AudioPlayerStateMonitor / AudioManager / AudioService | Observe playback activity, participate in target determination, execute local volume control |
| Media execution | App player and its media service chain | Actually prepare, read, decode, play, and output |
Service and session record are not two processes; app Callback and player are not two independent services. When analyzing the diagram's relationships, first distinguish in-process calls, Binder control requests, and information reports, then see who holds objects and data. The "player app" in the diagram means the app holds the player and initiates media calls; when using the platform MediaPlayer, reading, decoding, and output can continue into the system media service, so you cannot attribute the entire media data pipeline to the app process.
| Capability published or connected by MediaSession | Representative interface | Work the app does |
|---|---|---|
| Session discovery and control connection | Token / MediaController | Provide a valid Token to controllers that need to connect |
| Playback commands | onPlay / onPause / onSeekTo / onSkipToNext | Connect commands to the actual player |
| Playback state and actions | setPlaybackState | Publish state, position, speed, supported actions, and custom actions |
| Track and list information | setMetadata / setQueue / setQueueTitle | Maintain the correspondence for current content and queue |
| Volume and playback type | setPlaybackToLocal / setPlaybackToRemote | Declare how local or remote volume is handled |
| Additional information and interaction | setExtras / setSessionActivity / sendSessionEvent | Provide business info, UI entry, or session events |
| Activity and exit | setActive / release | Maintain session activity conditions and release connections |
These capabilities can be used separately. For example, a controller with a Token accessing a specific session is a different path from the system finding a default target for media keys; a session being active does not mean playback data has already reached the device.
What Is Created When You Create a MediaSession?
The App Gets a Session Interface and Control Token
When constructing MediaSession, it first creates a callback Binder object, then requests the system to create a session, and finally constructs the Token and corresponding controller.
Source location: frameworks/base/media/java/android/media/session/MediaSession.java, constructor.
mCbStub = new CallbackStub(this);
MediaSessionManager manager =
(MediaSessionManager) context.getSystemService(Context.MEDIA_SESSION_SERVICE);
try {
mBinder = manager.createSession(mCbStub, tag, sessionInfo, overridePackageName);
mSessionToken = new Token(Process.myUid(), mBinder.getController());
mController = new MediaController(context, mSessionToken);
mBinder is the system interface used by the session owner; the Token contains the session identifier and control interface for controllers. It lets a controller find the corresponding session; it does not mean a decoder, AudioTrack, or playback content is already prepared. The constructor passes the framework's CallbackStub; the app's own MediaSession.Callback is set later via setCallback(), invoked by a Handler. The two connect system requests and app handling; they have different roles.
system_server Creates a Session Record
The service entry validates package name and user ownership, then passes the app callback and other info to internal creation logic.
Source location: frameworks/base/services/core/java/com/android/server/media/MediaSessionService.java, createSession().
MediaServerUtils.enforcePackageName(mContext, packageName, uid);
int resolvedUserId = handleIncomingUser(pid, uid, userId, packageName);
if (cb == null) {
throw new IllegalArgumentException("Controller callback cannot be null");
}
// …
MediaSessionRecord session =
createSessionInternal(
pid,
uid,
resolvedUserId,
packageName,
overridePackageName,
cb,
tag,
sessionInfo);
Source location: frameworks/base/services/core/java/com/android/server/media/MediaSessionService.java, createSessionInternal() creates the record.
new MediaSessionRecord(
callerPid,
callerUid,
userId,
callerPackageName,
overridePackageName,
cb,
tag,
sessionInfo,
this,
mRecordThread.getLooper(),
policies);
MediaSessionRecord stores PID, UID, user, package name, and session callback, and uses the service-provided Looper. What is created here is a system record connecting app and controller; the code does not create a MediaPlayer or decoder.
Source: App session construction, Service validation and creation, Creating MediaSessionRecord.
Finding the Player: Token, System Discovery, and User Ownership
Having a Token and Enumerating System Sessions Are Different Permission Entries
The app gives a controller a valid Token, and the controller can construct a MediaController to connect to that session. Another need is enumerating active system sessions, for example when the system media UI needs to discover multiple players; that path goes through MediaSessionService's media access permission and user validation.
Source location: frameworks/base/services/core/java/com/android/server/media/MediaSessionService.java, verifySessionsRequest().
private int verifySessionsRequest(ComponentName componentName, int userId, final int pid,
final int uid) {
String packageName = null;
if (componentName != null) {
// If they gave us a component name verify they own the
// package
packageName = componentName.getPackageName();
MediaServerUtils.enforcePackageName(mContext, packageName, uid);
}
// Check that they can make calls on behalf of the user and get the final user id
int resolvedUserId = handleIncomingUser(pid, uid, userId, packageName);
// Check if they have the permissions or their component is enabled for the user
// they're calling from.
enforceMediaPermissions(packageName, pid, uid, resolvedUserId);
return resolvedUserId;
}
Source location: frameworks/base/services/core/java/com/android/server/media/MediaSessionService.java, enforceMediaPermissions().
private void enforceMediaPermissions(String packageName, int pid, int uid,
int resolvedUserId) {
if (hasStatusBarServicePermission(pid, uid)) return;
if (hasMediaControlPermission(pid, uid)) return;
if (packageName == null || !hasEnabledNotificationListener(
packageName, UserHandle.getUserHandleForUid(uid), resolvedUserId)) {
throw new SecurityException("Missing permission to control media.");
}
}
verifySessionsRequest() validates the package ownership of the notification listener component, resolves the target user, and calls the media permission check. enforceMediaPermissions() accepts the appropriate system permissions or an enabled notification listener condition. Cross-user requests also go through handleIncomingUser().
Therefore, you cannot treat active-session enumeration permission as the same check that every Token control goes through, nor can you treat getting a Token as having permission to control all players. Apps can also combine caller information carried in control requests with interfaces like isTrustedForMediaControl() to make business judgments on sensitive operations. Trust queries and command execution entries themselves also need to be analyzed separately.
Source: Session enumeration entry, User and media permissions, Media access conditions, Controller trust decision.
Session Information Is Organized by User; You Can't Just Rely on Package Name
MediaSessionRecord stores user, UID, package name, and owner callback; the service's user records and session stack maintain the corresponding collections. One package name can have multiple sessions, and multiple users can run the same package name. When control fails, you should simultaneously confirm whether the user, UID, Token, and session still exist.
A normal app registers sessions it owns; this source also allows callers with appropriate permissions to register on behalf of other packages, with separate validation for overridePackageName. You cannot simply equate "session owner package name" with the identity of every Binder call.
Source: Ownership and proxy registration validation at creation, Handling state changes by user.
Controlling Playback: Media Keys and Specified Session Control
First Distinguish Key Sources: Media KeyEvent Is Not the Same as a Screen Button
"Media keys" are defined by KeyCode, not by whether the button is physical. Keyboard, headphones, remote, or car input can generate media KeyEvents; software can also generate the same kind of event. But tapping a screen play icon may either directly call the player or go through a MediaController to control a session known by Token; it does not necessarily generate a media KeyEvent.
| Source | How it connects to control |
|---|---|
| Hardware such as keyboard, headphones, remote | Input mapping generates Android KeyEvent, then handled by window or system policy |
| Steering wheel or car hardware keys | Mapped to KeyEvent according to product input implementation, or given to vendor control interfaces |
| App's own play button | Can directly call the app player, no need to dispatch a global media key first |
| System or other app's screen control button | Can use MediaController and target Token to control a specified session |
| Software-generated media key event | Handled through the corresponding dispatch interface; capabilities depend on interface and caller identity |
The platform session entry recognizes media keys including play, pause, toggle play/pause, headset multi-function key, stop, previous/next, rewind, fast forward, and record. Volume keys go through a separate volume path.
Source location: frameworks/base/core/java/android/view/KeyEvent.java, isMediaSessionKey().
public static final boolean isMediaSessionKey(int keyCode) {
switch (keyCode) {
case KeyEvent.KEYCODE_MEDIA_PLAY:
case KeyEvent.KEYCODE_MEDIA_PAUSE:
case KeyEvent.KEYCODE_MEDIA_PLAY_PAUSE:
case KeyEvent.KEYCODE_HEADSETHOOK:
case KeyEvent.KEYCODE_MEDIA_STOP:
case KeyEvent.KEYCODE_MEDIA_NEXT:
case KeyEvent.KEYCODE_MEDIA_PREVIOUS:
case KeyEvent.KEYCODE_MEDIA_REWIND:
case KeyEvent.KEYCODE_MEDIA_RECORD:
case KeyEvent.KEYCODE_MEDIA_FAST_FORWARD:
return true;
}
return false;
}
Being in this recognition list does not mean the app implements all actions by default. The default handling for a standalone play key and a play/pause toggle key is different; actions such as record also need to be checked against the app's processing logic.
Default Input Handling: First Look at the Window, Then Decide Whether System Dispatch Is Needed
Physical keys need to be mapped to Android KeyCode first. The platform Generic.kl provides a common layout; devices can use their own. The numbers below are Linux key code mappings in this layout, not numbers that every steering wheel or headset must use.
Source location: frameworks/base/data/keyboards/Generic.kl.
key 163 MEDIA_NEXT
key 164 MEDIA_PLAY_PAUSE
key 165 MEDIA_PREVIOUS
PhoneWindowManager checks in its media key branch whether the key should be passed to the foreground. If the normal path passes the key to the foreground, the app can handle it first; when system processing is needed, it arranges media key dispatch. A global priority session also affects this choice. If the foreground does not consume the event, window and fallback logic may continue processing; you cannot draw all hardware media keys as going directly into MediaSessionService.
Source location: frameworks/base/services/core/java/com/android/server/policy/PhoneWindowManager.java, media key branch.
if (MediaSessionLegacyHelper.getHelper(mContext).isGlobalPriorityActive()) {
// If the global session is active pass all media keys to it
// instead of the active window.
result &= ~ACTION_PASS_TO_USER;
}
if ((result & ACTION_PASS_TO_USER) == 0) {
// Only do this if we would otherwise not pass it to the user. In that
// case, the PhoneWindow class will do the same thing, except it will
// only do it if the showing app doesn't process the key on its own.
// Note that we need to make a copy of the key event here because the
// original key event will be recycled when we return.
mBroadcastWakeLock.acquire();
Message msg = mHandler.obtainMessage(MSG_DISPATCH_MEDIA_KEY_WITH_WAKE_LOCK,
new KeyEvent(event));
msg.setAsynchronous(true);
msg.sendToTarget();
When a window is associated with a MediaController, it can specify that controller's Token; normal fallback handling uses MediaSessionManager's system dispatch interface.
Source location: frameworks/base/core/java/com/android/internal/policy/PhoneFallbackEventHandler.java.
private void handleMediaKeyEvent(KeyEvent keyEvent) {
getMediaSessionManager().dispatchMediaKeyEventAsSystemService(keyEvent);
}
This version's compatibility auxiliary path also uses MediaCommunicationManager. It is an upstream dispatch interface; you cannot forcibly merge it with MediaSessionManager's different entries into one identical call chain.
Source location: packages/modules/Media/apex/framework/java/android/media/MediaCommunicationManager.java, dispatchMediaKeyEvent().
public void dispatchMediaKeyEvent(@NonNull KeyEvent keyEvent, boolean asSystemService) {
Objects.requireNonNull(keyEvent, "keyEvent shouldn't be null");
// When MCS handles this, caller is changed.
// TODO: remove this when MCS implementation is done.
if (!asSystemService) {
getMediaSessionManager().dispatchMediaKeyEvent(keyEvent, false);
return;
}
try {
getService().dispatchMediaKeyEvent(mContext.getPackageName(),
keyEvent, asSystemService);
} catch (RemoteException e) {
Log.e(TAG, "Failed to send key event.", e);
}
The non-system dispatch parameter branch forwards to MediaSessionManager; the other branch requests MediaCommunicationService. This article later explains how events enter MediaSessionService for target selection and app callback; the intermediate interface differences cannot be ignored, nor do they change the app player's execution responsibility.
What Can Be Customized: Mapping, System Policy, and App Behavior
Input layer can change physical key to KeyCode mapping through device key layout or car input implementation. System layer can plug in custom media key dispatchers and session policy providers. AOSP's resource default values are empty:
Source location: frameworks/base/core/res/res/values/config.xml.
<string name="config_customMediaKeyDispatcher"></string>
<string name="config_customMediaSessionPolicyProvider"></string>
These are extension points for platform and vendor integration, not a way for ordinary apps or users to set a package name and specify a global control target. App layer decides how to respond through Callback, PlaybackState supported actions, and its own screen interactions; overriding onMediaButtonEvent() changes the app's default key handling behavior.
Figure 7: Media KeyEvent, direct screen play, and specified Token control are read separately. Window handling conditionally consumes, specifies, or continues dispatching; these arrows represent branches, not that one key press executes all paths in sequence. After a system media key enters the service, it checks users, listeners, and session policy.
Source: Media key definition, Default key layout, System input policy, Window specified session, Unconsumed event fallback, Compatibility auxiliary entry, MediaCommunicationManager branch, Default extension config.
Media Keys Go Through System Entry and Target Selection First
dispatchMediaKeyEvent() first checks whether the event is a media key. Later there are user settings, global priority, media key listeners, and other conditions; the event is not necessarily immediately sent to an ordinary session.
Source location: frameworks/base/services/core/java/com/android/server/media/MediaSessionService.java, dispatchMediaKeyEvent().
public void dispatchMediaKeyEvent(String packageName, boolean asSystemService,
KeyEvent keyEvent, boolean needWakeLock) {
if (keyEvent == null || !KeyEvent.isMediaSessionKey(keyEvent.getKeyCode())) {
Log.w(TAG, "Attempted to dispatch null or non-media key event.");
return;
}
Continuing into dispatchMediaKeyEventLocked(), a custom dispatcher can provide a session or receiver; if none is provided, it reads the media key session in the current user record. The default target is maintained by the session stack based on audio activity and session changes; key dispatch usually reads this result rather than re-traversing all players each time. Global priority session handling is also in user records and service judgment, and cannot all be attributed to normal session stack ordering. If there is no corresponding session, it may also try the retained media button receiver.
Source location: frameworks/base/services/core/java/com/android/server/media/MediaSessionService.java, dispatchMediaKeyEventLocked().
if (mCustomMediaKeyDispatcher != null) {
MediaSession.Token token = mCustomMediaKeyDispatcher.getMediaSession(
keyEvent, uid, asSystemService);
if (token != null) {
session = getMediaSessionRecordLocked(token);
}
// …
if (session == null && mediaButtonReceiverHolder == null) {
session = (MediaSessionRecord) mCurrentFullUserRecord.getMediaButtonSessionLocked();
if (session == null) {
mediaButtonReceiverHolder =
mCurrentFullUserRecord.mLastMediaButtonReceiverHolder;
}
}
// …
session.sendMediaButton(packageName, pid, uid, asSystemService, keyEvent,
needWakeLock ? mKeyEventReceiver.mLastTimeoutId : -1,
mKeyEventReceiver);
The last segment is in the session != null branch, handing the key to the selected record's sendMediaButton(). So when troubleshooting, first confirm whether the key was intercepted and which session was actually selected, then confirm whether the app callback received the event.
Source: Media key entry, Target selection and dispatch, Session forwarding media key.
Audio Playback Changes Trigger Media Key Target Reselection
MediaSessionService also listens to system audio playback changes through AudioPlayerStateMonitor. This entry is AudioManager.AudioPlaybackCallback, which observes player configuration and activity; it is not a unified listener for all AudioPolicy changes, nor does it replace the dedicated interfaces for audio focus or device routing.
Source location: frameworks/base/services/core/java/com/android/server/media/AudioPlayerStateMonitor.java, constructor registers callback.
private AudioPlayerStateMonitor(Context context, Set<Integer> ignoredAudioUsageTypes) {
mIgnoredAudioUsageTypes = Set.copyOf(ignoredAudioUsageTypes);
AudioManager am = (AudioManager) context.getSystemService(Context.AUDIO_SERVICE);
am.registerAudioPlaybackCallback(new AudioManagerPlaybackListener(), null);
}
onPlaybackConfigChanged() updates records like active UIDs, then notifies listeners of player activity changes. MediaSessionService registers its own listener, finds the corresponding user by player UID, and requests the session stack to re-check the media key target.
Source location: frameworks/base/services/core/java/com/android/server/media/MediaSessionService.java, listener registered during initialization.
mAudioPlayerStateMonitor.registerListener(
(config, isRemoved) -> {
if (DEBUG) {
Log.d(TAG, "Audio playback is changed, config=" + config
+ ", removed=" + isRemoved);
}
if (config.getPlayerType()
== AudioPlaybackConfiguration.PLAYER_TYPE_JAM_SOUNDPOOL) {
return;
}
synchronized (mLock) {
FullUserRecord user = getFullUserRecordLocked(
UserHandle.getUserHandleForUid(config.getClientUid())
.getIdentifier());
if (user != null) {
user.mPriorityStack.updateMediaButtonSessionIfNeeded();
}
}
}, null /* handler */);
This also excludes SoundPool-type players, to avoid treating such sound-effect playback as a trigger for media key target reselection. Audio playback observation and session state reporting are two information sources: the former helps the system choose a control target, the latter describes the playback state the app wants to publish to controllers.
Source: Register audio playback callback, Handle audio config changes, Service listener and target reselection.
The Media Key Target Is Not the Same as the Session That Last Called setActive
The default MediaSessionStack consults the audio playback UID order recorded by AudioPlayerStateMonitor, looks for the corresponding session, and checks policies for ignoring media key targets.
Source location: frameworks/base/services/core/java/com/android/server/media/MediaSessionStack.java, updateMediaButtonSessionIfNeeded().
List<Integer> audioPlaybackUids =
mAudioPlayerStateMonitor.getSortedAudioPlaybackClientUids();
for (int i = 0; i < audioPlaybackUids.size(); i++) {
int audioPlaybackUid = audioPlaybackUids.get(i);
MediaSessionRecordImpl mediaButtonSession = findMediaButtonSession(audioPlaybackUid);
if (mediaButtonSession == null) {
if (DEBUG) {
Log.d(TAG, "updateMediaButtonSessionIfNeeded, skipping uid="
+ audioPlaybackUid);
}
// Ignore if the lastly played app isn't a media app (i.e. has no media session)
continue;
// …
boolean ignoreButtonSession =
(mediaButtonSession.getSessionPolicies()
& SESSION_POLICY_IGNORE_BUTTON_SESSION) != 0;
// …
if (!ignoreButtonSession) {
mAudioPlayerStateMonitor.cleanUpAudioPlaybackUids(mediaButtonSession.getUid());
if (mediaButtonSession != mMediaButtonSession) {
updateMediaButtonSession(mediaButtonSession);
}
When there are multiple sessions under the same UID, the system first looks for a session whose playback active state matches the observed audio playback state; if no match is found, it keeps the candidate selected by session order.
Source location: frameworks/base/services/core/java/com/android/server/media/MediaSessionStack.java, findMediaButtonSession().
private MediaSessionRecordImpl findMediaButtonSession(int uid) {
MediaSessionRecordImpl mediaButtonSession = null;
for (MediaSessionRecordImpl session : mSessions) {
if (session instanceof MediaSession2Record) {
// TODO(jaewan): Make MediaSession2 to receive media key event
continue;
}
if (uid == session.getUid()) {
if (session.checkPlaybackActiveState(
mAudioPlayerStateMonitor.isPlaybackActive(session.getUid()))) {
// If there's a media session whose PlaybackState matches
// the audio playback state, return it immediately.
return session;
}
if (mediaButtonSession == null) {
// Among the media sessions whose PlaybackState doesn't match
// the audio playback state, pick the top priority.
mediaButtonSession = session;
}
}
}
return mediaButtonSession;
}
This explains why calling only setActive(true) is not enough to guarantee the next media key will control you. Session state, audio playback activity, user records, and policies need to be analyzed together. Audio activity observation is not acoustic measurement: recording that a UID has an active audio player does not prove that sound has come out of the speaker.
Source: Update media key target, Select session under same UID, Audio active UID record.
MediaController Controls a Specified Session Through Token
The controller's TransportControls.play() directly calls the session control interface it holds.
Source location: frameworks/base/media/java/android/media/session/MediaController.java, TransportControls.play().
public void play() {
try {
mSessionBinder.play(mContext.getPackageName());
} catch (RemoteException ex) {
throw ex.rethrowFromSystemServer();
}
}
This path has already located the session through Token; it does not re-run default media key target selection to execute a single play(). Whether the Token is valid, whether the server-side record still exists, the calling identity, and whether the app responds still affect the result. The ACTIONS check for default media keys cannot be directly applied to all specified-session control calls.
Source: Play request for specified session, Server-side control interface.
How the App Receives and Executes Play Requests
The earlier MediaController.play() already sends the request to the control interface of the specified session. Next, the request goes through the session record in system_server, the app-side Binder callback, and the app callback thread before reaching the app's implemented playback logic.
The System Record Forwards the Request to the App
The control interface of MediaSessionRecord first finds its associated session record. If the record still exists, it continues to call the record's play(); then the record's SessionCb forwards it to the owner callback passed in during session creation.
Source location: frameworks/base/services/core/java/com/android/server/media/MediaSessionRecord.java, ControllerStub.play().
public void play(String packageName) {
MediaSessionRecord record = mRecord.get();
if (record == null) {
Log.w(TAG, IGNORE_CALL_TO_DESTROYED_SESSION_MESSAGE);
return;
}
record.play(packageName);
}
Source location: same file, SessionCb.play().
public void play(String packageName, int pid, int uid) {
try {
final String reason = TAG + ":play";
mService.tempAllowlistTargetPkgIfPossible(getUid(), getPackageName(),
pid, uid, packageName, reason);
mCb.onPlay(packageName, pid, uid);
} catch (RemoteException e) {
Slog.e(TAG, "Remote failure in play.", e);
}
}
Here mCb.onPlay() calls the app-side Binder interface and carries the caller's package name, PID, and UID. The receiver is MediaSession's internal CallbackStub, which has not yet reached the app's player control code.
Source location: frameworks/base/media/java/android/media/session/MediaSession.java, CallbackStub.onPlay().
public void onPlay(String packageName, int pid, int uid) {
MediaSession session = mMediaSession.get();
if (session != null) {
session.dispatchPlay(createRemoteUserInfo(packageName, pid, uid));
}
}
CallbackStub finds the app-held MediaSession and hands the request to dispatchPlay(). The latter posts a Handler message, connecting the Binder request to the app-chosen callback thread.
Source: Control interface receives play request, Record forwards to owner callback, App-side Binder entry.
The App Sets the Callback and Chooses the Handling Thread
The app needs to implement MediaSession.Callback and hand it to MediaSession via setCallback(). This Callback defines onPlay(), onPause(), and other handling methods; the earlier CallbackStub receives Binder requests, and the two have different responsibilities.
setCallback() also determines the thread that processes requests: if a Handler is provided, it uses that Handler's Looper; otherwise it uses the calling thread's Looper.
Source location: frameworks/base/media/java/android/media/session/MediaSession.java, setCallback().
public void setCallback(@Nullable Callback callback, @Nullable Handler handler) {
synchronized (mLock) {
if (mCallback != null) {
// We're updating the callback, clear the session from the old one.
mCallback.mCallback.clearSession();
}
if (callback == null) {
mCallback = null;
return;
}
Looper looper = handler != null ? handler.getLooper() : Looper.myLooper();
callback.setSession(this);
CallbackMessageHandler msgHandler = new CallbackMessageHandler(looper, callback);
mCallback = msgHandler;
}
}
The callback thread should coordinate with the player's thread requirements. Time-consuming preparation can be handed to the app's task scheduling mechanism, to avoid blocking subsequent commands on the callback thread. Receiving the Binder request and executing on the player are therefore two phases.
Source: Bind callback thread.
MediaSession's app-side implementation posts the play request to the callback Handler, and message handling calls the app's onPlay().
Source location: frameworks/base/media/java/android/media/session/MediaSession.java, dispatchPlay() and CallbackMessageHandler.
void dispatchPlay(RemoteUserInfo caller) {
postToCallback(caller, CallbackMessageHandler.MSG_PLAY, null, null);
}
// …
case MSG_PLAY:
mCallback.onPlay();
break;
The App Implements Player Operations in the Callback
The Handler ultimately calls the app-set Callback. The platform's default onPlay() method body is empty:
Source location: frameworks/base/media/java/android/media/session/MediaSession.java, Callback.onPlay().
/**
* Override to handle requests to begin playback.
*/
public void onPlay() {
}
The app needs to implement the play action in its own callback or player adapter, and maintain session information based on the player result. Whether the callback executes, whether the player accepts the request, and whether data begins output should be recorded separately.
Source: Post play callback, Handle play message, Default onPlay.
Media Keys First Convert to Corresponding Playback Operations
The above expands the play() request for a specified session. When a media key enters the app, it is first handed to onMediaButtonEvent(), which decides whether the event should correspond to play, pause, or another operation. The default onMediaButtonEvent() reads the actions in PlaybackState and then decides how to handle the key. Below is the branch for a standalone play key:
Source location: frameworks/base/media/java/android/media/session/MediaSession.java, Callback.onMediaButtonEvent().
PlaybackState state = mSession.mPlaybackState;
validActions = state == null ? 0 : state.getActions();
// …
switch (ke.getKeyCode()) {
case KeyEvent.KEYCODE_MEDIA_PLAY -> {
if ((validActions & PlaybackState.ACTION_PLAY) != 0) {
onPlay();
return true;
}
}
When ACTION_PLAY is not set, this default branch does not call onPlay(). The play/pause toggle key also has single-click, double-click, and delayed handling; you cannot assume every press immediately triggers a callback. After the app overrides onMediaButtonEvent(), it should also check its own return value and event handling logic.
Source: Default media key handling.
When Is Media Key Dispatch Acknowledged?
This media key path also includes a dispatch acknowledgment. The app-side CallbackStub arranges media key handling and then returns a sequence number via ResultReceiver; it does not wait for the app's playback logic to complete.
Source location: frameworks/base/media/java/android/media/session/MediaSession.java, CallbackStub.onMediaButton().
public void onMediaButton(String packageName, int pid, int uid, Intent mediaButtonIntent,
int sequenceNumber, ResultReceiver cb) {
MediaSession session = mMediaSession.get();
try {
if (session != null) {
session.dispatchMediaButton(createRemoteUserInfo(packageName, pid, uid),
mediaButtonIntent);
}
} finally {
if (cb != null) {
cb.send(sequenceNumber, null);
}
}
}
cb.send(sequenceNumber, null) is the acknowledgment for this media key dispatch, tied to the related wake lock handling. It does not wait for the player to produce a first frame or sound. Treating this acknowledgment as playback completion mixes up timing points in the control chain and the data chain.
Source: Media key acknowledgment, Wake lock and receiver in dispatch.
The Control Protocol Also Includes Seeking, Track Change, and Custom Interaction
The mechanism for receiving and executing play requests applies to other control operations as well. TransportControls, besides play/pause, provides seekTo, skipToNext, skipToPrevious, prepare, rating, playback speed, and various start-by-media-id interfaces. They send the request to the corresponding app callback; whether it can execute and how it modifies the player state is decided by the app implementation. PlaybackState.actions expresses supported actions; custom actions can carry business operations; ordinary session commands and session events separately carry additional interaction and information notification.
For example, skipToQueueItem() carries a queue item ID; after the app receives the request, it needs to map that ID to its own content and then call the player. Queue content, current item, and execution results are also maintained by the app; how related information is published to controllers will be covered in the state synchronization section.
Source: TransportControls control interface, App callback dispatch, Rating and speed control, Custom action handling, Platform MediaSession API.
Whether a Session Is Active and What State the Player Is In Must Be Separated
After a request enters the app, the player may start playing, or it may still be preparing, buffering, or waiting for other conditions. The app needs to publish these changes to controllers. Before that, distinguish whether the session is active from what state the player is in: they are expressed respectively by setActive() and setPlaybackState().
active Indicates Session Activity Conditions
The server-side setActive() saves the active flag and notifies the service; isActive() also checks whether the record has been destroyed.
Source location: frameworks/base/services/core/java/com/android/server/media/MediaSessionRecord.java, setActive() and isActive().
synchronized (mLock) {
mIsActive = active;
}
final long token = Binder.clearCallingIdentity();
try {
mService.onSessionActiveStateChanged(MediaSessionRecord.this, mPlaybackState);
} finally {
Binder.restoreCallingIdentity(token);
}
mHandler.post(MessageHandler.MSG_UPDATE_SESSION_STATE);
// …
@Override
public boolean isActive() {
return mIsActive && !mDestroyed;
}
setActive() updates the active flag of the session record. Calling setActive(false) does not call the player's pause method, and calling setActive(true) does not call the player's play method. The app can keep a session active after pausing, so the system media UI can still discover it and provide subsequent control.
"Inactive" and "destroyed" must be separated: the former is still the same session record and can be set active again; the latter has closed the record, and an old session cannot be restored with setActive(true). Recreating a MediaSession creates a new record and Token; the destruction cleanup process is detailed later.
Source: Active flag update, Active condition.
The App Constructs PlaybackState, Then Submits It to the System Record
In the diagram, "construct PlaybackState" means the app fills in session information based on the player state. For example, when the player is paused, the app chooses STATE_PAUSED, fills in the current playback position, playback speed, and update time, and declares supported control actions. MediaSession does not automatically read the player object to complete this conversion.
PlaybackState.Builder provides the construction interface. The following setState() saves the passed state, position, speed, and update time; the app can also use setActions() to fill in supported actions, and finally call build() to get a PlaybackState.
Source location: frameworks/base/media/java/android/media/session/PlaybackState.java, Builder.setState().
public Builder setState(@State int state, long position, float playbackSpeed,
long updateTime) {
mState = state;
mPosition = position;
mUpdateTime = updateTime;
mSpeed = playbackSpeed;
return this;
}
After construction, the app calls MediaSession.setPlaybackState(). This method saves the app-side state and submits it to the session record in system_server through mBinder.setPlaybackState(state), corresponding to the "Binder: submit playback state" in the diagram.
Source location: frameworks/base/media/java/android/media/session/MediaSession.java, setPlaybackState().
public void setPlaybackState(@Nullable PlaybackState state) {
mPlaybackState = state;
try {
mBinder.setPlaybackState(state);
} catch (RemoteException e) {
Log.wtf(TAG, "Dead object in setPlaybackState.", e);
}
}
Source: Construct state fields, App submits playback state.
The System Record Saves State, Then Notifies Controllers
After MediaSessionRecord receives the state, it saves this information, determines whether to update priority based on old and new states, and then posts a Handler message to arrange notification to controllers. Below is an excerpt from the server-side record's handling method.
Source location: frameworks/base/services/core/java/com/android/server/media/MediaSessionRecord.java, setPlaybackState().
private void setPlaybackState(PlaybackState state) {
int oldState =
mPlaybackState == null ? PlaybackState.STATE_NONE : mPlaybackState.getState();
int newState = state == null ? PlaybackState.STATE_NONE : state.getState();
boolean shouldUpdatePriority =
ALWAYS_PRIORITY_STATES.contains(newState)
|| (!TRANSITION_PRIORITY_STATES.contains(oldState)
&& TRANSITION_PRIORITY_STATES.contains(newState));
boolean isGlobalPrioritySessionActive = mService.isGlobalPrioritySessionActive();
synchronized (mLock) {
mPlaybackState = state;
updateUserEngagedStateIfNeededLocked(
/* isTimeoutExpired= */ false, isGlobalPrioritySessionActive);
}
final long token = Binder.clearCallingIdentity();
try {
mService.onSessionPlaybackStateChanged(
MediaSessionRecord.this, shouldUpdatePriority, mPlaybackState);
} finally {
Binder.restoreCallingIdentity(token);
}
mHandler.post(MessageHandler.MSG_UPDATE_PLAYBACK_STATE);
}
After the system record's Handler processes the state update message, it calls pushPlaybackStateUpdate() and sends the saved PlaybackState through the registered controller's Binder callback. This corresponds to "Binder: notify state change" in the diagram:
Source location: frameworks/base/services/core/java/com/android/server/media/MediaSessionRecord.java, pushPlaybackStateUpdate().
private void pushPlaybackStateUpdate() {
PlaybackState playbackState;
synchronized (mLock) {
if (mDestroyed) {
return;
}
playbackState = mPlaybackState;
}
performOnCallbackHolders(
"pushPlaybackStateUpdate",
holder -> holder.mCallback.onPlaybackStateChanged(playbackState));
}
pushPlaybackStateUpdate() reads the saved state inside the lock, then calls the onPlaybackStateChanged() Binder interface. After the controller receives it, it goes through its own Handler to call the app-implemented MediaController.Callback.onPlaybackStateChanged(). The system record's message publication and the controller UI update occur in different stages.
The "playing" shown on the controller comes from the PlaybackState submitted by the app. The UI can reflect changes only if the app constructs and submits information in time based on the player state; if the app reports PLAYING early or misses a pause or error, the system record still saves and notifies that value. This notification chain does not read the device's actual output.
State also participates in ordering. The current implementation lists fast-forward, rewind, and previous/next track as states that always raise priority; entering BUFFERING, CONNECTING, or PLAYING from another category may also request an order adjustment. After MediaSessionStack receives shouldUpdatePriority, it moves the record to the front of the list. However, session ordering and media key target are related but different decisions; you cannot generalize it as "reporting PLAYING grabs control."
Source: State saving and notification, State classification, Session order update, Controller state callback.
Figure 2: The upper half shows the session record's active and destroyed states; setActive(true/false) can go back and forth, and both non-destroyed states can enter destruction. The lower half shows the playback information publication flow: the app first constructs PlaybackState based on the player state, then submits it to the system record via Binder, and finally the system record notifies the controller of the state change via Binder. Orange arrows indicate information transfer, not state transitions between reported values like PLAYING, PAUSED, etc.
Source: Destroy record and notify.
Putting these two dimensions into actual operation makes responsibilities clearer:
| App operation or scenario | Session record change | Player and playback information handling |
|---|---|---|
| Pause playback, keep subsequent control | Can remain active | App pauses player and publishes PAUSED |
| Call setActive(false) | Record becomes inactive, not destroyed | This call does not pause the player or rewrite PlaybackState |
| Call setPlaybackState(PLAYING) | Saves the report, may affect session order | Does not start the player or automatically set session active |
| Release session or owner dies | Destroys record, clears saved state, arranges notification | Explicitly releasing MediaSession and releasing player resources are handled separately |
Therefore, the diagram has no arrow from active automatically pointing to PLAYING, and no arrow from inactive automatically pointing to PAUSED. The app needs to maintain session activity conditions and playback state reports separately, according to its own playback and exit strategy.
Connecting Command, Execution, and State Reporting
The state diagram separates session activity conditions and playback information. Next, put playback information back into the complete call chain. A specified-Token control request directly locates the session record; a media key first goes through system entry and target judgment, then is handed to the selected record. The record calls the app's CallbackStub via Binder, which posts the request to a Handler, which then executes the app-set Callback, and the app calls the player.
Playback state reaches controllers through another path: the app constructs PlaybackState based on the player state, calls MediaSession.setPlaybackState() to submit it to the system record via Binder; the record saves the state and notifies controllers through registered Binder callbacks, and the controller updates its UI through its own Handler.
Figure 3: The left side shows control requests and player calls; the right side shows PlaybackState construction, submission, and notification. The line from record to app Handler summarizes two stages: Binder calls CallbackStub, then posts a message. Media processing continues into the actual output chain; playback state is submitted by the app based on player changes; the two paths are observed separately.
graph TD
K[System media key] --> S[Target selection and policy check]
C[Controller with Token] --> R[Specified MediaSessionRecord]
S --> R
R --> B[App CallbackStub receives Binder callback]
B --> H[Handler executes app Callback]
H --> P[App calls player]
P --> U[App constructs PlaybackState]
U --> M[MediaSession submits playback state via Binder]
M --> N[MediaSessionRecord saves state]
N --> V[Notifies controller state change via Binder]
V --> D[Controller Handler updates UI]
Player state may also change due to preparation completion, buffering, playback end, or errors; it is not always triggered by a single control command. The app should publish PlaybackState from a unified actual state source, not only when onPlay() or onPause() are called. MediaSessionService manages and forwards requests; it does not automatically fill in player state.
In the diagram, "construct PlaybackState" means the app organizes the session report based on player state, not that a first frame or sound output has been confirmed. Similarly, a media key returning a dispatch sequence number, the app receiving a control callback, and the controller receiving a state change each indicate progress at each stage; actual output still needs to be confirmed along the media processing, audio routing, and device chain.
Syncing State: Snapshot Query and Callback Subscription Work Together
The previous diagrams already show how state notifications reach controllers, but a newly connected UI still needs to read the session information at that time. MediaController therefore provides both snapshot queries and change subscriptions: queries establish the initial display, callbacks continue subsequent changes, and the controller coordinates the two paths.
Initial Query and Change Subscription Need to Be Designed Together
MediaController can query PlaybackState, MediaMetadata, queue, extras, session activity entry, and PlaybackInfo, and can register callbacks to receive subsequent changes. Registering only a listener without reading initial information may leave a new control UI temporarily without content; querying only without listening leaves stale information. The interface does not require query before registration; the integration order needs to consider the change window between the two.
Source location: frameworks/base/media/java/android/media/session/MediaController.java, getPlaybackState() / getMetadata().
public @Nullable PlaybackState getPlaybackState() {
try {
return mSessionBinder.getPlaybackState();
} catch (RemoteException ex) {
throw ex.rethrowFromSystemServer();
}
}
// …
public @Nullable MediaMetadata getMetadata() {
try {
return mSessionBinder.getMetadata();
} catch (RemoteException ex) {
throw ex.rethrowFromSystemServer();
}
}
Source location: frameworks/base/media/java/android/media/session/MediaController.java, registerCallback().
public void registerCallback(@NonNull Callback callback, @Nullable Handler handler) {
if (callback == null) {
throw new IllegalArgumentException("callback must not be null");
}
if (handler == null) {
handler = new Handler();
}
synchronized (mLock) {
addCallbackLocked(callback, handler);
}
}
The controller should unify initial queries and change callbacks into its own UI model, and consider changes that may occur between subscription and query. They are not an atomic transaction; two independent calls cannot be assumed to be a snapshot at the same moment.
For example, the UI first reads PAUSED, the player then switches to PLAYING, but the controller has not yet finished registering; that notification could fall outside the subscription window. Registering first and then reading initial information can narrow this window; but the query result return and callback message handling can still interleave, so you cannot let an old query result that is processed later unconditionally overwrite a newer change. The controller should direct both query and callback into the same model-update mechanism; the app should publish information from a unified player-state source.
The platform session does not package PlaybackState, metadata, queue, and extras into an atomic snapshot with a unified version number. When a track changes, the controller may see the new title before the new queue position. UI should tolerate brief cross-field differences; for stricter consistency requirements, you can correlate content IDs or business versions in your own protocol and define the handling rules.
The server saves callback holders and also listens for controller Binder death, to avoid keeping invalid subscriptions after a process exits.
Source location: frameworks/base/services/core/java/com/android/server/media/MediaSessionRecord.java, registerCallback().
private void registerCallback(String packageName, ISessionControllerCallback cb) {
synchronized (mLock) {
if (getControllerHolderIndexForCb(cb) < 0) {
ISessionControllerCallbackHolder holder =
new ISessionControllerCallbackHolder(
cb, packageName, () -> unregisterCallback(cb));
mControllerCallbackHolders.add(holder);
// …
try {
// cb is not referenced outside of the MediaSessionRecord, so the death
// handler won't prevent MediaSessionRecord to be garbage collected.
cb.asBinder().linkToDeath(holder.mDeathMonitor, 0);
} catch (RemoteException e) {
unregisterCallback(cb);
Slog.w(TAG, "registerCallback failed to linkToDeath", e);
}
App-side callbacks are handled on the Handler chosen at registration; the server-side record also uses its own Handler to organize message publication. When thread and Binder calls interleave, check state snapshot, lock scope, and callback delivery order; you cannot equate "notification has been sent" with "control UI has refreshed."
Source: State snapshot, Controller subscription, Server-side subscription and death handling, Unsubscribe.
Playback Progress Is Extrapolated From Reported Information
When the server queries PlaybackState, it calls getStateWithUpdatedPosition(). For states like PLAYING, FAST_FORWARDING, and REWINDING, if the update time is valid, it uses speed and elapsed time to extrapolate position and clamps it to the valid range.
Source location: frameworks/base/services/core/java/com/android/server/media/MediaSessionRecord.java, getStateWithUpdatedPosition().
if (state.getState() == PlaybackState.STATE_PLAYING
|| state.getState() == PlaybackState.STATE_FAST_FORWARDING
|| state.getState() == PlaybackState.STATE_REWINDING) {
long updateTime = state.getLastPositionUpdateTime();
long currentTime = SystemClock.elapsedRealtime();
if (updateTime > 0) {
long position = (long) (state.getPlaybackSpeed()
* (currentTime - updateTime)) + state.getPosition();
if (duration >= 0 && position > duration) {
position = duration;
} else if (position < 0) {
position = 0;
}
PlaybackState.Builder builder = new PlaybackState.Builder(state);
builder.setState(state.getState(), position, state.getPlaybackSpeed(),
currentTime);
result = builder.build();
}
This lets a controller avoid requiring the app to send a notification for every progress tick, but the extrapolation still relies on app-provided basis: position, speed, timestamp, and duration. If buffering, pause, seek, or playback errors are not updated in time, the extrapolated value may drift from the player's actual position. The position here is media position, not a measurement reverse-derived from speaker output.
Source: Update position on query.
Metadata, Queue, and the Player's Internal List Must Correspond
MediaMetadata expresses the current content; QueueItem provides a displayable queue item with an ID. The list, current item, and playback state update separately; the app must maintain the correspondence among them. The system session record saves and notifies; it does not replace the app's internal playlist management.
Source location: frameworks/base/services/core/java/com/android/server/media/MediaSessionRecord.java, setMetadata().
private void setMetadata(MediaMetadata metadata, long duration, String metadataDescription) {
synchronized (mLock) {
mDuration = duration;
mMetadataDescription = metadataDescription;
mMetadata = sanitizeMediaMetadata(metadata);
}
mHandler.post(MessageHandler.MSG_UPDATE_METADATA);
}
The current implementation cleans related URI information from metadata before saving and publishing. A controller seeing an artwork or title does not mean the resource has been decoded or is being output by the player.
Queue also involves cross-process transfer: the app sends the list using ParcelableListBinder; after the server saves it, it uses ParceledListSlice when notifying controllers, limiting the number of elements inlined in the initial Parcel.
Source location: frameworks/base/media/java/android/media/session/MediaSession.java, setQueue().
public void setQueue(@Nullable List<QueueItem> queue) {
try {
if (queue == null) {
mBinder.resetQueue();
} else {
IBinder binder = mBinder.getBinderForSetQueue();
if (binder != null) {
ParcelableListBinder.send(binder, queue);
}
}
} catch (RemoteException e) {
Log.wtf("Dead object in setQueue.", e);
}
}
Source location: frameworks/base/services/core/java/com/android/server/media/MediaSessionRecord.java, pushQueueUpdate() constructs the callback parameter.
ParceledListSlice<QueueItem> parcelableQueue = null;
if (toSend != null) {
parcelableQueue = new ParceledListSlice<>(toSend);
// Limit the size of initial Parcel to prevent binder buffer overflow
// as onQueueChanged is an async binder call.
parcelableQueue.setInlineCountLimit(1);
}
holder.mCallback.onQueueChanged(parcelableQueue);
This reflects Binder data size constraints: queue change is control information, but a large list may need to be sent in batches. Interface composition and callback publication need to account for transfer cost; you cannot treat an entire content library as an unbounded session state package.
Source: Save metadata, App sends queue, Server saves queue, Queue update notification, Metadata notification.
Figure 4: ① register subscription, ② app submits information, ③ system notifies change, ④ controller queries, ⑤ returns query result. Numbering distinguishes paths, not call order. The subscription management, saving, notification, and query nodes in the system record all belong to the same MediaSessionRecord; gray dashed lines indicate notifications use registered callbacks. The controller directs query results and change callbacks into the same UI model; the two read paths do not form an atomic snapshot.
State Snapshots and One-Time Events Are Suited to Different Information
setPlaybackState(), setMetadata(), and setExtras() update the session's currently readable information, which new controllers can query; sendSessionEvent() delivers a one-time event to current subscribers. The former is suited to "what is now," the latter to "what happened once" under a business agreement, such as a prompt for an app custom operation.
Source location: frameworks/base/media/java/android/media/session/MediaSession.java, sendSessionEvent().
public void sendSessionEvent(@NonNull String event, @Nullable Bundle extras) {
if (TextUtils.isEmpty(event)) {
throw new IllegalArgumentException("event cannot be null or empty");
}
try {
mBinder.sendEvent(event, extras);
} catch (RemoteException e) {
Log.wtf(TAG, "Error sending event", e);
}
}
Source location: frameworks/base/services/core/java/com/android/server/media/MediaSessionRecord.java, pushEvent().
private void pushEvent(String event, Bundle data) {
synchronized (mLock) {
if (mDestroyed) {
return;
}
}
performOnCallbackHolders("pushEvent",
holder -> holder.mCallback.onEvent(event, data));
}
This publication path iterates over callbacks held at that time; it does not save a queryable event history for controllers connecting later. If a controller was not subscribed or disconnected, it cannot expect to catch up on events after reconnecting. If the information affects subsequent UI state, maintain a queryable business state as well, rather than sending only a one-time event.
Session activity entry is another type of information: setSessionActivity(PendingIntent) provides an entry back to the player UI, and controllers get it via getSessionActivity(). It differs in responsibility from the receiver used for media key resume playback; do not mix them just because both use PendingIntent.
Source location: frameworks/base/media/java/android/media/session/MediaSession.java, setSessionActivity().
public void setSessionActivity(@Nullable PendingIntent pi) {
try {
mBinder.setLaunchPendingIntent(pi);
} catch (RemoteException e) {
Log.wtf(TAG, "Failure in setLaunchPendingIntent.", e);
}
}
Source: Send session event, Publish event to current subscribers, Set activity entry.
Controlling Volume: Local and Remote Playback Follow Different Paths
Play, pause, and other commands are ultimately executed by the app on the player; the executor of a volume request depends on the playback type declared by the session. Local volume goes to the audio system; remote volume goes to the app-provided VolumeProvider.
First Declare Who Handles Volume
setPlaybackToLocal(AudioAttributes) declares the audio attributes for local playback; setPlaybackToRemote(VolumeProvider) declares that the app handles remote volume. The app still holds MediaSession and VolumeProvider in its own process; remote device connection, control protocol, and volume information retrieval are the app's playback solution's responsibility.
Source location: frameworks/base/media/java/android/media/session/MediaSession.java, setPlaybackToRemote().
synchronized (mLock) {
mVolumeProvider = volumeProvider;
}
volumeProvider.setCallback(new VolumeProvider.Callback() {
@Override
public void onVolumeChanged(VolumeProvider volumeProvider) {
notifyRemoteVolumeChanged(volumeProvider);
}
});
try {
mBinder.setPlaybackToRemote(volumeProvider.getVolumeControl(),
volumeProvider.getMaxVolume(), volumeProvider.getVolumeControlId());
mBinder.setCurrentVolume(volumeProvider.getCurrentVolume());
} catch (RemoteException e) {
Log.wtf(TAG, "Failure in setPlaybackToRemote.", e);
}
VolumeProvider declares control type, maximum volume, and current volume. The system decides whether a request can be forwarded to the app based on this:
| Control type | adjustVolume relative adjustment | setVolumeTo absolute setting |
|---|---|---|
| FIXED | Not forwarded to app | Not forwarded to app |
| RELATIVE | Supports non-mute adjustments | Not forwarded to app |
| ABSOLUTE | Supports non-mute adjustments | Supported, value limited to allowed range |
The remote adjustment path here also excludes ADJUST_MUTE, ADJUST_UNMUTE, and ADJUST_TOGGLE_MUTE. Implementing VolumeProvider does not mean all volume operations reach the app.
Local Goes to the Audio System, Remote Goes to the App Callback
For local volume adjustment, the corresponding stream is selected based on AudioAttributes, posted to a Handler, and then the audio management interface is called. The remote path forwards to the session owner and decides whether to allow adjustment or setting based on the supported control type.
Source location: frameworks/base/services/core/java/com/android/server/media/MediaSessionRecord.java, adjustVolume() local and adjustable remote branches.
if (mVolumeType == PLAYBACK_TYPE_LOCAL) {
int stream = getVolumeStream(mAudioAttrs);
postAdjustLocalVolume(stream, direction, flags, opPackageName, pid, uid,
asSystemService, useSuggested, previousFlagPlaySound);
}
// Remote branch after control type and direction checks
mSessionCb.adjustVolume(packageName, pid, uid, asSystemService, direction);
int volumeBefore = (mOptimisticVolume < 0 ? mCurrentVolume : mOptimisticVolume);
mOptimisticVolume = volumeBefore + direction;
mOptimisticVolume = Math.max(0, Math.min(mOptimisticVolume, mMaxVolume));
mHandler.removeCallbacks(mClearOptimisticVolumeRunnable);
mHandler.postDelayed(mClearOptimisticVolumeRunnable, OPTIMISTIC_VOLUME_TIMEOUT);
if (volumeBefore != mOptimisticVolume) {
pushVolumeUpdate();
}
Absolute volume setting is only forwarded to the app when remote declares VOLUME_CONTROL_ABSOLUTE, and the value is clamped to the allowed range.
Source location: frameworks/base/services/core/java/com/android/server/media/MediaSessionRecord.java, setVolumeTo().
if (mVolumeType == PLAYBACK_TYPE_LOCAL) {
int stream = getVolumeStream(mAudioAttrs);
final int volumeValue = value;
mHandler.post(
() ->
setStreamVolumeForUid(
opPackageName, pid, uid, flags, stream, volumeValue));
} else {
if (mVolumeControlType != VOLUME_CONTROL_ABSOLUTE) {
if (DEBUG) {
Slog.d(TAG, "Session does not support setting volume");
}
} else {
value = Math.max(0, Math.min(value, mMaxVolume));
mSessionCb.setVolumeTo(packageName, pid, uid, value);
// Later updates optimistic estimate and schedules cleanup
}
}
The system record forwards the remote request to the app CallbackStub via Binder, then posts to the app's callback Handler. The Handler takes out the current VolumeProvider and calls the relative adjustment or absolute setting method:
Source location: frameworks/base/media/java/android/media/session/MediaSession.java, CallbackMessageHandler volume branches.
case MSG_ADJUST_VOLUME:
synchronized (mLock) {
vp = mVolumeProvider;
}
if (vp != null) {
vp.onAdjustVolume((int) obj);
}
break;
case MSG_SET_VOLUME:
synchronized (mLock) {
vp = mVolumeProvider;
}
if (vp != null) {
vp.onSetVolumeTo((int) obj);
}
break;
The App Reports the Current Value; the System Also Maintains an Optimistic Estimate
VolumeProvider.onAdjustVolume() and onSetVolumeTo() are handling entries to be implemented by the app. The app connects the request to the remote playback solution and, after obtaining new volume information, calls setCurrentVolume(); the framework does not automatically read the remote device volume for the app.
Source location: frameworks/base/media/java/android/media/VolumeProvider.java, setCurrentVolume().
public final void setCurrentVolume(int currentVolume) {
mCurrentVolume = currentVolume;
if (mCallback != null) {
mCallback.onVolumeChanged(this);
}
}
When registering the remote playback type, MediaSession has already set a callback for the provider. When the current value changes, it enters notifyRemoteVolumeChanged(), where the framework checks that the provider is still the current object, then reports volume via Binder.
Source location: frameworks/base/media/java/android/media/session/MediaSession.java, notifyRemoteVolumeChanged().
synchronized (mLock) {
if (provider == null || provider != mVolumeProvider) {
Log.w(TAG, "Received update from stale volume provider");
return;
}
}
try {
mBinder.setCurrentVolume(provider.getCurrentVolume());
} catch (RemoteException e) {
Log.e(TAG, "Error in notifyVolumeChanged", e);
}
Source: Provider current value update, Report volume to session record.
In the remote branch, mOptimisticVolume is a temporary estimate: after sending a request, the display is updated first, and the temporary value is cleaned by a timeout task; the app's report updates the current volume in the record. This is a strategy to reduce control-feedback latency; the estimate is not proof that the remote device has executed successfully.
Source location: frameworks/base/services/core/java/com/android/server/media/MediaSessionRecord.java, setCurrentVolume() and estimate cleanup task.
private void setCurrentVolume(int volume) {
mCurrentVolume = volume;
mHandler.post(MessageHandler.MSG_UPDATE_VOLUME);
}
private final Runnable mClearOptimisticVolumeRunnable =
() -> {
boolean needUpdate = (mOptimisticVolume != mCurrentVolume);
mOptimisticVolume = -1;
if (needUpdate) {
pushVolumeUpdate();
}
};
Updating mCurrentVolume and clearing mOptimisticVolume are different paths; you cannot treat any remote report as proof that the cleanup task has run.
Whether the app's reported value corresponds to the remote device having actually executed is guaranteed by the app and remote protocol; the framework only saves the passed value. The current source's estimate cleanup delay is 1000 milliseconds; allowed relative adjustments and absolute settings both reschedule the cleanup. This timeout ends the temporary display estimate; it is not a timeout or retry mechanism for the remote device operation.
Source: Optimistic estimate timeout constant.
Where PlaybackInfo Gets Its Values and How It Notifies Controllers
When a controller reads PlaybackInfo, the volume source for local and remote also differs: local queries the audio stream from AudioManager, remote uses the app-reported current value; if a temporary estimate exists from a request, that estimate is returned first. How this estimate is produced and cleaned is explained next with the volume execution code.
Source location: frameworks/base/services/core/java/com/android/server/media/MediaSessionRecord.java, getVolumeAttributes().
if (mVolumeType == PlaybackInfo.PLAYBACK_TYPE_REMOTE) {
int current = mOptimisticVolume != -1 ? mOptimisticVolume : mCurrentVolume;
return new PlaybackInfo(mVolumeType, mVolumeControlType, mMaxVolume, current,
mAudioAttrs, mVolumeControlId);
}
// Local branch
int stream = getVolumeStream(attributes);
int max = mAudioManager.getStreamMaxVolume(stream);
int current = mAudioManager.getStreamVolume(stream);
Source: PlaybackInfo volume source.
Local reads get the current audio stream value from AudioManager, independent of whether a volume adjustment was just executed. Remote reads select the display value based on whether mOptimisticVolume is valid: even if the app has reported a new value, the estimate may still take precedence in queries or notifications as long as it exists.
The controller actively calls getPlaybackInfo() to get a query result; after subscribing, it receives volume information changes via MediaController.Callback.onAudioInfoChanged(). The server-side pushVolumeUpdate() also calls getVolumeAttributes(), constructs a PlaybackInfo, and sends a Binder callback.
Source location: frameworks/base/services/core/java/com/android/server/media/MediaSessionRecord.java, pushVolumeUpdate().
private void pushVolumeUpdate() {
PlaybackInfo info;
synchronized (mLock) {
if (mDestroyed) {
return;
}
info = getVolumeAttributes();
}
performOnCallbackHolders(
"pushVolumeUpdate", holder -> holder.mCallback.onVolumeInfoChanged(info));
}
Source: Controller volume info notification, Controller volume callback.
System Volume UI Interaction Notification Is Another Path
After a remote volume request is handled, the record also calls MediaSessionService.notifyRemoteVolumeChanged(flags, this). This path sends the session Token and flags to registered IRemoteSessionCallback so the system volume UI gets the corresponding interaction event; it is distinct from the controller volume info notification carrying PlaybackInfo.
Source location: frameworks/base/services/core/java/com/android/server/media/MediaSessionService.java, notifyRemoteVolumeChanged().
if (!session.isActive()) {
return;
}
// iterate registered system remote session callbacks
IRemoteSessionCallback cb = mRemoteVolumeControllers.getBroadcastItem(i);
cb.onVolumeChanged(token, flags);
In the remote adjustment or setting branches, even if the volume did not change, the record calls this service notification path; the service also checks whether the session is active. Therefore, the system volume UI receiving an interaction event does not alone prove that the remote has executed or that the current value has been updated.
Source: UI notification after remote request, System remote volume callback.
Media keys and system volume keys each select their own handling target; this section's MediaController volume interface already specifies a session through Token. Even if car input comes from the same set of steering wheel keys, you need to trace each specific entry; you cannot directly assume that play keys and volume keys are always handled by the same session.
Source: Configure remote volume, Volume adjustment and estimate, Current volume report, Estimate cleanup, Absolute volume setting, Local volume task, App VolumeProvider callback, Controller query PlaybackInfo.
Figure 5: ① specified session volume request, ② local audio processing, ③ remote app callback, ④ app reports current value, ⑤ query PlaybackInfo, ⑥ query return or volume info notification, ⑦ system remote volume interaction event. Orange dashed lines indicate allowable remote requests produce an optimistic estimate, without waiting for app report. Local read and execution are independent; ⑥ and ⑦ have different data and receivers.
Session Exit: Explicit Release, Process Death, and Subscription Cleanup
The earlier control, state, and volume collaboration all rely on valid session records and callback connections. When use ends or a process exits, you need to distinguish: is the exiting party the session owner or one of the subscribed controllers? The two cases clean up different objects.
release Destroys the Session Connection, Not the Player
When the app finishes, it calls release(), clears its own callback, and requests the server to destroy the record.
Source location: frameworks/base/media/java/android/media/session/MediaSession.java, release().
public void release() {
if (mIsReleased) {
return;
}
setCallback(null);
try {
mBinder.destroySession();
} catch (RemoteException e) {
Log.wtf(TAG, "Error releasing session: ", e);
} finally {
mIsReleased = true;
}
}
The player, audio focus, and other resources are still managed by their respective holders. Do not treat release() as a stop/release of the player object; do not stop the player and assume the session record is destroyed.
Owner Process Death Goes Through the Same Service Cleanup
MediaSessionRecord listens for the death of the owner callback Binder during construction; after the process exits, it calls onSessionDied() to request service handling. The destruction process removes the record from the session collection, cleans up the global target or stack association, and then closes the single-session record.
Source location: frameworks/base/services/core/java/com/android/server/media/MediaSessionRecord.java, binderDied().
public void binderDied() {
mService.onSessionDied(this);
}
Source location: frameworks/base/services/core/java/com/android/server/media/MediaSessionService.java, destroySessionLocked().
if (mGlobalPrioritySession == session) {
mGlobalPrioritySession = null;
if (session.isActive()) {
if (user != null) {
user.pushAddressedPlayerChangedLocked();
}
mHandler.post(this::notifyGlobalPrioritySessionActiveChanged);
}
} else {
if (user != null) {
user.mPriorityStack.removeSession(session);
}
}
session.close();
Source location: frameworks/base/services/core/java/com/android/server/media/MediaSessionRecord.java, close().
synchronized (mLock) {
if (mDestroyed) {
return;
}
mSessionCb.mCb.asBinder().unlinkToDeath(this, 0);
mController.release();
mSession.release();
mDestroyed = true;
mPlaybackState = null;
mUserEngagementState = USER_DISENGAGED;
mHandler.removeCallbacks(mUserEngagementTimeoutExpirationRunnable);
mHandler.post(MessageHandler.MSG_DESTROYED);
}
close() marks the record as destroyed, clears playback state, and arranges a Handler message for destruction. pushSessionDestroyed() iterates over subscription holders, first unlinks each death listener, then sends a Binder destruction notification; then it clears the server-side subscription list. Controllers process the notification through their own Handler; the server does not wait for the control UI to finish updating.
Source location: frameworks/base/services/core/java/com/android/server/media/MediaSessionRecord.java, pushSessionDestroyed().
performOnCallbackHolders(
"pushSessionDestroyed",
holder -> {
holder.mCallback.asBinder().unlinkToDeath(holder.mDeathMonitor, 0);
holder.mCallback.onSessionDestroyed();
});
// After notifying clear all listeners
synchronized (mLock) {
mControllerCallbackHolders.clear();
}
Controller Exit Cleans Up Only the Corresponding Subscription
When the owner's session is destroyed, it affects controllers connected to it; when a controller cancels a listener, cleanup starts with the local callback list. You also need to distinguish two objects: the app-registered MediaController.Callback and the shared Binder callback mCbStub that the controller registers with the server.
The same MediaController can register multiple app Callbacks, but the first registration registers mCbStub with the server only once. unregisterCallback(callback) first removes the specified callback's local Handler; if other local callbacks remain, the server subscription is retained. Only when all local callbacks are canceled and the Binder subscription still exists does it request the system to remove this subscription holder.
Source location: frameworks/base/media/java/android/media/session/MediaController.java, removeCallbackLocked().
for (int i = mCallbacks.size() - 1; i >= 0; i--) {
MessageHandler handler = mCallbacks.get(i);
if (cb == handler.mCallback) {
mCallbacks.remove(i);
success = true;
handler.mRegistered = false;
}
}
if (mCbRegistered && mCallbacks.size() == 0) {
try {
mSessionBinder.unregisterCallback(mCbStub);
} catch (RemoteException ex) {
throw ex.rethrowFromSystemServer();
}
mCbRegistered = false;
}
Source: Local callback and shared Binder subscription, Unregister after last local callback canceled.
When a controller callback Binder dies, the server-side holder's death listener triggers cleanup. The server-side unregisterCallback() finds this Binder callback, unlinks the death listener, and removes the corresponding holder; this path does not enter destroySessionLocked().
Source location: frameworks/base/services/core/java/com/android/server/media/MediaSessionRecord.java, unregisterCallback().
int index = getControllerHolderIndexForCb(cb);
if (index != -1) {
try {
cb.asBinder().unlinkToDeath(
mControllerCallbackHolders.get(index).mDeathMonitor, 0);
} catch (NoSuchElementException e) {
Slog.w(TAG, "error unlinking to binder death", e);
}
mControllerCallbackHolders.remove(index);
}
Source: Remove subscription holder.
Owner death and controller death clean up different objects: the former destroys the session, the latter usually removes one subscription. After receiving onSessionDestroyed(), a control UI should end the old connection and rediscover or obtain a valid Token; a historical Token cannot automatically represent a newly created session.
Source: Explicit release, Listen for owner death, Service destroys session, Close record, Notify and clear subscriptions.
Figure 6: On the left, explicit release or owner Binder death enters service cleanup, record close, destruction notification, and subscription list clearing. On the right, after canceling one local Callback, check whether other callbacks remain; only the last cancellation unregisters the shared Binder subscription. Controller Binder death directly triggers server-side cleanup of the corresponding subscription. The cleanup scope differs on the two sides; the server sending notification and the controller processing it are not the same moment.
What Objects to Confirm When Troubleshooting on a Vehicle
In a car system, media keys may come from the steering wheel, Bluetooth, system UI, or vendor control entry. First confirm whether the entry actually uses system media key dispatch or a control call targeting a Token, then trace user, UID, package name, and session ownership. A vendor custom dispatcher may also change target selection.
adb shell dumpsys media_session
Use the output's user sessions, active flags, playback state, and media key target, and correlate with the app's callback and player logs. Actual fields depend on the device output.
| Symptom | Priority check | Results to distinguish |
|---|---|---|
| Key no response | System entry, listeners, custom dispatcher, target session, app callback | Key received vs. app notified |
| Callback received but no playback | onPlay implementation, player return result, data source preparation and errors | Command arrived vs. player executed |
| Standalone play key doesn't trigger onPlay | Default media key handling ACTION_PLAY, event type, override logic | Supported action declaration vs. event handling |
| Controls another player | Media key target, audio active UID, session policy, Token and user | Default selection vs. specified session control |
| UI shows playing but no sound | App reporting timing, player output, audio route and device state | Playback state report vs. actual output |
| Track, queue, or progress inconsistent | Metadata, QueueItem ID, position timestamp, query and callback timing | App content state vs. published info |
| Local or remote volume abnormal | Playback type, control capability, audio stream read value, app report and optimistic estimate | Request forwarding, current value update, remote execution |
| System volume UI appears but remote unchanged | Active condition, system remote volume callback, provider handling and current value | System interaction event vs. PlaybackInfo notification |
| Control fails after app exits | Session destruction, old Token, controller destruction notification and reconnect | Historical record vs. current active session |
MediaSession itself does not decide audio routing, nor does it select which audio zone actually produces sound. When the session target is correct but the output device is wrong, continue to check AudioPolicy and the audio output chain. Audio focus also needs separate management; setActive(true) does not equal having acquired focus.
Reference: Media key dispatch extension, Audio playback observation, Audio focus request.
Reflections and Summary
The Point of Comparison Is How Responsibilities Are Allocated
MediaSession's value is not just delivering a play key to an app, but defining a common language between system and player: identity and target, commands and supported actions, playback and content information, volume model, connection and destruction. Players can use different data-processing implementations; controllers still use the same session protocol.
| Comparison target | System control and player relationship | Who is responsible for consistency |
|---|---|---|
| Platform MediaSession | Session interface and player implementation are separate; app provides callback and information publication | App connects commands and published info to the player; system manages ownership, dispatch, and notification |
| AndroidX Media3 MediaSession | Session binds Media3 Player, adapts control and Player state | Standard connection is handled by the library; app maintains business extensions, control access, and player implementation |
| Linux desktop MPRIS | D-Bus protocol defines playback commands, metadata, playback state, and property notifications | Player implements the protocol; desktop controls access via the protocol; its object and permission model differs from Android |
| FFmpeg / GStreamer | Provide media processing libraries or pipelines; app can build a player from them | Integrator additionally connects platform media control, device policy, and lifecycle |
Platform MediaSession separates control protocol from media execution, letting the player retain implementation freedom; Media3 provides tighter session-to-player adaptation, reducing manual maintenance of two sets of information; MPRIS provides a standard protocol for cross-process desktop scenarios. All three pursue control interoperability, but adaptation responsibility, system management capability, and deployment environment differ. When comparing design, you cannot look only at whether there are play/pause methods.
Reference: Media3 session and player, MPRIS interface specification, FFmpeg documentation, GStreamer application development.
Android's Architectural Constraints Explain Why These Components Are Needed
Multiple apps and multiple users make target selection a system responsibility. An app can describe its own state, but cannot, by its own wish, decide who gets the next system media key. User records, permissions, audio activity observation, and session policy all participate in this judgment; specified-Token control is another path.
Cross-process makes information publication have to consider data size and object lifetime. Queue uses segmented transfer; callback registration attaches death listeners; after destruction notification, holders are cleaned. This code reflects that interface design carries resource and lifecycle responsibilities, not just functional calls.
A time gap exists between state and device results. Playback position can be extrapolated, remote volume can be given an optimistic estimate first, and app reports and controller notifications go through Handler and Binder. The system uses these mechanisms to improve interaction feedback, but the estimate, the app-reported value, and the device execution result each have their own source; the system volume UI's interaction event cannot replace device confirmation.
System control does not handle device output. MediaSession receives playback requests and connects local or remote volume handling, but audio focus, routing, decoding, and real device state remain in their own chains. Connecting to a unified control protocol does not mean all output conditions are satisfied.
From a Framework development perspective, the real thing to maintain is a set of connection relationships: which user's session, owned by whom, who is subscribed, which callback is waiting to be handled, and whether the held Token is still valid. From an app perspective, organize command execution, player state, and information publication around the same player-state source. Only by mapping the two sides' responsibilities can you maintain consistency across multiple players, multiple controllers, and device state changes.