1/* 2 * Copyright (C) 2018 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 17package com.android.systemui.volume; 18 19import android.animation.Animator; 20import android.animation.AnimatorInflater; 21import android.animation.AnimatorSet; 22import android.annotation.DrawableRes; 23import android.annotation.Nullable; 24import android.app.Dialog; 25import android.app.KeyguardManager; 26import android.car.Car; 27import android.car.CarNotConnectedException; 28import android.car.media.CarAudioManager; 29import android.car.media.ICarVolumeCallback; 30import android.content.ComponentName; 31import android.content.Context; 32import android.content.DialogInterface; 33import android.content.ServiceConnection; 34import android.content.res.TypedArray; 35import android.content.res.XmlResourceParser; 36import android.graphics.Color; 37import android.graphics.drawable.ColorDrawable; 38import android.graphics.PixelFormat; 39import android.graphics.drawable.Drawable; 40import android.media.AudioAttributes; 41import android.media.AudioManager; 42import android.os.Debug; 43import android.os.Handler; 44import android.os.IBinder; 45import android.os.Looper; 46import android.os.Message; 47import android.util.AttributeSet; 48import android.util.Log; 49import android.util.SparseArray; 50import android.util.Xml; 51import android.view.ContextThemeWrapper; 52import android.view.Gravity; 53import android.view.MotionEvent; 54import android.view.View; 55import android.view.ViewGroup; 56import android.view.Window; 57import android.view.WindowManager; 58import android.widget.SeekBar; 59import android.widget.SeekBar.OnSeekBarChangeListener; 60 61import androidx.car.widget.ListItem; 62import androidx.car.widget.ListItemAdapter; 63import androidx.car.widget.ListItemAdapter.BackgroundStyle; 64import androidx.car.widget.ListItemProvider.ListProvider; 65import androidx.car.widget.PagedListView; 66import androidx.car.widget.SeekbarListItem; 67 68import java.util.Iterator; 69import org.xmlpull.v1.XmlPullParserException; 70 71import java.io.IOException; 72import java.io.PrintWriter; 73import java.util.ArrayList; 74import java.util.List; 75 76import com.android.systemui.R; 77import com.android.systemui.plugins.VolumeDialog; 78 79/** 80 * Car version of the volume dialog. 81 * 82 * Methods ending in "H" must be called on the (ui) handler. 83 */ 84public class CarVolumeDialogImpl implements VolumeDialog { 85 private static final String TAG = Util.logTag(CarVolumeDialogImpl.class); 86 87 private static final String XML_TAG_VOLUME_ITEMS = "carVolumeItems"; 88 private static final String XML_TAG_VOLUME_ITEM = "item"; 89 private static final int HOVERING_TIMEOUT = 16000; 90 private static final int NORMAL_TIMEOUT = 3000; 91 private static final int LISTVIEW_ANIMATION_DURATION_IN_MILLIS = 250; 92 private static final int DISMISS_DELAY_IN_MILLIS = 50; 93 private static final int ARROW_FADE_IN_START_DELAY_IN_MILLIS = 100; 94 95 private final Context mContext; 96 private final H mHandler = new H(); 97 98 private Window mWindow; 99 private CustomDialog mDialog; 100 private PagedListView mListView; 101 private ListItemAdapter mPagedListAdapter; 102 // All the volume items. 103 private final SparseArray<VolumeItem> mVolumeItems = new SparseArray<>(); 104 // Available volume items in car audio manager. 105 private final List<VolumeItem> mAvailableVolumeItems = new ArrayList<>(); 106 // Volume items in the PagedListView. 107 private final List<ListItem> mVolumeLineItems = new ArrayList<>(); 108 private final KeyguardManager mKeyguard; 109 110 private Car mCar; 111 private CarAudioManager mCarAudioManager; 112 113 private boolean mHovering; 114 private boolean mShowing; 115 private boolean mExpanded; 116 117 public CarVolumeDialogImpl(Context context) { 118 mContext = new ContextThemeWrapper(context, com.android.systemui.R.style.qs_theme); 119 mKeyguard = (KeyguardManager) mContext.getSystemService(Context.KEYGUARD_SERVICE); 120 mCar = Car.createCar(mContext, mServiceConnection); 121 } 122 123 public void init(int windowType, Callback callback) { 124 initDialog(); 125 126 mCar.connect(); 127 } 128 129 @Override 130 public void destroy() { 131 mHandler.removeCallbacksAndMessages(null); 132 133 cleanupAudioManager(); 134 // unregisterVolumeCallback is not being called when disconnect car, so we manually cleanup 135 // audio manager beforehand. 136 mCar.disconnect(); 137 } 138 139 private void initDialog() { 140 loadAudioUsageItems(); 141 mVolumeLineItems.clear(); 142 mDialog = new CustomDialog(mContext); 143 144 mHovering = false; 145 mShowing = false; 146 mExpanded = false; 147 mWindow = mDialog.getWindow(); 148 mWindow.requestFeature(Window.FEATURE_NO_TITLE); 149 mWindow.setBackgroundDrawable(new ColorDrawable(Color.TRANSPARENT)); 150 mWindow.clearFlags(WindowManager.LayoutParams.FLAG_DIM_BEHIND 151 | WindowManager.LayoutParams.FLAG_LAYOUT_INSET_DECOR); 152 mWindow.addFlags(WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE 153 | WindowManager.LayoutParams.FLAG_LAYOUT_IN_SCREEN 154 | WindowManager.LayoutParams.FLAG_NOT_TOUCH_MODAL 155 | WindowManager.LayoutParams.FLAG_SHOW_WHEN_LOCKED 156 | WindowManager.LayoutParams.FLAG_WATCH_OUTSIDE_TOUCH 157 | WindowManager.LayoutParams.FLAG_HARDWARE_ACCELERATED); 158 mWindow.setType(WindowManager.LayoutParams.TYPE_VOLUME_OVERLAY); 159 mWindow.setWindowAnimations(com.android.internal.R.style.Animation_Toast); 160 final WindowManager.LayoutParams lp = mWindow.getAttributes(); 161 lp.format = PixelFormat.TRANSLUCENT; 162 lp.setTitle(VolumeDialogImpl.class.getSimpleName()); 163 lp.gravity = Gravity.TOP | Gravity.CENTER_HORIZONTAL; 164 lp.windowAnimations = -1; 165 mWindow.setAttributes(lp); 166 mWindow.setLayout(ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.WRAP_CONTENT); 167 168 mDialog.setCanceledOnTouchOutside(true); 169 mDialog.setContentView(R.layout.car_volume_dialog); 170 mDialog.setOnShowListener(dialog -> { 171 mListView.setTranslationY(-mListView.getHeight()); 172 mListView.setAlpha(0); 173 mListView.animate() 174 .alpha(1) 175 .translationY(0) 176 .setDuration(LISTVIEW_ANIMATION_DURATION_IN_MILLIS) 177 .setInterpolator(new SystemUIInterpolators.LogDecelerateInterpolator()) 178 .start(); 179 }); 180 mListView = (PagedListView) mWindow.findViewById(R.id.volume_list); 181 mListView.setOnHoverListener((v, event) -> { 182 int action = event.getActionMasked(); 183 mHovering = (action == MotionEvent.ACTION_HOVER_ENTER) 184 || (action == MotionEvent.ACTION_HOVER_MOVE); 185 rescheduleTimeoutH(); 186 return true; 187 }); 188 189 mPagedListAdapter = new ListItemAdapter(mContext, new ListProvider(mVolumeLineItems), 190 BackgroundStyle.PANEL); 191 mListView.setAdapter(mPagedListAdapter); 192 mListView.setMaxPages(PagedListView.UNLIMITED_PAGES); 193 } 194 195 public void show(int reason) { 196 mHandler.obtainMessage(H.SHOW, reason, 0).sendToTarget(); 197 } 198 199 public void dismiss(int reason) { 200 mHandler.obtainMessage(H.DISMISS, reason, 0).sendToTarget(); 201 } 202 203 private void showH(int reason) { 204 if (D.BUG) { 205 Log.d(TAG, "showH r=" + Events.DISMISS_REASONS[reason]); 206 } 207 208 mHandler.removeMessages(H.SHOW); 209 mHandler.removeMessages(H.DISMISS); 210 rescheduleTimeoutH(); 211 // Refresh the data set before showing. 212 mPagedListAdapter.notifyDataSetChanged(); 213 if (mShowing) { 214 return; 215 } 216 mShowing = true; 217 218 mDialog.show(); 219 Events.writeEvent(mContext, Events.EVENT_SHOW_DIALOG, reason, mKeyguard.isKeyguardLocked()); 220 } 221 222 protected void rescheduleTimeoutH() { 223 mHandler.removeMessages(H.DISMISS); 224 final int timeout = computeTimeoutH(); 225 mHandler.sendMessageDelayed(mHandler 226 .obtainMessage(H.DISMISS, Events.DISMISS_REASON_TIMEOUT, 0), timeout); 227 228 if (D.BUG) { 229 Log.d(TAG, "rescheduleTimeout " + timeout + " " + Debug.getCaller()); 230 } 231 } 232 233 private int computeTimeoutH() { 234 return mHovering ? HOVERING_TIMEOUT : NORMAL_TIMEOUT; 235 } 236 237 protected void dismissH(int reason) { 238 if (D.BUG) { 239 Log.d(TAG, "dismissH r=" + Events.DISMISS_REASONS[reason]); 240 } 241 242 mHandler.removeMessages(H.DISMISS); 243 mHandler.removeMessages(H.SHOW); 244 if (!mShowing) { 245 return; 246 } 247 248 mListView.animate().cancel(); 249 mShowing = false; 250 251 mListView.setTranslationY(0); 252 mListView.setAlpha(1); 253 mListView.animate() 254 .alpha(0) 255 .translationY(-mListView.getHeight()) 256 .setDuration(LISTVIEW_ANIMATION_DURATION_IN_MILLIS) 257 .setInterpolator(new SystemUIInterpolators.LogAccelerateInterpolator()) 258 .withEndAction(() -> mHandler.postDelayed(() -> { 259 if (D.BUG) { 260 Log.d(TAG, "mDialog.dismiss()"); 261 } 262 mDialog.dismiss(); 263 }, DISMISS_DELAY_IN_MILLIS)) 264 .start(); 265 266 Events.writeEvent(mContext, Events.EVENT_DISMISS_DIALOG, reason); 267 } 268 269 public void dump(PrintWriter writer) { 270 writer.println(VolumeDialogImpl.class.getSimpleName() + " state:"); 271 writer.print(" mShowing: "); writer.println(mShowing); 272 } 273 274 private void loadAudioUsageItems() { 275 try (XmlResourceParser parser = mContext.getResources().getXml(R.xml.car_volume_items)) { 276 AttributeSet attrs = Xml.asAttributeSet(parser); 277 int type; 278 // Traverse to the first start tag 279 while ((type=parser.next()) != XmlResourceParser.END_DOCUMENT 280 && type != XmlResourceParser.START_TAG) { 281 } 282 283 if (!XML_TAG_VOLUME_ITEMS.equals(parser.getName())) { 284 throw new RuntimeException("Meta-data does not start with carVolumeItems tag"); 285 } 286 int outerDepth = parser.getDepth(); 287 int rank = 0; 288 while ((type=parser.next()) != XmlResourceParser.END_DOCUMENT 289 && (type != XmlResourceParser.END_TAG || parser.getDepth() > outerDepth)) { 290 if (type == XmlResourceParser.END_TAG) { 291 continue; 292 } 293 if (XML_TAG_VOLUME_ITEM.equals(parser.getName())) { 294 TypedArray item = mContext.getResources().obtainAttributes( 295 attrs, R.styleable.carVolumeItems_item); 296 int usage = item.getInt(R.styleable.carVolumeItems_item_usage, -1); 297 if (usage >= 0) { 298 VolumeItem volumeItem = new VolumeItem(); 299 volumeItem.usage = usage; 300 volumeItem.rank = rank; 301 volumeItem.icon = item.getResourceId(R.styleable.carVolumeItems_item_icon, 0); 302 mVolumeItems.put(usage, volumeItem); 303 rank++; 304 } 305 item.recycle(); 306 } 307 } 308 } catch (XmlPullParserException | IOException e) { 309 Log.e(TAG, "Error parsing volume groups configuration", e); 310 } 311 } 312 313 private VolumeItem getVolumeItemForUsages(int[] usages) { 314 int rank = Integer.MAX_VALUE; 315 VolumeItem result = null; 316 for (int usage : usages) { 317 VolumeItem volumeItem = mVolumeItems.get(usage); 318 if (volumeItem.rank < rank) { 319 rank = volumeItem.rank; 320 result = volumeItem; 321 } 322 } 323 return result; 324 } 325 326 private static int getSeekbarValue(CarAudioManager carAudioManager, int volumeGroupId) { 327 try { 328 return carAudioManager.getGroupVolume(volumeGroupId); 329 } catch (CarNotConnectedException e) { 330 Log.e(TAG, "Car is not connected!", e); 331 } 332 return 0; 333 } 334 335 private static int getMaxSeekbarValue(CarAudioManager carAudioManager, int volumeGroupId) { 336 try { 337 return carAudioManager.getGroupMaxVolume(volumeGroupId); 338 } catch (CarNotConnectedException e) { 339 Log.e(TAG, "Car is not connected!", e); 340 } 341 return 0; 342 } 343 344 private SeekbarListItem addSeekbarListItem(VolumeItem volumeItem, int volumeGroupId, 345 int supplementalIconId, @Nullable View.OnClickListener supplementalIconOnClickListener) { 346 SeekbarListItem listItem = new SeekbarListItem(mContext); 347 listItem.setMax(getMaxSeekbarValue(mCarAudioManager, volumeGroupId)); 348 int color = mContext.getResources().getColor(R.color.car_volume_dialog_tint); 349 int progress = getSeekbarValue(mCarAudioManager, volumeGroupId); 350 listItem.setProgress(progress); 351 listItem.setOnSeekBarChangeListener( 352 new CarVolumeDialogImpl.VolumeSeekBarChangeListener(volumeGroupId, mCarAudioManager)); 353 Drawable primaryIcon = mContext.getResources().getDrawable(volumeItem.icon); 354 primaryIcon.setTint(color); 355 listItem.setPrimaryActionIcon(primaryIcon); 356 if (supplementalIconId != 0) { 357 Drawable supplementalIcon = mContext.getResources().getDrawable(supplementalIconId); 358 supplementalIcon.setTint(color); 359 listItem.setSupplementalIcon(supplementalIcon, true, 360 supplementalIconOnClickListener); 361 } else { 362 listItem.setSupplementalEmptyIcon(true); 363 } 364 365 mVolumeLineItems.add(listItem); 366 volumeItem.listItem = listItem; 367 volumeItem.progress = progress; 368 return listItem; 369 } 370 371 private VolumeItem findVolumeItem(SeekbarListItem targetItem) { 372 for (int i = 0; i < mVolumeItems.size(); ++i) { 373 VolumeItem volumeItem = mVolumeItems.valueAt(i); 374 if (volumeItem.listItem == targetItem) { 375 return volumeItem; 376 } 377 } 378 return null; 379 } 380 381 private void cleanupAudioManager() { 382 try { 383 mCarAudioManager.unregisterVolumeCallback(mVolumeChangeCallback.asBinder()); 384 } catch (CarNotConnectedException e) { 385 Log.e(TAG, "Car is not connected!", e); 386 } 387 mVolumeLineItems.clear(); 388 mCarAudioManager = null; 389 } 390 391 private final class H extends Handler { 392 private static final int SHOW = 1; 393 private static final int DISMISS = 2; 394 395 public H() { 396 super(Looper.getMainLooper()); 397 } 398 399 @Override 400 public void handleMessage(Message msg) { 401 switch (msg.what) { 402 case SHOW: 403 showH(msg.arg1); 404 break; 405 case DISMISS: 406 dismissH(msg.arg1); 407 break; 408 default: 409 } 410 } 411 } 412 413 private final class CustomDialog extends Dialog implements DialogInterface { 414 public CustomDialog(Context context) { 415 super(context, com.android.systemui.R.style.qs_theme); 416 } 417 418 @Override 419 public boolean dispatchTouchEvent(MotionEvent ev) { 420 rescheduleTimeoutH(); 421 return super.dispatchTouchEvent(ev); 422 } 423 424 @Override 425 protected void onStart() { 426 super.setCanceledOnTouchOutside(true); 427 super.onStart(); 428 } 429 430 @Override 431 protected void onStop() { 432 super.onStop(); 433 } 434 435 @Override 436 public boolean onTouchEvent(MotionEvent event) { 437 if (isShowing()) { 438 if (event.getAction() == MotionEvent.ACTION_OUTSIDE) { 439 dismissH(Events.DISMISS_REASON_TOUCH_OUTSIDE); 440 return true; 441 } 442 } 443 return false; 444 } 445 } 446 447 private final class ExpandIconListener implements View.OnClickListener { 448 @Override 449 public void onClick(final View v) { 450 mExpanded = !mExpanded; 451 Animator inAnimator; 452 if (mExpanded) { 453 for (int groupId = 0; groupId < mAvailableVolumeItems.size(); ++groupId) { 454 // Adding the items which are not coming from the default item. 455 VolumeItem volumeItem = mAvailableVolumeItems.get(groupId); 456 if (volumeItem.defaultItem) { 457 // Set progress here due to the progress of seekbar may not be updated. 458 volumeItem.listItem.setProgress(volumeItem.progress); 459 } else { 460 addSeekbarListItem(volumeItem, groupId, 0, null); 461 } 462 } 463 inAnimator = AnimatorInflater.loadAnimator( 464 mContext, R.anim.car_arrow_fade_in_rotate_up); 465 } else { 466 // Only keeping the default stream if it is not expended. 467 Iterator itr = mVolumeLineItems.iterator(); 468 while (itr.hasNext()) { 469 SeekbarListItem seekbarListItem = (SeekbarListItem) itr.next(); 470 VolumeItem volumeItem = findVolumeItem(seekbarListItem); 471 if (!volumeItem.defaultItem) { 472 itr.remove(); 473 } else { 474 // Set progress here due to the progress of seekbar may not be updated. 475 seekbarListItem.setProgress(volumeItem.progress); 476 } 477 } 478 inAnimator = AnimatorInflater.loadAnimator( 479 mContext, R.anim.car_arrow_fade_in_rotate_down); 480 } 481 482 Animator outAnimator = AnimatorInflater.loadAnimator( 483 mContext, R.anim.car_arrow_fade_out); 484 inAnimator.setStartDelay(ARROW_FADE_IN_START_DELAY_IN_MILLIS); 485 AnimatorSet animators = new AnimatorSet(); 486 animators.playTogether(outAnimator, inAnimator); 487 animators.setTarget(v); 488 animators.start(); 489 mPagedListAdapter.notifyDataSetChanged(); 490 } 491 } 492 493 private final class VolumeSeekBarChangeListener implements OnSeekBarChangeListener { 494 private final int mVolumeGroupId; 495 private final CarAudioManager mCarAudioManager; 496 497 private VolumeSeekBarChangeListener(int volumeGroupId, CarAudioManager carAudioManager) { 498 mVolumeGroupId = volumeGroupId; 499 mCarAudioManager = carAudioManager; 500 } 501 502 @Override 503 public void onProgressChanged(SeekBar seekBar, int progress, boolean fromUser) { 504 if (!fromUser) { 505 // For instance, if this event is originated from AudioService, 506 // we can ignore it as it has already been handled and doesn't need to be 507 // sent back down again. 508 return; 509 } 510 try { 511 if (mCarAudioManager == null) { 512 Log.w(TAG, "Ignoring volume change event because the car isn't connected"); 513 return; 514 } 515 mAvailableVolumeItems.get(mVolumeGroupId).progress = progress; 516 mCarAudioManager.setGroupVolume(mVolumeGroupId, progress, 0); 517 } catch (CarNotConnectedException e) { 518 Log.e(TAG, "Car is not connected!", e); 519 } 520 } 521 522 @Override 523 public void onStartTrackingTouch(SeekBar seekBar) {} 524 525 @Override 526 public void onStopTrackingTouch(SeekBar seekBar) {} 527 } 528 529 private final ICarVolumeCallback mVolumeChangeCallback = new ICarVolumeCallback.Stub() { 530 @Override 531 public void onGroupVolumeChanged(int groupId, int flags) { 532 VolumeItem volumeItem = mAvailableVolumeItems.get(groupId); 533 int value = getSeekbarValue(mCarAudioManager, groupId); 534 // Do not update the progress if it is the same as before. When car audio manager sets its 535 // group volume caused by the seekbar progress changed, it also triggers this callback. 536 // Updating the seekbar at the same time could block the continuous seeking. 537 if (value != volumeItem.progress) { 538 volumeItem.listItem.setProgress(value); 539 volumeItem.progress = value; 540 if ((flags & AudioManager.FLAG_SHOW_UI) != 0) { 541 show(Events.SHOW_REASON_VOLUME_CHANGED); 542 } 543 } 544 } 545 546 @Override 547 public void onMasterMuteChanged(int flags) { 548 // ignored 549 } 550 }; 551 552 private final ServiceConnection mServiceConnection = new ServiceConnection() { 553 @Override 554 public void onServiceConnected(ComponentName name, IBinder service) { 555 try { 556 mExpanded = false; 557 mCarAudioManager = (CarAudioManager) mCar.getCarManager(Car.AUDIO_SERVICE); 558 int volumeGroupCount = mCarAudioManager.getVolumeGroupCount(); 559 // Populates volume slider items from volume groups to UI. 560 for (int groupId = 0; groupId < volumeGroupCount; groupId++) { 561 VolumeItem volumeItem = getVolumeItemForUsages( 562 mCarAudioManager.getUsagesForVolumeGroupId(groupId)); 563 mAvailableVolumeItems.add(volumeItem); 564 // The first one is the default item. 565 if (groupId == 0) { 566 volumeItem.defaultItem = true; 567 addSeekbarListItem(volumeItem, groupId, R.drawable.car_ic_keyboard_arrow_down, 568 new ExpandIconListener()); 569 } 570 } 571 572 // If list is already initiated, update its content. 573 if (mPagedListAdapter != null) { 574 mPagedListAdapter.notifyDataSetChanged(); 575 } 576 mCarAudioManager.registerVolumeCallback(mVolumeChangeCallback.asBinder()); 577 } catch (CarNotConnectedException e) { 578 Log.e(TAG, "Car is not connected!", e); 579 } 580 } 581 582 /** 583 * This does not get called when service is properly disconnected. 584 * So we need to also handle cleanups in destroy(). 585 */ 586 @Override 587 public void onServiceDisconnected(ComponentName name) { 588 cleanupAudioManager(); 589 } 590 }; 591 592 /** 593 * Wrapper class which contains information of each volume group. 594 */ 595 private static class VolumeItem { 596 private @AudioAttributes.AttributeUsage int usage; 597 private int rank; 598 private boolean defaultItem = false; 599 private @DrawableRes int icon; 600 private SeekbarListItem listItem; 601 private int progress; 602 } 603} 604