Skip to content

Commit f584849

Browse files
committed
Fix workaround logic for system UI gaps
The workaround introduced in a645066 should only be applied in cases where the stock system UI is used and the workaround is needed. In all other cases, like other manfacturers or when connected to Android Auto, the original usage is preferred as it more correctly represents the intention and helps to display the buttons more consistently. Issue: #3041 PiperOrigin-RevId: 865364728 (cherry picked from commit eeb5a2a)
1 parent ccfc4f3 commit f584849

File tree

4 files changed

+239
-53
lines changed

4 files changed

+239
-53
lines changed

RELEASENOTES.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,10 @@ This release includes the following changes since
1111
* Fix bug where `ProgressiveMediaSource` propagates out-of-date timeline
1212
info to player and the queued periods unexpectedly get removed
1313
([#3016](https://github.com/androidx/media/issues/3016)).
14+
* Session:
15+
* Fix issue where system UI button placement workaround negatively affects
16+
other UI surface like Android Auto or manufacturers not needing the
17+
workaround ([#3041](https://github.com/androidx/media/issues/3041)).
1418
* Cast extension:
1519
* Fix bug where transferring from Cast to local playback was broken.
1620

Lines changed: 127 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,127 @@
1+
/*
2+
* Copyright 2026 The Android Open Source Project
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
package androidx.media3.session;
17+
18+
import static android.os.Build.VERSION.SDK_INT;
19+
20+
import android.content.BroadcastReceiver;
21+
import android.content.Context;
22+
import android.content.Intent;
23+
import android.content.IntentFilter;
24+
import android.database.Cursor;
25+
import android.net.Uri;
26+
import androidx.media3.common.util.BackgroundExecutor;
27+
import java.util.concurrent.Executor;
28+
import java.util.concurrent.atomic.AtomicBoolean;
29+
30+
/** Util class to observe the connection state to Android Auto. */
31+
/* package */ final class AndroidAutoConnectionStateObserver {
32+
33+
private static final Uri QUERY_URI = Uri.parse("content://androidx.car.app.connection");
34+
private static final String QUERY_COLUMN = "CarConnectionState";
35+
private static final String BROADCAST_INTENT =
36+
"androidx.car.app.connection.action.CAR_CONNECTION_UPDATED";
37+
38+
private final Context context;
39+
private final Runnable listener;
40+
private final Executor backgroundExecutor;
41+
private final AndroidAutoChangeReceiver changeReceiver;
42+
private final AtomicBoolean isConnected;
43+
private final AtomicBoolean isReleased;
44+
45+
/**
46+
* Creates the observer.
47+
*
48+
* @param context A {@link Context}.
49+
* @param onConnectionStateChanged Called when the return value of {@link #isConnected()} changed.
50+
* Will be called on a background thread.
51+
*/
52+
public AndroidAutoConnectionStateObserver(Context context, Runnable onConnectionStateChanged) {
53+
this.context = context.getApplicationContext();
54+
this.listener = onConnectionStateChanged;
55+
this.backgroundExecutor = BackgroundExecutor.get();
56+
this.changeReceiver = new AndroidAutoChangeReceiver();
57+
this.isConnected = new AtomicBoolean();
58+
this.isReleased = new AtomicBoolean();
59+
backgroundExecutor.execute(
60+
() -> {
61+
IntentFilter intentFilter = new IntentFilter(BROADCAST_INTENT);
62+
if (SDK_INT >= 33) {
63+
this.context.registerReceiver(changeReceiver, intentFilter, Context.RECEIVER_EXPORTED);
64+
} else {
65+
this.context.registerReceiver(changeReceiver, intentFilter);
66+
}
67+
updateConnectionState();
68+
});
69+
}
70+
71+
/** Release the observer. */
72+
public void release() {
73+
if (isReleased.getAndSet(true)) {
74+
return;
75+
}
76+
backgroundExecutor.execute(() -> context.unregisterReceiver(changeReceiver));
77+
}
78+
79+
/** Returns whether the device is currently connected to Android Auto. */
80+
public boolean isConnected() {
81+
return isConnected.get();
82+
}
83+
84+
private void updateConnectionState() {
85+
boolean oldValue = isConnected.get();
86+
boolean newValue = queryConnectionState();
87+
isConnected.set(newValue);
88+
if (oldValue != newValue && !isReleased.get()) {
89+
listener.run();
90+
}
91+
}
92+
93+
private boolean queryConnectionState() {
94+
// Query the Android Auto content provider and check if it returns at least one non-zero entry
95+
// for the requested query column.
96+
try (Cursor cursor =
97+
context
98+
.getContentResolver()
99+
.query(
100+
QUERY_URI,
101+
new String[] {QUERY_COLUMN},
102+
/* selection= */ null,
103+
/* selectionArgs= */ null,
104+
/* orderBy= */ null)) {
105+
if (cursor == null) {
106+
return false;
107+
}
108+
int columnIndex = cursor.getColumnIndex(QUERY_COLUMN);
109+
if (columnIndex == -1) {
110+
return false;
111+
}
112+
if (!cursor.moveToNext()) {
113+
return false;
114+
}
115+
return cursor.getInt(columnIndex) != 0;
116+
} catch (Exception e) {
117+
return false;
118+
}
119+
}
120+
121+
private final class AndroidAutoChangeReceiver extends BroadcastReceiver {
122+
@Override
123+
public void onReceive(Context context, Intent intent) {
124+
backgroundExecutor.execute(AndroidAutoConnectionStateObserver.this::updateConnectionState);
125+
}
126+
}
127+
}

libraries/session/src/main/java/androidx/media3/session/MediaSessionLegacyStub.java

Lines changed: 93 additions & 48 deletions
Original file line numberDiff line numberDiff line change
@@ -134,6 +134,8 @@
134134
private final MediaSessionManager sessionManager;
135135
private final ControllerLegacyCbForBroadcast controllerLegacyCbForBroadcast;
136136
private final ConnectionTimeoutHandler connectionTimeoutHandler;
137+
private final boolean mayNeedButtonReservationWorkaroundForSeekbar;
138+
@Nullable private final AndroidAutoConnectionStateObserver androidAutoObserver;
137139
private final MediaSessionCompat sessionCompat;
138140
@Nullable private final MediaButtonReceiver runtimeBroadcastReceiver;
139141
@Nullable private final ComponentName broadcastReceiverComponentName;
@@ -182,6 +184,8 @@ public MediaSessionLegacyStub(
182184
connectionTimeoutHandler =
183185
new ConnectionTimeoutHandler(
184186
session.getApplicationHandler().getLooper(), connectedControllersManager);
187+
mayNeedButtonReservationWorkaroundForSeekbar =
188+
mayNeedButtonReservationWorkaroundForSeekbar(context);
185189

186190
if (!mediaButtonPreferences.isEmpty()) {
187191
updateCustomLayoutAndLegacyExtrasForMediaButtonPreferences();
@@ -260,6 +264,12 @@ public MediaSessionLegacyStub(
260264
@Initialized
261265
MediaSessionLegacyStub thisRef = this;
262266
sessionCompat.setCallback(thisRef, handler);
267+
268+
androidAutoObserver =
269+
mayNeedButtonReservationWorkaroundForSeekbar
270+
? new AndroidAutoConnectionStateObserver(
271+
context, thisRef::onAndroidAutoConnectionStateChanged)
272+
: null;
263273
}
264274

265275
/**
@@ -281,25 +291,7 @@ public void setAvailableCommands(
281291
this.availablePlayerCommands = playerCommands;
282292

283293
if (!mediaButtonPreferences.isEmpty()) {
284-
boolean hadPrevReservation =
285-
legacyExtras.getBoolean(
286-
MediaConstants.EXTRAS_KEY_SLOT_RESERVATION_SEEK_TO_PREV, /* defaultValue= */ false);
287-
boolean hadNextReservation =
288-
legacyExtras.getBoolean(
289-
MediaConstants.EXTRAS_KEY_SLOT_RESERVATION_SEEK_TO_NEXT, /* defaultValue= */ false);
290-
updateCustomLayoutAndLegacyExtrasForMediaButtonPreferences();
291-
boolean extrasChanged =
292-
(legacyExtras.getBoolean(
293-
MediaConstants.EXTRAS_KEY_SLOT_RESERVATION_SEEK_TO_PREV,
294-
/* defaultValue= */ false)
295-
!= hadPrevReservation)
296-
|| (legacyExtras.getBoolean(
297-
MediaConstants.EXTRAS_KEY_SLOT_RESERVATION_SEEK_TO_NEXT,
298-
/* defaultValue= */ false)
299-
!= hadNextReservation);
300-
if (extrasChanged) {
301-
getSessionCompat().setExtras(legacyExtras);
302-
}
294+
updateCustomLayoutAndLegacyExtrasForMediaButtonPreferencesAndInformExtrasChanged();
303295
}
304296

305297
if (commandGetTimelineChanged) {
@@ -344,25 +336,7 @@ public void setPlatformCustomLayout(ImmutableList<CommandButton> customLayout) {
344336
public void setPlatformMediaButtonPreferences(
345337
ImmutableList<CommandButton> mediaButtonPreferences) {
346338
this.mediaButtonPreferences = mediaButtonPreferences;
347-
boolean hadPrevReservation =
348-
legacyExtras.getBoolean(
349-
MediaConstants.EXTRAS_KEY_SLOT_RESERVATION_SEEK_TO_PREV, /* defaultValue= */ false);
350-
boolean hadNextReservation =
351-
legacyExtras.getBoolean(
352-
MediaConstants.EXTRAS_KEY_SLOT_RESERVATION_SEEK_TO_NEXT, /* defaultValue= */ false);
353-
updateCustomLayoutAndLegacyExtrasForMediaButtonPreferences();
354-
boolean extrasChanged =
355-
(legacyExtras.getBoolean(
356-
MediaConstants.EXTRAS_KEY_SLOT_RESERVATION_SEEK_TO_PREV,
357-
/* defaultValue= */ false)
358-
!= hadPrevReservation)
359-
|| (legacyExtras.getBoolean(
360-
MediaConstants.EXTRAS_KEY_SLOT_RESERVATION_SEEK_TO_NEXT,
361-
/* defaultValue= */ false)
362-
!= hadNextReservation);
363-
if (extrasChanged) {
364-
getSessionCompat().setExtras(legacyExtras);
365-
}
339+
updateCustomLayoutAndLegacyExtrasForMediaButtonPreferencesAndInformExtrasChanged();
366340
}
367341

368342
/**
@@ -485,6 +459,9 @@ public void release() {
485459
if (runtimeBroadcastReceiver != null) {
486460
sessionImpl.getContext().unregisterReceiver(runtimeBroadcastReceiver);
487461
}
462+
if (androidAutoObserver != null) {
463+
androidAutoObserver.release();
464+
}
488465
// No check for COMMAND_RELEASE needed as MediaControllers can always be released.
489466
sessionCompat.release();
490467
}
@@ -1253,6 +1230,34 @@ private boolean isQueueEnabled() {
12531230
&& playerWrapper.getAvailableCommands().contains(Player.COMMAND_GET_TIMELINE);
12541231
}
12551232

1233+
private void onAndroidAutoConnectionStateChanged() {
1234+
postOrRun(
1235+
sessionImpl.getApplicationHandler(),
1236+
this::updateCustomLayoutAndLegacyExtrasForMediaButtonPreferencesAndInformExtrasChanged);
1237+
}
1238+
1239+
private void updateCustomLayoutAndLegacyExtrasForMediaButtonPreferencesAndInformExtrasChanged() {
1240+
boolean hadPrevReservation =
1241+
legacyExtras.getBoolean(
1242+
MediaConstants.EXTRAS_KEY_SLOT_RESERVATION_SEEK_TO_PREV, /* defaultValue= */ false);
1243+
boolean hadNextReservation =
1244+
legacyExtras.getBoolean(
1245+
MediaConstants.EXTRAS_KEY_SLOT_RESERVATION_SEEK_TO_NEXT, /* defaultValue= */ false);
1246+
updateCustomLayoutAndLegacyExtrasForMediaButtonPreferences();
1247+
boolean extrasChanged =
1248+
(legacyExtras.getBoolean(
1249+
MediaConstants.EXTRAS_KEY_SLOT_RESERVATION_SEEK_TO_PREV,
1250+
/* defaultValue= */ false)
1251+
!= hadPrevReservation)
1252+
|| (legacyExtras.getBoolean(
1253+
MediaConstants.EXTRAS_KEY_SLOT_RESERVATION_SEEK_TO_NEXT,
1254+
/* defaultValue= */ false)
1255+
!= hadNextReservation);
1256+
if (extrasChanged) {
1257+
getSessionCompat().setExtras(legacyExtras);
1258+
}
1259+
}
1260+
12561261
private void updateCustomLayoutAndLegacyExtrasForMediaButtonPreferences() {
12571262
ImmutableList<CommandButton> mediaButtonPreferencesWithUnavailableButtonsDisabled =
12581263
CommandButton.copyWithUnavailableButtonsDisabled(
@@ -1266,16 +1271,27 @@ private void updateCustomLayoutAndLegacyExtrasForMediaButtonPreferences() {
12661271
mediaButtonPreferencesWithUnavailableButtonsDisabled,
12671272
/* backSlotAllowed= */ true,
12681273
/* forwardSlotAllowed= */ true);
1269-
// If no custom back slot button is defined and other custom forward or overflow buttons exist,
1270-
// we need to reserve the back slot to prevent the other buttons from moving into this slot. The
1271-
// forward slot should never be reserved to avoid gaps in the output. We explicitly clear the
1272-
// value to avoid any manually defined extras to interfere with our logic.
1273-
boolean reserveBackSpaceSlot =
1274-
!customLayout.isEmpty()
1275-
&& !CommandButton.containsButtonForSlot(customLayout, CommandButton.SLOT_BACK);
1276-
legacyExtras.putBoolean(
1277-
MediaConstants.EXTRAS_KEY_SLOT_RESERVATION_SEEK_TO_PREV, reserveBackSpaceSlot);
1278-
legacyExtras.putBoolean(MediaConstants.EXTRAS_KEY_SLOT_RESERVATION_SEEK_TO_NEXT, false);
1274+
if (needsButtonReservationWorkaroundForSeekbar(androidAutoObserver)) {
1275+
// When applying the workaround, if no custom back slot button is defined and other custom
1276+
// forward or overflow buttons exist, we need to reserve the back slot to prevent the other
1277+
// buttons from moving into this slot. The forward slot should never be reserved to avoid gaps
1278+
// in the output. We explicitly clear the value to avoid any manually defined extras to
1279+
// interfere with our logic.
1280+
boolean reserveBackSpaceSlot =
1281+
!customLayout.isEmpty()
1282+
&& !CommandButton.containsButtonForSlot(customLayout, CommandButton.SLOT_BACK);
1283+
legacyExtras.putBoolean(
1284+
MediaConstants.EXTRAS_KEY_SLOT_RESERVATION_SEEK_TO_PREV, reserveBackSpaceSlot);
1285+
legacyExtras.putBoolean(MediaConstants.EXTRAS_KEY_SLOT_RESERVATION_SEEK_TO_NEXT, false);
1286+
} else {
1287+
// Without the workaround, set the reservations to match our actual slot definition.
1288+
legacyExtras.putBoolean(
1289+
MediaConstants.EXTRAS_KEY_SLOT_RESERVATION_SEEK_TO_PREV,
1290+
!CommandButton.containsButtonForSlot(customLayout, CommandButton.SLOT_BACK));
1291+
legacyExtras.putBoolean(
1292+
MediaConstants.EXTRAS_KEY_SLOT_RESERVATION_SEEK_TO_NEXT,
1293+
!CommandButton.containsButtonForSlot(customLayout, CommandButton.SLOT_FORWARD));
1294+
}
12791295
}
12801296

12811297
private static MediaItem createMediaItemForMediaRequest(
@@ -2112,6 +2128,35 @@ public void onAdjustVolume(int direction) {
21122128
};
21132129
}
21142130

2131+
private boolean needsButtonReservationWorkaroundForSeekbar(
2132+
@Nullable AndroidAutoConnectionStateObserver androidAutoObserver) {
2133+
// Check if the device is generally known to require the workaround. Also disable the workaround
2134+
// when connected to Android Auto under the assumption that it is the main user interface while
2135+
// connected. See https://github.com/androidx/media/issues/3041.
2136+
if (!mayNeedButtonReservationWorkaroundForSeekbar) {
2137+
return false;
2138+
}
2139+
return androidAutoObserver == null || !androidAutoObserver.isConnected();
2140+
}
2141+
2142+
private static boolean mayNeedButtonReservationWorkaroundForSeekbar(Context context) {
2143+
// The stock system UMO has an issue that when a navigation button is reserved, it doesn't
2144+
// automatically fill its empty space with an extended seek bar, leaving an unexpected gap.
2145+
// This affects all manufacturers known to rely on the stock UMO from API 33. See
2146+
// https://github.com/androidx/media/issues/2976.
2147+
if (SDK_INT < 33) {
2148+
return false;
2149+
}
2150+
if (Util.isAutomotive(context)) {
2151+
return false;
2152+
}
2153+
return Build.MANUFACTURER.equals("Google")
2154+
|| Build.MANUFACTURER.equals("motorola")
2155+
|| Build.MANUFACTURER.equals("vivo")
2156+
|| Build.MANUFACTURER.equals("Sony")
2157+
|| Build.MANUFACTURER.equals("Nothing");
2158+
}
2159+
21152160
/** Describes a legacy error. */
21162161
private static final class LegacyError {
21172162
public final boolean isFatal;

libraries/test_session_current/src/androidTest/java/androidx/media3/session/MediaControllerCompatPlaybackStateCompatActionsWithMediaSessionTest.java

Lines changed: 15 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616

1717
package androidx.media3.session;
1818

19+
import static android.os.Build.VERSION.SDK_INT;
1920
import static androidx.media3.test.session.common.TestUtils.TIMEOUT_MS;
2021
import static androidx.media3.test.utils.TestUtil.getEventsAsList;
2122
import static androidx.test.platform.app.InstrumentationRegistry.getInstrumentation;
@@ -2027,11 +2028,20 @@ public void onPlaybackStateChanged(PlaybackStateCompat state) {
20272028
androidx.media.utils.MediaConstants
20282029
.SESSION_EXTRAS_KEY_SLOT_RESERVATION_SKIP_TO_PREV))
20292030
.isTrue();
2030-
assertThat(
2031-
extras2.getBoolean(
2032-
androidx.media.utils.MediaConstants
2033-
.SESSION_EXTRAS_KEY_SLOT_RESERVATION_SKIP_TO_NEXT))
2034-
.isFalse();
2031+
if (SDK_INT >= 33) {
2032+
// Applies if the workaround to disable to next reservation for SysUI is enabled.
2033+
assertThat(
2034+
extras2.getBoolean(
2035+
androidx.media.utils.MediaConstants
2036+
.SESSION_EXTRAS_KEY_SLOT_RESERVATION_SKIP_TO_NEXT))
2037+
.isFalse();
2038+
} else {
2039+
assertThat(
2040+
extras2.getBoolean(
2041+
androidx.media.utils.MediaConstants
2042+
.SESSION_EXTRAS_KEY_SLOT_RESERVATION_SKIP_TO_NEXT))
2043+
.isTrue();
2044+
}
20352045
assertThat(actions2 & PlaybackStateCompat.ACTION_SKIP_TO_NEXT).isNotEqualTo(0);
20362046
assertThat(actions2 & PlaybackStateCompat.ACTION_SKIP_TO_PREVIOUS).isNotEqualTo(0);
20372047
}

0 commit comments

Comments
 (0)