From f267812d29bd7e483bb1b93d47b968267c01edb9 Mon Sep 17 00:00:00 2001 From: Hans-Peter Lehmann Date: Wed, 15 Jul 2026 18:32:28 +0200 Subject: [PATCH] Fix android auto voice commands in media3 playback service (#8605) ### Description Fix android auto voice commands in media3 playback service. I hope this solves the Google Play rejection of new beta versions. ### Checklist - [x] I have read the contribution guidelines: https://github.com/AntennaPod/AntennaPod/blob/develop/CONTRIBUTING.md#submit-a-pull-request - [x] I have performed a self-review of my code, going through my changes line by line and carefully considering why this line change is necessary - [x] I have run the automated code checks using `./gradlew checkstyle lint` - [x] My code follows the style guidelines of the AntennaPod project: https://antennapod.org/contribute/develop/app/code-style - [x] I have mentioned the corresponding issue and the relevant keyword (e.g., "Closes: #xy") in the description (see https://docs.github.com/en/issues/tracking-your-work-with-issues/linking-a-pull-request-to-an-issue) - [x] If it is a core feature, I have added automated tests --- .../internal/MediaLibrarySessionCallback.java | 45 +++++++++++++++++++ .../MediaLibrarySessionCallbackTest.java | 30 +++++++++++++ 2 files changed, 75 insertions(+) diff --git a/playback/service/src/main/java/de/danoeh/antennapod/playback/service/internal/MediaLibrarySessionCallback.java b/playback/service/src/main/java/de/danoeh/antennapod/playback/service/internal/MediaLibrarySessionCallback.java index 89f8c44c1..ec8751f43 100644 --- a/playback/service/src/main/java/de/danoeh/antennapod/playback/service/internal/MediaLibrarySessionCallback.java +++ b/playback/service/src/main/java/de/danoeh/antennapod/playback/service/internal/MediaLibrarySessionCallback.java @@ -40,6 +40,7 @@ import de.danoeh.antennapod.storage.preferences.PlaybackPreferences; import de.danoeh.antennapod.storage.preferences.UserPreferences; import de.danoeh.antennapod.event.playback.SleepTimerUpdatedEvent; import org.greenrobot.eventbus.EventBus; +import io.reactivex.rxjava3.core.Maybe; import io.reactivex.rxjava3.core.Single; import io.reactivex.rxjava3.schedulers.Schedulers; @@ -259,6 +260,38 @@ public class MediaLibrarySessionCallback implements MediaLibraryService.MediaLib return Futures.immediateFuture(new MediaSession.MediaItemsWithStartPosition( mediaItems, index, startPositionMs)); } + String searchQuery = mediaItems.get(index).requestMetadata.searchQuery; + if (searchQuery != null) { + if ("".equals(searchQuery)) { + return onPlaybackResumption(mediaSession, controller); // "Play something" voice action + } + SettableFuture future = SettableFuture.create(); + Maybe.fromCallable(() -> { + List results = DBReader.searchFeedItems(0, searchQuery, FeedItemFilter.unfiltered()); + for (FeedItem result : results) { + if (result.getMedia() != null) { + return result.getMedia(); + } + } + return null; + }) + .subscribeOn(Schedulers.io()) + .subscribe(media -> { + long startPosition = SkipUtils.skipIntroIfNecessary(context, media); + startPosition = RewindAfterPauseUtils.calculatePositionWithRewind( + (int) startPosition, media.getLastPlayedTimeStatistics()); + future.set(new MediaSession.MediaItemsWithStartPosition( + Collections.singletonList(MediaItemAdapter.fromPlayable(context, media, false)), + 0, startPosition)); + }, error -> { + Log.e(TAG, "Voice search failed", error); + future.set(new MediaSession.MediaItemsWithStartPosition( + Collections.emptyList(), index, startPositionMs)); + }, + () -> future.set(new MediaSession.MediaItemsWithStartPosition( + Collections.emptyList(), index, startPositionMs))); + return future; + } SettableFuture future = SettableFuture.create(); Single.fromCallable( () -> { @@ -473,6 +506,18 @@ public class MediaLibrarySessionCallback implements MediaLibraryService.MediaLib public ListenableFuture> onSearch(@NonNull MediaLibraryService.MediaLibrarySession session, @NonNull MediaSession.ControllerInfo browser, @NonNull String query, @Nullable MediaLibraryService.LibraryParams params) { + if (query.isEmpty()) { + session.notifySearchResultChanged(browser, query, 0, params); + return Futures.immediateFuture(LibraryResult.ofVoid()); + } + Single.fromCallable(() -> DBReader.searchFeedItems(0, query, FeedItemFilter.unfiltered())) + .subscribeOn(Schedulers.io()) + .subscribe( + items -> session.notifySearchResultChanged(browser, query, items.size(), params), + error -> { + Log.e(TAG, "Search failed", error); + session.notifySearchResultChanged(browser, query, 0, params); + }); return Futures.immediateFuture(LibraryResult.ofVoid()); } diff --git a/playback/service/src/test/java/de/danoeh/antennapod/playback/service/internal/MediaLibrarySessionCallbackTest.java b/playback/service/src/test/java/de/danoeh/antennapod/playback/service/internal/MediaLibrarySessionCallbackTest.java index 7d907c464..e5395b838 100644 --- a/playback/service/src/test/java/de/danoeh/antennapod/playback/service/internal/MediaLibrarySessionCallbackTest.java +++ b/playback/service/src/test/java/de/danoeh/antennapod/playback/service/internal/MediaLibrarySessionCallbackTest.java @@ -74,6 +74,36 @@ public class MediaLibrarySessionCallbackTest { assertEquals(String.valueOf(media.getId()), result.mediaItems.get(0).mediaId); } + @Test + public void onAndroidAutoVoiceSearchQuery() throws Exception { + long mediaId = seedEpisode().getId(); + MediaItem searchItem = MediaItem.EMPTY.buildUpon() + .setRequestMetadata(new MediaItem.RequestMetadata.Builder().setSearchQuery(EPISODE_TITLE).build()) + .build(); + MediaSession.MediaItemsWithStartPosition result = callback.onSetMediaItems(session, controllerInfo, + Collections.singletonList(searchItem), C.INDEX_UNSET, C.TIME_UNSET).get(5, TimeUnit.SECONDS); + assertEquals(1, result.mediaItems.size()); + assertEquals(String.valueOf(mediaId), result.mediaItems.get(0).mediaId); + + // No match: nothing to play + searchItem = MediaItem.EMPTY.buildUpon() + .setRequestMetadata(new MediaItem.RequestMetadata.Builder().setSearchQuery("Unrelated").build()) + .build(); + result = callback.onSetMediaItems(session, controllerInfo, + Collections.singletonList(searchItem), C.INDEX_UNSET, C.TIME_UNSET).get(5, TimeUnit.SECONDS); + assertEquals(0, result.mediaItems.size()); + + // Empty query ("play something"): fall back to playing something rather than nothing, per + // Android Auto/Assistant voice action guidelines. + searchItem = MediaItem.EMPTY.buildUpon() + .setRequestMetadata(new MediaItem.RequestMetadata.Builder().setSearchQuery("").build()) + .build(); + result = callback.onSetMediaItems(session, controllerInfo, + Collections.singletonList(searchItem), C.INDEX_UNSET, C.TIME_UNSET).get(5, TimeUnit.SECONDS); + assertEquals(1, result.mediaItems.size()); + assertEquals(String.valueOf(mediaId), result.mediaItems.get(0).mediaId); + } + private FeedMedia seedEpisode() { Feed feed = new Feed("url", null, null); feed.setItems(new ArrayList<>());