• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
1 /*
2  * Copyright (C) 2024 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 
17 package android.media.router.cts;
18 
19 import static android.media.MediaRoute2Info.FLAG_ROUTING_TYPE_REMOTE;
20 import static android.media.MediaRoute2Info.FLAG_ROUTING_TYPE_SYSTEM_AUDIO;
21 import static android.media.MediaRoute2Info.PLAYBACK_VOLUME_VARIABLE;
22 
23 import android.media.AudioFormat;
24 import android.media.AudioRecord;
25 import android.media.MediaRoute2Info;
26 import android.media.MediaRoute2ProviderService;
27 import android.media.RouteDiscoveryPreference;
28 import android.media.RoutingSessionInfo;
29 import android.os.Bundle;
30 import android.os.Handler;
31 import android.os.HandlerThread;
32 import android.text.TextUtils;
33 
34 import androidx.annotation.NonNull;
35 import androidx.annotation.Nullable;
36 
37 import com.android.media.flags.Flags;
38 
39 import java.util.ArrayList;
40 import java.util.HashMap;
41 import java.util.Map;
42 import java.util.Set;
43 import java.util.concurrent.atomic.AtomicInteger;
44 
45 import javax.annotation.concurrent.GuardedBy;
46 
47 /**
48  * {@link MediaRoute2ProviderService} implementation that serves routes that support system media.
49  *
50  * @see MediaRoute2Info#getSupportedRoutingTypes()
51  * @see MediaRoute2ProviderService#onCreateSystemRoutingSession
52  */
53 public class SystemMediaRoutingProviderService extends MediaRoute2ProviderService {
54 
55     /** The format to pass to {@link #notifySystemRoutingSessionCreated}. */
56     private static final AudioFormat AUDIO_FORMAT_RECORDING =
57             new AudioFormat.Builder()
58                     .setSampleRate(44100 /*Hz*/)
59                     .setEncoding(AudioFormat.ENCODING_PCM_16BIT)
60                     .setChannelMask(AudioFormat.CHANNEL_OUT_STEREO)
61                     .build();
62 
63     public static final String FEATURE_SAMPLE = "android.media.router.cts.FEATURE_SAMPLE";
64     public static final String ROUTE_ID_ONLY_SYSTEM_AUDIO_TRANSFERABLE_1 =
65             "ROUTE_ID_ONLY_SYSTEM_AUDIO_TRANSFERABLE_1";
66     public static final String ROUTE_ID_ONLY_SYSTEM_AUDIO_TRANSFERABLE_2 =
67             "ROUTE_ID_ONLY_SYSTEM_AUDIO_TRANSFERABLE_2";
68     public static final String ROUTE_ID_ONLY_REMOTE = "ROUTE_ID_ONLY_REMOTE";
69     public static final String ROUTE_ID_BOTH_SYSTEM_AND_REMOTE = "ROUTE_ID_BOTH_SYSTEM_AND_REMOTE";
70     public static final String ROUTE_ID_ONLY_SYSTEM_AUDIO_SELECTABLE =
71             "ROUTE_ID_ONLY_SYSTEM_AUDIO_GROUPABLE";
72     public static final int VOLUME_MAX = 100;
73     public static final int INITIAL_VOLUME = 50;
74     private static final String ROUTING_SESSION_ID = "ROUTING_SESSION_ID";
75 
76     /**
77      * Holds the ids of the routes from this provider that support transferring to each other.
78      *
79      * @see RoutingSessionInfo#getTransferableRoutes()
80      */
81     private static final Set<String> TRANSFERABLE_ROUTES =
82             Set.of(
83                     ROUTE_ID_ONLY_SYSTEM_AUDIO_TRANSFERABLE_1,
84                     ROUTE_ID_ONLY_SYSTEM_AUDIO_TRANSFERABLE_2);
85 
86     private static final Object sLock = new Object();
87 
88     /** Maps route ids to routes. */
89     @GuardedBy("sLock")
90     private final Map<String, MediaRoute2Info> mRoutes = new HashMap<>();
91 
92     private HandlerThread mHandlerThread;
93     private Handler mHandler;
94     private final byte[] mBuffer = new byte[1024 * 100];
95 
96     @GuardedBy("sLock")
97     private RoutingSessionInfo mCurrentRoutingSession = null;
98 
99     private final AtomicInteger mNoisyByteCount = new AtomicInteger(0);
100 
101     @GuardedBy("sLock")
102     private static SystemMediaRoutingProviderService sInstance;
103 
104     /** Returns the currently running service instance, or null if the service is not running. */
getInstance()105     public static SystemMediaRoutingProviderService getInstance() {
106         synchronized (sLock) {
107             return sInstance;
108         }
109     }
110 
111     /**
112      * Returns the {@link MediaRoute2Info#getOriginalId() original id} of the currently selected
113      * route, or null if there's no selected route at the moment.
114      */
115     @Nullable
getSelectedRouteOriginalId()116     public String getSelectedRouteOriginalId() {
117         synchronized (sLock) {
118             return mCurrentRoutingSession != null
119                     ? mCurrentRoutingSession.getSelectedRoutes().getFirst()
120                     : null;
121         }
122     }
123 
124     /**
125      * Returns the number of non-zero bytes read from audio record since the {@link
126      * #getSelectedRouteOriginalId currently selected route} became selected.
127      */
getNoisyBytesCount()128     public int getNoisyBytesCount() {
129         return mNoisyByteCount.get();
130     }
131 
132     @Override
onCreate()133     public void onCreate() {
134         super.onCreate();
135         mHandlerThread = new HandlerThread(getClass().getSimpleName());
136         mHandlerThread.start();
137         mHandler = new Handler(mHandlerThread.getLooper());
138         mNoisyByteCount.set(0);
139         synchronized (sLock) {
140             sInstance = this;
141         }
142     }
143 
144     @Override
onDestroy()145     public void onDestroy() {
146         synchronized (sLock) {
147             sInstance = null;
148         }
149         mHandlerThread.quitSafely();
150         super.onDestroy();
151     }
152 
153     @Override
onSetRouteVolume(long requestId, @NonNull String routeId, int volume)154     public void onSetRouteVolume(long requestId, @NonNull String routeId, int volume) {
155         synchronized (sLock) {
156             var route = mRoutes.get(routeId);
157             if (route == null
158                     || mCurrentRoutingSession == null
159                     || !mCurrentRoutingSession.getSelectedRoutes().contains(routeId)) {
160                 notifyRequestFailed(requestId, REASON_ROUTE_NOT_AVAILABLE);
161                 return;
162             }
163             MediaRoute2Info newRoute = new MediaRoute2Info.Builder(route).setVolume(volume).build();
164             mRoutes.put(routeId, newRoute);
165             notifyRoutes(mRoutes.values());
166         }
167     }
168 
169     @Override
onSetSessionVolume(long requestId, @NonNull String sessionId, int volume)170     public void onSetSessionVolume(long requestId, @NonNull String sessionId, int volume) {
171         synchronized (sLock) {
172             if (mCurrentRoutingSession == null
173                     || !mCurrentRoutingSession.getOriginalId().equals(sessionId)) {
174                 notifyRequestFailed(requestId, REASON_INVALID_COMMAND);
175                 return;
176             }
177             mCurrentRoutingSession =
178                     new RoutingSessionInfo.Builder(mCurrentRoutingSession)
179                             .setVolume(volume)
180                             .build();
181             notifySessionUpdated(mCurrentRoutingSession);
182         }
183     }
184 
185     @Override
onCreateSession( long requestId, @NonNull String packageName, @NonNull String routeId, @Nullable Bundle sessionHints)186     public void onCreateSession(
187             long requestId,
188             @NonNull String packageName,
189             @NonNull String routeId,
190             @Nullable Bundle sessionHints) {
191         throw new IllegalStateException(
192                 "Unexpected: This provider only expects system-media routing.");
193     }
194 
195     @Override
onReleaseSession(long requestId, @NonNull String sessionId)196     public void onReleaseSession(long requestId, @NonNull String sessionId) {
197         mHandler.post(() -> releaseSessionOnHandler(sessionId));
198     }
199 
200     @Override
onSelectRoute(long requestId, @NonNull String sessionId, @NonNull String routeId)201     public void onSelectRoute(long requestId, @NonNull String sessionId, @NonNull String routeId) {
202         synchronized (sLock) {
203             if (mCurrentRoutingSession == null
204                     || !mCurrentRoutingSession.getSelectableRoutes().contains(routeId)) {
205                 notifyRequestFailed(requestId, REASON_INVALID_COMMAND);
206                 return;
207             }
208             if (!mRoutes.containsKey(routeId)) {
209                 notifyRequestFailed(requestId, REASON_ROUTE_NOT_AVAILABLE);
210                 return;
211             }
212             mCurrentRoutingSession =
213                     new RoutingSessionInfo.Builder(mCurrentRoutingSession)
214                             .removeSelectableRoute(routeId)
215                             .addSelectedRoute(routeId)
216                             .addDeselectableRoute(routeId)
217                             .build();
218             notifySessionUpdated(mCurrentRoutingSession);
219         }
220     }
221 
222     @Override
onDeselectRoute( long requestId, @NonNull String sessionId, @NonNull String routeId)223     public void onDeselectRoute(
224             long requestId, @NonNull String sessionId, @NonNull String routeId) {
225         synchronized (sLock) {
226             if (mCurrentRoutingSession == null
227                     || !mCurrentRoutingSession.getDeselectableRoutes().contains(routeId)) {
228                 notifyRequestFailed(requestId, REASON_INVALID_COMMAND);
229                 return;
230             }
231             if (!mRoutes.containsKey(routeId)) {
232                 notifyRequestFailed(requestId, REASON_ROUTE_NOT_AVAILABLE);
233                 return;
234             }
235             var selectedRoutes = mCurrentRoutingSession.getSelectedRoutes();
236             if (!selectedRoutes.contains(routeId) || selectedRoutes.size() == 1) {
237                 // We don't expect this condition to be ever true, as that would mean that there's
238                 // an implementation error in this provider. But we add this check for robustness.
239                 notifyRequestFailed(requestId, REASON_UNKNOWN_ERROR);
240                 return;
241             }
242             mCurrentRoutingSession =
243                     new RoutingSessionInfo.Builder(mCurrentRoutingSession)
244                             .removeSelectedRoute(routeId)
245                             .removeDeselectableRoute(routeId)
246                             .addSelectableRoute(routeId)
247                             .build();
248             notifySessionUpdated(mCurrentRoutingSession);
249         }
250     }
251 
252     @Override
onTransferToRoute( long requestId, @NonNull String sessionId, @NonNull String routeId)253     public void onTransferToRoute(
254             long requestId, @NonNull String sessionId, @NonNull String routeId) {
255         synchronized (sLock) {
256             if (mCurrentRoutingSession == null
257                     || !TextUtils.equals(mCurrentRoutingSession.getOriginalId(), sessionId)) {
258                 // Unexpected call to transfer or invalid id received.
259                 notifyRequestFailed(requestId, REASON_INVALID_COMMAND);
260                 return;
261             }
262             if (!mRoutes.containsKey(routeId)) {
263                 notifyRequestFailed(requestId, REASON_ROUTE_NOT_AVAILABLE);
264                 return;
265             }
266             String clientPackageName = mCurrentRoutingSession.getClientPackageName();
267             mHandler.post(() -> transferToRouteOnHandler(clientPackageName, routeId));
268         }
269     }
270 
271     @Override
onCreateSystemRoutingSession( long requestId, @NonNull String routeId, @NonNull SystemRoutingSessionParams parameters)272     public void onCreateSystemRoutingSession(
273             long requestId,
274             @NonNull String routeId,
275             @NonNull SystemRoutingSessionParams parameters) {
276         var routingSession =
277                 createRoutingSession(parameters.getPackageName(), /* selectedRouteId= */ routeId);
278         var streams =
279                 notifySystemRoutingSessionCreated(
280                         requestId,
281                         routingSession,
282                         new MediaStreamsFormats.Builder()
283                                 .setAudioFormat(AUDIO_FORMAT_RECORDING)
284                                 .build());
285         var audioRecord = streams.getAudioRecord();
286         if (audioRecord != null) {
287             audioRecord.startRecording();
288             mNoisyByteCount.set(0);
289             synchronized (sLock) {
290                 mCurrentRoutingSession = routingSession;
291             }
292             mHandler.post(() -> readFromAudioRecordOnHandler(audioRecord));
293         }
294     }
295 
296     /** Creates a routing session with a single given selected route. */
createRoutingSession( String clientPackageName, String selectedRouteId)297     private static RoutingSessionInfo createRoutingSession(
298             String clientPackageName, String selectedRouteId) {
299         boolean isInTransferableRoutes = TRANSFERABLE_ROUTES.contains(selectedRouteId);
300         var transferableRoutes =
301                 new ArrayList<>(isInTransferableRoutes ? TRANSFERABLE_ROUTES : Set.of());
302         // A route should not be transferable and also selected.
303         transferableRoutes.remove(selectedRouteId);
304         var sessionBuilder =
305                 new RoutingSessionInfo.Builder(ROUTING_SESSION_ID, clientPackageName)
306                         .setVolumeHandling(PLAYBACK_VOLUME_VARIABLE)
307                         .setVolumeMax(VOLUME_MAX)
308                         .setVolume(INITIAL_VOLUME)
309                         .addSelectedRoute(selectedRouteId);
310         if (!selectedRouteId.equals(ROUTE_ID_ONLY_SYSTEM_AUDIO_SELECTABLE)) {
311             sessionBuilder.addSelectableRoute(ROUTE_ID_ONLY_SYSTEM_AUDIO_SELECTABLE);
312         }
313         transferableRoutes.forEach(sessionBuilder::addTransferableRoute);
314         return sessionBuilder.build();
315     }
316 
317     @Override
onDiscoveryPreferenceChanged(@onNull RouteDiscoveryPreference preference)318     public void onDiscoveryPreferenceChanged(@NonNull RouteDiscoveryPreference preference) {
319         synchronized (sLock) {
320             if (preference.shouldPerformActiveScan()) {
321                 populateRoutes();
322             } else if (mCurrentRoutingSession == null) {
323                 mRoutes.clear();
324             } else {
325                 // We must keep routes that are in a routing session, even while not scanning.
326                 mRoutes.keySet()
327                         .removeIf(it -> !mCurrentRoutingSession.getSelectedRoutes().contains(it));
328             }
329             notifyRoutes(mRoutes.values());
330         }
331     }
332 
333     @GuardedBy("sLock")
populateRoutes()334     private void populateRoutes() {
335         if (!Flags.enableMirroringInMediaRouter2()) {
336             return;
337         }
338         synchronized (sLock) {
339             mRoutes.clear();
340             registerRoute(
341                     ROUTE_ID_ONLY_SYSTEM_AUDIO_TRANSFERABLE_1, FLAG_ROUTING_TYPE_SYSTEM_AUDIO);
342             registerRoute(
343                     ROUTE_ID_ONLY_SYSTEM_AUDIO_TRANSFERABLE_2, FLAG_ROUTING_TYPE_SYSTEM_AUDIO);
344             registerRoute(
345                     ROUTE_ID_BOTH_SYSTEM_AND_REMOTE,
346                     FLAG_ROUTING_TYPE_SYSTEM_AUDIO | FLAG_ROUTING_TYPE_REMOTE);
347             registerRoute(ROUTE_ID_ONLY_REMOTE, FLAG_ROUTING_TYPE_REMOTE);
348             registerRoute(ROUTE_ID_ONLY_SYSTEM_AUDIO_SELECTABLE, FLAG_ROUTING_TYPE_SYSTEM_AUDIO);
349         }
350     }
351 
352     /**
353      * Creates and registers a route with the given properties.
354      *
355      * <p>For convenience we use the id as the name of the route as well. We don't need a human
356      * readable string for the test route names.
357      */
358     @GuardedBy("sLock")
registerRoute( String idAndName, @MediaRoute2Info.RoutingType int supportedRoutingTypes)359     private void registerRoute(
360             String idAndName, @MediaRoute2Info.RoutingType int supportedRoutingTypes) {
361         var route =
362                 new MediaRoute2Info.Builder(/* id= */ idAndName, /* name= */ idAndName)
363                         .addFeature(FEATURE_SAMPLE)
364                         .setVolumeHandling(PLAYBACK_VOLUME_VARIABLE)
365                         .setVolumeMax(VOLUME_MAX)
366                         .setVolume(INITIAL_VOLUME)
367                         .setSupportedRoutingTypes(supportedRoutingTypes)
368                         .setDeduplicationIds(Set.of(idAndName))
369                         .build();
370         // We only put it if not already there, to avoid overriding a "mutable" property, such as
371         // volume.
372         mRoutes.putIfAbsent(route.getOriginalId(), route);
373     }
374 
transferToRouteOnHandler(String packageName, String routeId)375     private void transferToRouteOnHandler(String packageName, String routeId) {
376         var updatedSession = createRoutingSession(packageName, routeId);
377         synchronized (sLock) {
378             mCurrentRoutingSession = updatedSession;
379         }
380         mNoisyByteCount.set(0);
381         notifySessionUpdated(updatedSession);
382     }
383 
readFromAudioRecordOnHandler(AudioRecord audioRecord)384     private void readFromAudioRecordOnHandler(AudioRecord audioRecord) {
385         int bytesRead =
386                 audioRecord.read(
387                         mBuffer,
388                         /* offsetInBytes= */ 0,
389                         mBuffer.length,
390                         AudioRecord.READ_NON_BLOCKING);
391         for (int i = 0; i < bytesRead; i++) {
392             if (mBuffer[i] != 0) {
393                 mNoisyByteCount.incrementAndGet();
394             }
395         }
396         mHandler.post(() -> readFromAudioRecordOnHandler(audioRecord));
397     }
398 
releaseSessionOnHandler(String sessionId)399     private void releaseSessionOnHandler(String sessionId) {
400         mHandler.removeCallbacksAndMessages(/* token= */ null);
401         synchronized (sLock) {
402             mCurrentRoutingSession = null;
403         }
404         mNoisyByteCount.set(0);
405         notifySessionReleased(sessionId);
406     }
407 }
408