1 /* 2 * Copyright (C) 2023 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 com.google.android.car.kitchensink.audio; 18 19 import static android.R.layout.simple_spinner_dropdown_item; 20 import static android.R.layout.simple_spinner_item; 21 import static android.car.CarOccupantZoneManager.OCCUPANT_TYPE_DRIVER; 22 import static android.car.media.CarAudioManager.AUDIO_FEATURE_AUDIO_MIRRORING; 23 import static android.car.media.CarAudioManager.INVALID_REQUEST_ID; 24 25 import android.car.Car; 26 import android.car.CarOccupantZoneManager; 27 import android.car.CarOccupantZoneManager.OccupantZoneInfo; 28 import android.car.media.CarAudioManager; 29 import android.car.media.MediaAudioRequestStatusCallback; 30 import android.car.media.PrimaryZoneMediaAudioRequestCallback; 31 import android.content.Context; 32 import android.os.Bundle; 33 import android.os.UserHandle; 34 import android.os.UserManager; 35 import android.util.ArrayMap; 36 import android.util.Log; 37 import android.view.LayoutInflater; 38 import android.view.View; 39 import android.view.ViewGroup; 40 import android.widget.AdapterView; 41 import android.widget.ArrayAdapter; 42 import android.widget.Button; 43 import android.widget.RadioGroup; 44 import android.widget.Spinner; 45 import android.widget.Toast; 46 47 import androidx.core.content.ContextCompat; 48 import androidx.fragment.app.Fragment; 49 50 import com.google.android.car.kitchensink.R; 51 52 import java.util.ArrayList; 53 import java.util.List; 54 55 public final class AudioUserAssignmentFragment extends Fragment { 56 57 public static final String FRAGMENT_NAME = "Play User Audio In Primary Zone"; 58 private static final String TAG = AudioUserAssignmentFragment.class.getSimpleName(); 59 60 private final ArrayMap<Long, OccupantZoneInfo> mRequestIdToOccupantZone = 61 new ArrayMap<>(); 62 private Context mContext; 63 private CarAudioManager mCarAudioManager; 64 private CarOccupantZoneManager mCarOccupantZoneManager; 65 private Spinner mUserSpinner; 66 private ArrayAdapter<Integer> mUserAdapter; 67 private Button mToggleUserAssignButton; 68 69 private PrimaryZoneMediaAudioRequestCallback mPrimaryZoneMediaAudioRequestCallback = 70 new PrimaryZoneMediaAudioRequestCallback() { 71 @Override 72 public void onRequestMediaOnPrimaryZone(OccupantZoneInfo info, long requestId) { 73 handleRequestMediaOnPrimaryZone(info, requestId); 74 } 75 76 @Override 77 public void onMediaAudioRequestStatusChanged(OccupantZoneInfo info, long requestId, 78 @CarAudioManager.MediaAudioRequestStatus int status) { 79 if (status == CarAudioManager.AUDIO_REQUEST_STATUS_CANCELLED) { 80 handleResetMediaOnPrimaryZone(info, requestId); 81 } 82 } 83 }; 84 85 private MediaAudioRequestStatusCallback mCallback = (info, requestId, status) -> { 86 Log.d(TAG, "onMediaAudioRequestStatusChanged request " + requestId 87 + " for occupant " + info + " status " + status); 88 if (status == CarAudioManager.AUDIO_REQUEST_STATUS_APPROVED) { 89 handleRequestAccepted(info); 90 return; 91 } 92 93 handleRequestRejected(info); 94 mRequestIdToOccupantZone.remove(requestId); 95 }; 96 private AcceptAudioDialog mAcceptDialog; 97 private RadioGroup mAssignAudioRadioGroup; 98 private boolean mIsPrimaryZoneMediaCallbackSet; 99 handleResetMediaOnPrimaryZone(OccupantZoneInfo info, long requestId)100 private void handleResetMediaOnPrimaryZone(OccupantZoneInfo info, 101 long requestId) { 102 Log.v(TAG, "handleResetMediaOnPrimaryZone request " + requestId + " for occupant " 103 + info + " reset"); 104 if (mAcceptDialog == null) { 105 return; 106 } 107 mAcceptDialog.dismiss(); 108 mAcceptDialog = null; 109 } 110 handleRequestMediaOnPrimaryZone(OccupantZoneInfo info, long requestId)111 private void handleRequestMediaOnPrimaryZone(OccupantZoneInfo info, 112 long requestId) { 113 Log.v(TAG, "handleRequestMediaOnPrimaryZone request " + requestId + " allowed to play"); 114 115 int currentSelectionRule = mAssignAudioRadioGroup.getCheckedRadioButtonId(); 116 switch (currentSelectionRule) { 117 case R.id.assign_audio_reject_audio_button: 118 case R.id.assign_audio_accept_audio_button: 119 boolean allow = currentSelectionRule == R.id.assign_audio_accept_audio_button; 120 handleAllowAudioInPrimaryZone(requestId, allow); 121 return; 122 case R.id.assign_audio_ask_user_button: 123 default: 124 // Fall through to ask user 125 } 126 127 mAcceptDialog = AcceptAudioDialog.newInstance( 128 (allowed) -> handleAllowAudioInPrimaryZone(requestId, allowed), getUserName(info)); 129 mAcceptDialog.show(getActivity().getSupportFragmentManager(), 130 "fragment_accept_audio_playback"); 131 } 132 getUserName(OccupantZoneInfo info)133 private String getUserName(OccupantZoneInfo info) { 134 int userId = mCarOccupantZoneManager.getUserForOccupant(info); 135 Context userContext = getContext() 136 .createContextAsUser(UserHandle.of(userId), /* flags= */ 0); 137 138 UserManager userManager = userContext.getSystemService(UserManager.class); 139 return userManager.getUserName(); 140 } 141 handleAllowAudioInPrimaryZone(long requestId, boolean allowed)142 private void handleAllowAudioInPrimaryZone(long requestId, boolean allowed) { 143 Log.v(TAG, "handleAllowAudioInPrimaryZone request allowed " + allowed); 144 mCarAudioManager.allowMediaAudioOnPrimaryZone(requestId, allowed); 145 mAcceptDialog = null; 146 } 147 148 149 @Override onCreateView(LayoutInflater inflater, ViewGroup container, Bundle bundle)150 public View onCreateView(LayoutInflater inflater, ViewGroup container, Bundle bundle) { 151 Log.i(TAG, "onCreateView"); 152 View view = inflater 153 .inflate(R.layout.assign_user_to_primary_audio_zone, container, 154 /* attachToRoot= */ false); 155 mUserSpinner = view.findViewById(R.id.user_spinner); 156 mUserSpinner.setOnItemSelectedListener(new AdapterView.OnItemSelectedListener() { 157 @Override 158 public void onItemSelected(AdapterView<?> parent, View view, int pos, long id) { 159 handleUserSelection(); 160 } 161 162 @Override 163 public void onNothingSelected(AdapterView<?> parent) { 164 } 165 }); 166 167 mToggleUserAssignButton = view.findViewById(R.id.user_assign_to_main_zone_button); 168 mToggleUserAssignButton.setOnClickListener(v -> handleToggleAssignUserAudio()); 169 170 Button resetUser = view.findViewById(R.id.user_reset_from_main_zone_button); 171 resetUser.setOnClickListener(v -> handleResetUserAudio()); 172 173 mAssignAudioRadioGroup = view.findViewById(R.id.assign_audio_radio_group); 174 175 connectCar(); 176 177 return view; 178 } 179 handleResetUserAudio()180 private void handleResetUserAudio() { 181 Log.d(TAG, "handleResetUserAudio"); 182 int position = mUserSpinner.getSelectedItemPosition(); 183 int userId = mUserAdapter.getItem(position); 184 OccupantZoneInfo info = getOccupantZoneForUser(userId); 185 186 mCarAudioManager.resetMediaAudioOnPrimaryZone(info); 187 } 188 handleToggleAssignUserAudio()189 private void handleToggleAssignUserAudio() { 190 Log.d(TAG, "handleToggleAssignUserAudio"); 191 int position = mUserSpinner.getSelectedItemPosition(); 192 int userId = mUserAdapter.getItem(position); 193 OccupantZoneInfo info = getOccupantZoneForUser(userId); 194 195 if (mRequestIdToOccupantZone.containsValue(info)) { 196 handleCancelMediaAudioOnPrimaryZone(info); 197 return; 198 } 199 200 handleRequestUserToPlayInMainCabin(userId); 201 } 202 handleRequestUserToPlayInMainCabin(int userId)203 private void handleRequestUserToPlayInMainCabin(int userId) { 204 Log.d(TAG, "requestUserToPlayInMainCabin"); 205 OccupantZoneInfo info = getOccupantZoneForUser(userId); 206 if (info == null) { 207 Log.e(TAG, "Can not find occupant zone info for user" + userId); 208 showToast("User " + userId + " is not currently assigned to any occupant zone"); 209 return; 210 } 211 212 if (mCarAudioManager.isMediaAudioAllowedInPrimaryZone(info)) { 213 showToast("User " + userId + " is already allowed to play in primary zone"); 214 return; 215 } 216 217 int carAudioZoneId = mCarOccupantZoneManager.getAudioZoneIdForOccupant(info); 218 219 if (mCarAudioManager.isAudioFeatureEnabled(AUDIO_FEATURE_AUDIO_MIRRORING) 220 && !mCarAudioManager.getMirrorAudioZonesForAudioZone(carAudioZoneId).isEmpty()) { 221 showToast("Can not enable primary zone playback as user " + userId 222 + " is currently mirroring with another zone"); 223 return; 224 } 225 226 requestToPlayAudioInPrimaryZone(info); 227 } 228 requestToPlayAudioInPrimaryZone(OccupantZoneInfo info)229 private void requestToPlayAudioInPrimaryZone(OccupantZoneInfo info) { 230 Log.d(TAG, "requestUserToPlayInMainCabin occupant " + info); 231 long requestId; 232 try { 233 requestId = mCarAudioManager.requestMediaAudioOnPrimaryZone(info, 234 ContextCompat.getMainExecutor(getActivity().getApplicationContext()), 235 mCallback); 236 } catch (Exception e) { 237 showToast("Error while requesting media playback: " + e.getMessage()); 238 return; 239 } 240 if (requestId == INVALID_REQUEST_ID) { 241 handleRequestRejected(info); 242 return; 243 } 244 245 mRequestIdToOccupantZone.put(requestId, info); 246 mToggleUserAssignButton.setText(R.string.cancel_request); 247 } 248 handleRequestRejected(OccupantZoneInfo info)249 private void handleRequestRejected(OccupantZoneInfo info) { 250 if (!mRequestIdToOccupantZone.containsValue(info)) { 251 Log.d(TAG, "handleRequestRejected info " + info + " has no pending request"); 252 return; 253 } 254 mToggleUserAssignButton.setText(R.string.assign_user); 255 256 AudioPlaybackRequestResults dialog = 257 AudioPlaybackRequestResults.newInstance(getUserName(info), /* allowed= */ false); 258 dialog.show(getActivity().getSupportFragmentManager(), "rejected_results"); 259 260 } 261 handleRequestAccepted(OccupantZoneInfo info)262 private void handleRequestAccepted(OccupantZoneInfo info) { 263 AudioPlaybackRequestResults dialog = 264 AudioPlaybackRequestResults.newInstance(getUserName(info), /* allowed= */ true); 265 mToggleUserAssignButton.setText(R.string.unassign_user); 266 dialog.show(getActivity().getSupportFragmentManager(), "allowed_results"); 267 } 268 getOccupantZoneForUser(int userId)269 private OccupantZoneInfo getOccupantZoneForUser(int userId) { 270 List<OccupantZoneInfo> occupants = 271 mCarOccupantZoneManager.getAllOccupantZones(); 272 273 for (int index = 0; index < occupants.size(); index++) { 274 OccupantZoneInfo info = occupants.get(index); 275 int occupantZoneUser = mCarOccupantZoneManager.getUserForOccupant(info); 276 if (occupantZoneUser == userId) { 277 return info; 278 } 279 } 280 281 return null; 282 } 283 handleCancelMediaAudioOnPrimaryZone(OccupantZoneInfo info)284 private void handleCancelMediaAudioOnPrimaryZone(OccupantZoneInfo info) { 285 Log.d(TAG, "handleCancelMediaAudioOnPrimaryZone"); 286 int index = mRequestIdToOccupantZone.indexOfValue(info); 287 if (index < 0) { 288 showToast("Occupant " + info + " not currently assign to play media in primary zone"); 289 return; 290 } 291 292 long requestId = mRequestIdToOccupantZone.keyAt(index); 293 294 mRequestIdToOccupantZone.remove(requestId); 295 boolean cancelled; 296 try { 297 cancelled = mCarAudioManager.cancelMediaAudioOnPrimaryZone(requestId); 298 } catch (Exception e) { 299 showToast("Could not cancel media on primary zone: " + e.getMessage()); 300 return; 301 } 302 if (!cancelled) { 303 showToast("Could not unassigned request " + requestId + " for occupant " + info); 304 return; 305 } 306 showToast("Unassigned request " + requestId + " for occupant " + info); 307 mToggleUserAssignButton.setText(R.string.assign_user); 308 } 309 handleUserSelection()310 private void handleUserSelection() { 311 Log.d(TAG, "handleUserSelection"); 312 int position = mUserSpinner.getSelectedItemPosition(); 313 int userId = mUserAdapter.getItem(position); 314 Log.d(TAG, String.format("User Selected: %d", userId)); 315 316 boolean isUserAssigned = 317 mCarAudioManager.isMediaAudioAllowedInPrimaryZone(getOccupantZoneForUser(userId)); 318 Log.d(TAG, String.format("User Selected: %d is assigned %b", userId, isUserAssigned)); 319 mToggleUserAssignButton.setText(isUserAssigned 320 ? R.string.unassign_user : R.string.assign_user); 321 } 322 323 @Override onDestroyView()324 public void onDestroyView() { 325 super.onDestroyView(); 326 if (mIsPrimaryZoneMediaCallbackSet) { 327 mCarAudioManager.clearPrimaryZoneMediaAudioRequestCallback(); 328 } 329 mIsPrimaryZoneMediaCallbackSet = false; 330 Log.i(TAG, "onDestroyView"); 331 } 332 connectCar()333 private void connectCar() { 334 mContext = getContext(); 335 Car.createCar(mContext, /* handler= */ null, Car.CAR_WAIT_TIMEOUT_WAIT_FOREVER, (car, 336 ready) -> onCarReady(car, ready)); 337 } 338 onCarReady(Car car, boolean ready)339 private void onCarReady(Car car, boolean ready) { 340 Log.i(TAG, String.format("connectCar ready %b", ready)); 341 if (!ready) { 342 showToast("Car service not ready!"); 343 return; 344 } 345 346 mCarAudioManager = car.getCarManager(CarAudioManager.class); 347 mCarOccupantZoneManager = car.getCarManager(CarOccupantZoneManager.class); 348 349 setUserInfo(); 350 setDriverOnlyAbilities(); 351 } 352 setDriverOnlyAbilities()353 private void setDriverOnlyAbilities() { 354 if (mCarOccupantZoneManager.getMyOccupantZone().occupantType != OCCUPANT_TYPE_DRIVER) { 355 mAssignAudioRadioGroup.setVisibility(View.INVISIBLE); 356 return; 357 } 358 mIsPrimaryZoneMediaCallbackSet = mCarAudioManager.setPrimaryZoneMediaAudioRequestCallback( 359 ContextCompat.getMainExecutor(getActivity().getApplicationContext()), 360 mPrimaryZoneMediaAudioRequestCallback); 361 } 362 setUserInfo()363 private void setUserInfo() { 364 List<OccupantZoneInfo> occupantZones = 365 mCarOccupantZoneManager.getAllOccupantZones(); 366 367 OccupantZoneInfo myInfo = 368 mCarOccupantZoneManager.getMyOccupantZone(); 369 370 Log.i(TAG, String.format("setUserInfo occupant zones %d", occupantZones.size())); 371 372 List<Integer> userList = new ArrayList<>(); 373 int myIndex = 0; 374 int counter = 0; 375 for (OccupantZoneInfo occupantZoneInfo : occupantZones) { 376 int userId = mCarOccupantZoneManager.getUserForOccupant(occupantZoneInfo); 377 if (userId == CarOccupantZoneManager.INVALID_USER_ID) { 378 Log.i(TAG, String.format("setUserInfo occupant zone %s has invalid user", 379 occupantZoneInfo)); 380 continue; 381 } 382 383 // Do not include driver in the list as driver already owns the primary zone 384 if (occupantZoneInfo.occupantType == OCCUPANT_TYPE_DRIVER) { 385 continue; 386 } 387 Log.i(TAG, String.format("setUserInfo occupant zone %s has user %d", 388 occupantZoneInfo, userId)); 389 userList.add(userId); 390 if (myInfo.equals(occupantZoneInfo)) { 391 myIndex = counter; 392 } 393 counter++; 394 } 395 396 if (userList.isEmpty()) { 397 showToast("Audio playback to primary zone is not supported on this device"); 398 return; 399 } 400 401 Integer[] userArray = userList.toArray(Integer[]::new); 402 mUserAdapter = new ArrayAdapter<>(mContext, simple_spinner_item, userArray); 403 mUserAdapter.setDropDownViewResource(simple_spinner_dropdown_item); 404 mUserSpinner.setAdapter(mUserAdapter); 405 mUserSpinner.setEnabled(true); 406 mUserSpinner.setSelection(myIndex); 407 } 408 showToast(String message)409 private void showToast(String message) { 410 Toast.makeText(mContext, message, Toast.LENGTH_LONG).show(); 411 Log.d(TAG, "Showed toast message: " + message); 412 } 413 } 414