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