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