1 /*
2  * Copyright (C) 2016 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 package com.android.car.media;
17 
18 import android.annotation.SuppressLint;
19 import android.app.AlertDialog;
20 import android.app.Application;
21 import android.app.PendingIntent;
22 import android.car.Car;
23 import android.car.drivingstate.CarUxRestrictions;
24 import android.content.ActivityNotFoundException;
25 import android.content.ComponentName;
26 import android.content.Context;
27 import android.content.Intent;
28 import android.content.pm.ResolveInfo;
29 import android.graphics.drawable.BitmapDrawable;
30 import android.graphics.drawable.Drawable;
31 import android.media.audiofx.AudioEffect;
32 import android.os.Bundle;
33 import android.support.v4.media.session.PlaybackStateCompat;
34 import android.text.TextUtils;
35 import android.util.Log;
36 import android.util.Size;
37 import android.view.GestureDetector;
38 import android.view.MotionEvent;
39 import android.view.View;
40 import android.view.ViewConfiguration;
41 import android.view.ViewGroup;
42 import android.widget.Toast;
43 
44 import androidx.annotation.NonNull;
45 import androidx.annotation.Nullable;
46 import androidx.core.view.GestureDetectorCompat;
47 import androidx.fragment.app.Fragment;
48 import androidx.fragment.app.FragmentActivity;
49 import androidx.lifecycle.AndroidViewModel;
50 import androidx.lifecycle.LiveData;
51 import androidx.lifecycle.MutableLiveData;
52 import androidx.lifecycle.ViewModelProviders;
53 
54 import com.android.car.apps.common.CarUxRestrictionsUtil;
55 import com.android.car.apps.common.util.CarPackageManagerUtils;
56 import com.android.car.apps.common.util.ViewUtils;
57 import com.android.car.media.common.MediaConstants;
58 import com.android.car.media.common.MediaItemMetadata;
59 import com.android.car.media.common.MinimizedPlaybackControlBar;
60 import com.android.car.media.common.browse.MediaBrowserViewModel;
61 import com.android.car.media.common.playback.PlaybackViewModel;
62 import com.android.car.media.common.source.MediaSource;
63 import com.android.car.media.common.source.MediaSourceViewModel;
64 import com.android.car.media.widgets.AppBarView;
65 import com.android.car.ui.toolbar.Toolbar;
66 import com.android.car.ui.AlertDialogBuilder;
67 
68 import java.util.ArrayList;
69 import java.util.Collections;
70 import java.util.HashMap;
71 import java.util.List;
72 import java.util.Map;
73 import java.util.Objects;
74 import java.util.Stack;
75 import java.util.stream.Collectors;
76 
77 /**
78  * This activity controls the UI of media. It also updates the connection status for the media app
79  * by broadcast.
80  */
81 public class MediaActivity extends FragmentActivity implements BrowseFragment.Callbacks {
82     private static final String TAG = "MediaActivity";
83 
84     /** Configuration (controlled from resources) */
85     private int mFadeDuration;
86 
87     /** Models */
88     private PlaybackViewModel.PlaybackController mPlaybackController;
89 
90     /** Layout views */
91     private View mRootView;
92     private AppBarView mAppBarView;
93     private PlaybackFragment mPlaybackFragment;
94     private BrowseFragment mSearchFragment;
95     private BrowseFragment mBrowseFragment;
96     private MinimizedPlaybackControlBar mMiniPlaybackControls;
97     private ViewGroup mBrowseContainer;
98     private ViewGroup mPlaybackContainer;
99     private ViewGroup mErrorContainer;
100     private ErrorFragment mErrorFragment;
101     private ViewGroup mSearchContainer;
102 
103     private Toast mToast;
104     private AlertDialog mDialog;
105 
106     /** Current state */
107     private Mode mMode;
108     private Intent mCurrentSourcePreferences;
109     private boolean mCanShowMiniPlaybackControls;
110     private boolean mShouldShowSoundSettings;
111     private boolean mBrowseTreeHasChildren;
112     private PlaybackViewModel.PlaybackStateWrapper mCurrentPlaybackStateWrapper;
113     /**
114      * Media items to display as tabs. If null, it means we haven't finished loading them yet. If
115      * empty, it means there are no tabs to show
116      */
117     @Nullable
118     private List<MediaItemMetadata> mTopItems;
119 
120     private CarPackageManagerUtils mCarPackageManagerUtils;
121     private CarUxRestrictionsUtil mCarUxRestrictionsUtil;
122     private CarUxRestrictions mActiveCarUxRestrictions;
123     @CarUxRestrictions.CarUxRestrictionsInfo
124     private int mRestrictions;
125     private final CarUxRestrictionsUtil.OnUxRestrictionsChangedListener mListener =
126             (carUxRestrictions) -> mActiveCarUxRestrictions = carUxRestrictions;
127     private Intent mAppSelectorIntent;
128 
129     private boolean mAcceptTabSelection = true;
130 
131     private AppBarView.AppBarListener mAppBarListener = new AppBarView.AppBarListener() {
132         @Override
133         public void onTabSelected(MediaItemMetadata item) {
134             if (mAcceptTabSelection) {
135                 showTopItem(item);
136             }
137         }
138 
139         @Override
140         public void onBack() {
141             BrowseFragment fragment = getCurrentBrowseFragment();
142             if (fragment != null) {
143                 boolean success = fragment.navigateBack();
144                 if (!success && (fragment == mSearchFragment)) {
145                     changeMode(Mode.BROWSING);
146                 }
147             }
148         }
149 
150         @Override
151         public void onEqualizerSelection() {
152             Intent i = new Intent(AudioEffect.ACTION_DISPLAY_AUDIO_EFFECT_CONTROL_PANEL);
153             // Using startActivityForResult so that the control panel app can track changes for
154             // the launching package name.
155             startActivityForResult(i, 0);
156         }
157 
158         @Override
159         public void onSettingsSelection() {
160             if (Log.isLoggable(TAG, Log.DEBUG)) {
161                 Log.d(TAG, "onSettingsSelection");
162             }
163             try {
164                 if (mCurrentSourcePreferences != null) {
165                     startActivity(mCurrentSourcePreferences);
166                 }
167             } catch (ActivityNotFoundException e) {
168                 if (Log.isLoggable(TAG, Log.ERROR)) {
169                     Log.e(TAG, "onSettingsSelection " + e);
170                 }
171             }
172         }
173 
174         @Override
175         public void onSearchSelection() {
176             if (mMode == Mode.SEARCHING) {
177                 mSearchFragment.reopenSearch();
178             } else {
179                 changeMode(Mode.SEARCHING);
180             }
181         }
182 
183         @Override
184         public void onAppSwitch() {
185             MediaActivity.this.startActivity(mAppSelectorIntent);
186         }
187 
188         @Override
189         public void onHeightChanged(int height) {
190             BrowseFragment fragment = getCurrentBrowseFragment();
191             if (fragment != null) {
192                 fragment.onAppBarHeightChanged(height);
193             }
194         }
195 
196         @Override
197         public void onSearch(String query) {
198             if (Log.isLoggable(TAG, Log.DEBUG)) {
199                 Log.d(TAG, "onSearch: " + query);
200             }
201             getInnerViewModel().setSearchQuery(query);
202             mSearchFragment.updateSearchQuery(query);
203         }
204     };
205 
206     private PlaybackFragment.PlaybackFragmentListener mPlaybackFragmentListener =
207             () -> changeMode(Mode.BROWSING);
208 
209     /**
210      * Possible modes of the application UI
211      */
212     private enum Mode {
213         /** The user is browsing a media source */
214         BROWSING,
215         /** The user is interacting with the full screen playback UI */
216         PLAYBACK,
217         /** The user is searching within a media source */
218         SEARCHING,
219         /** There's no browse tree and playback doesn't work. */
220         FATAL_ERROR
221     }
222 
223     private static final Map<Integer, Integer> ERROR_CODE_MESSAGES_MAP;
224 
225     static {
226         Map<Integer, Integer> map = new HashMap<>();
map.put(PlaybackStateCompat.ERROR_CODE_APP_ERROR, R.string.error_code_app_error)227         map.put(PlaybackStateCompat.ERROR_CODE_APP_ERROR, R.string.error_code_app_error);
map.put(PlaybackStateCompat.ERROR_CODE_NOT_SUPPORTED, R.string.error_code_not_supported)228         map.put(PlaybackStateCompat.ERROR_CODE_NOT_SUPPORTED, R.string.error_code_not_supported);
map.put(PlaybackStateCompat.ERROR_CODE_AUTHENTICATION_EXPIRED, R.string.error_code_authentication_expired)229         map.put(PlaybackStateCompat.ERROR_CODE_AUTHENTICATION_EXPIRED,
230                 R.string.error_code_authentication_expired);
map.put(PlaybackStateCompat.ERROR_CODE_PREMIUM_ACCOUNT_REQUIRED, R.string.error_code_premium_account_required)231         map.put(PlaybackStateCompat.ERROR_CODE_PREMIUM_ACCOUNT_REQUIRED,
232                 R.string.error_code_premium_account_required);
map.put(PlaybackStateCompat.ERROR_CODE_CONCURRENT_STREAM_LIMIT, R.string.error_code_concurrent_stream_limit)233         map.put(PlaybackStateCompat.ERROR_CODE_CONCURRENT_STREAM_LIMIT,
234                 R.string.error_code_concurrent_stream_limit);
map.put(PlaybackStateCompat.ERROR_CODE_PARENTAL_CONTROL_RESTRICTED, R.string.error_code_parental_control_restricted)235         map.put(PlaybackStateCompat.ERROR_CODE_PARENTAL_CONTROL_RESTRICTED,
236                 R.string.error_code_parental_control_restricted);
map.put(PlaybackStateCompat.ERROR_CODE_NOT_AVAILABLE_IN_REGION, R.string.error_code_not_available_in_region)237         map.put(PlaybackStateCompat.ERROR_CODE_NOT_AVAILABLE_IN_REGION,
238                 R.string.error_code_not_available_in_region);
map.put(PlaybackStateCompat.ERROR_CODE_CONTENT_ALREADY_PLAYING, R.string.error_code_content_already_playing)239         map.put(PlaybackStateCompat.ERROR_CODE_CONTENT_ALREADY_PLAYING,
240                 R.string.error_code_content_already_playing);
map.put(PlaybackStateCompat.ERROR_CODE_SKIP_LIMIT_REACHED, R.string.error_code_skip_limit_reached)241         map.put(PlaybackStateCompat.ERROR_CODE_SKIP_LIMIT_REACHED,
242                 R.string.error_code_skip_limit_reached);
map.put(PlaybackStateCompat.ERROR_CODE_ACTION_ABORTED, R.string.error_code_action_aborted)243         map.put(PlaybackStateCompat.ERROR_CODE_ACTION_ABORTED, R.string.error_code_action_aborted);
map.put(PlaybackStateCompat.ERROR_CODE_END_OF_QUEUE, R.string.error_code_end_of_queue)244         map.put(PlaybackStateCompat.ERROR_CODE_END_OF_QUEUE, R.string.error_code_end_of_queue);
245         ERROR_CODE_MESSAGES_MAP = Collections.unmodifiableMap(map);
246     }
247 
248     @Override
onCreate(Bundle savedInstanceState)249     protected void onCreate(Bundle savedInstanceState) {
250         super.onCreate(savedInstanceState);
251         setContentView(R.layout.media_activity);
252 
253         MediaSourceViewModel mediaSourceViewModel = getMediaSourceViewModel();
254         PlaybackViewModel playbackViewModel = getPlaybackViewModel();
255         ViewModel localViewModel = getInnerViewModel();
256         // We can't rely on savedInstanceState to determine whether the model has been initialized
257         // as on a config change savedInstanceState != null and the model is initialized, but if
258         // the app was killed by the system then savedInstanceState != null and the model is NOT
259         // initialized...
260         if (localViewModel.needsInitialization()) {
261             localViewModel.init(playbackViewModel);
262         }
263         mMode = localViewModel.getSavedMode();
264 
265         mRootView = findViewById(R.id.media_activity_root);
266         mAppBarView = findViewById(R.id.app_bar);
267         mAppBarView.setListener(mAppBarListener);
268         mediaSourceViewModel.getPrimaryMediaSource().observe(this,
269                 this::onMediaSourceChanged);
270 
271         MediaBrowserViewModel mediaBrowserViewModel = getRootBrowserViewModel();
272         mediaBrowserViewModel.getBrowseState().observe(this,
273                 browseState -> mBrowseFragment.setRootState(browseState,
274                         mediaSourceViewModel.getPrimaryMediaSource().getValue()));
275         mediaBrowserViewModel.getBrowsedMediaItems().observe(this, futureData -> {
276             if (futureData.isLoading()) {
277                 if (Log.isLoggable(TAG, Log.INFO)) {
278                     Log.i(TAG, "Loading browse tree...");
279                 }
280                 mBrowseTreeHasChildren = false;
281                 updateTabs(null);
282                 return;
283             }
284             final boolean browseTreeHasChildren =
285                     futureData.getData() != null && !futureData.getData().isEmpty();
286             if (Log.isLoggable(TAG, Log.INFO)) {
287                 Log.i(TAG, "Browse tree loaded, status (has children or not) changed: "
288                         + mBrowseTreeHasChildren + " -> " + browseTreeHasChildren);
289             }
290             mBrowseTreeHasChildren = browseTreeHasChildren;
291             handlePlaybackState(playbackViewModel.getPlaybackStateWrapper().getValue(), false);
292             updateTabs(futureData.getData() != null ? futureData.getData() : new ArrayList<>());
293         });
294         mediaBrowserViewModel.supportsSearch().observe(this,
295                 mAppBarView::setSearchSupported);
296 
297         mPlaybackFragment = new PlaybackFragment();
298         mPlaybackFragment.setListener(mPlaybackFragmentListener);
299 
300 
301         Size maxArtSize = MediaAppConfig.getMediaItemsBitmapMaxSize(this);
302         mMiniPlaybackControls = findViewById(R.id.minimized_playback_controls);
303         mMiniPlaybackControls.setModel(playbackViewModel, this, maxArtSize);
304         mMiniPlaybackControls.setOnClickListener(view -> changeMode(Mode.PLAYBACK));
305 
306         mFadeDuration = getResources().getInteger(R.integer.new_album_art_fade_in_duration);
307         mBrowseContainer = findViewById(R.id.fragment_container);
308         mErrorContainer = findViewById(R.id.error_container);
309         mPlaybackContainer = findViewById(R.id.playback_container);
310         mSearchContainer = findViewById(R.id.search_container);
311         getSupportFragmentManager().beginTransaction()
312                 .replace(R.id.playback_container, mPlaybackFragment)
313                 .commit();
314 
315         mBrowseFragment = BrowseFragment.newInstance(this, mBrowseContainer);
316         mSearchFragment = BrowseFragment.newSearchInstance(this, mSearchContainer);
317 
318         playbackViewModel.getPlaybackController().observe(this,
319                 playbackController -> {
320                     if (playbackController != null) playbackController.prepare();
321                     mPlaybackController = playbackController;
322                 });
323 
324         playbackViewModel.getPlaybackStateWrapper().observe(this,
325                 state -> handlePlaybackState(state, true));
326 
327         mCarPackageManagerUtils = CarPackageManagerUtils.getInstance(this);
328         mCarUxRestrictionsUtil = CarUxRestrictionsUtil.getInstance(this);
329         mRestrictions = CarUxRestrictions.UX_RESTRICTIONS_NO_SETUP;
330         mCarUxRestrictionsUtil.register(mListener);
331         mShouldShowSoundSettings = getResources().getBoolean(R.bool.show_sound_settings);
332 
333         mPlaybackContainer.setOnTouchListener(new ClosePlaybackDetector(this));
334         mAppSelectorIntent = MediaSource.getSourceSelectorIntent(this, false);
335         mAppBarView.setAppLauncherSupported(mAppSelectorIntent != null);
336 
337         localViewModel.getMiniControlsVisible().observe(this, visible -> {
338             int topMargin = mAppBarView.getHeight();
339             int bottomMargin = visible ? mMiniPlaybackControls.getHeight() : 0;
340             mBrowseFragment.onPlaybackControlsChanged(visible, topMargin, bottomMargin);
341             mSearchFragment.onPlaybackControlsChanged(visible, topMargin, bottomMargin);
342         } );
343     }
344 
345     @Override
onDestroy()346     protected void onDestroy() {
347         mCarUxRestrictionsUtil.unregister(mListener);
348         super.onDestroy();
349     }
350 
isUxRestricted()351     private boolean isUxRestricted() {
352         return CarUxRestrictionsUtil.isRestricted(mRestrictions, mActiveCarUxRestrictions);
353     }
354 
handlePlaybackState(PlaybackViewModel.PlaybackStateWrapper state, boolean ignoreSameState)355     private void handlePlaybackState(PlaybackViewModel.PlaybackStateWrapper state,
356             boolean ignoreSameState) {
357         if (Log.isLoggable(TAG, Log.DEBUG)) {
358             Log.d(TAG,
359                     "handlePlaybackState(); state change: " + (mCurrentPlaybackStateWrapper != null
360                             ? mCurrentPlaybackStateWrapper.getState() : null) + " -> " + (
361                             state != null ? state.getState() : null));
362 
363         }
364 
365         // TODO(arnaudberry) rethink interactions between customized layouts and dynamic visibility.
366         mCanShowMiniPlaybackControls = (state != null) && state.shouldDisplay();
367         updateMiniPlaybackControls(true);
368 
369         if (state == null) {
370             mCurrentPlaybackStateWrapper = null;
371             return;
372         }
373 
374         String displayedMessage = getDisplayedMessage(state);
375         if (Log.isLoggable(TAG, Log.DEBUG)) {
376             Log.d(TAG, "Displayed error message: [" + displayedMessage + "]");
377         }
378         if (ignoreSameState && mCurrentPlaybackStateWrapper != null
379                 && mCurrentPlaybackStateWrapper.getState() == state.getState()
380                 && TextUtils.equals(displayedMessage,
381                 getDisplayedMessage(mCurrentPlaybackStateWrapper))) {
382             if (Log.isLoggable(TAG, Log.DEBUG)) {
383                 Log.d(TAG, "Ignore same playback state.");
384             }
385             return;
386         }
387 
388         mCurrentPlaybackStateWrapper = state;
389 
390         maybeCancelToast();
391         maybeCancelDialog();
392 
393         Bundle extras = state.getExtras();
394         PendingIntent intent = extras == null ? null : extras.getParcelable(
395                 MediaConstants.ERROR_RESOLUTION_ACTION_INTENT);
396         String label = extras == null ? null : extras.getString(
397                 MediaConstants.ERROR_RESOLUTION_ACTION_LABEL);
398 
399         boolean isFatalError = false;
400         if (!TextUtils.isEmpty(displayedMessage)) {
401             if (mBrowseTreeHasChildren) {
402                 if (intent != null && !isUxRestricted()) {
403                     showDialog(intent, displayedMessage, label, getString(android.R.string.cancel));
404                 } else {
405                     showToast(displayedMessage);
406                 }
407             } else {
408                 mErrorFragment = ErrorFragment.newInstance(displayedMessage, label, intent);
409                 setErrorFragment(mErrorFragment);
410                 isFatalError = true;
411             }
412         }
413         if (isFatalError) {
414             changeMode(Mode.FATAL_ERROR);
415         } else if (mMode == Mode.FATAL_ERROR) {
416             changeMode(Mode.BROWSING);
417         }
418     }
419 
getDisplayedMessage(@ullable PlaybackViewModel.PlaybackStateWrapper state)420     private String getDisplayedMessage(@Nullable PlaybackViewModel.PlaybackStateWrapper state) {
421         if (state == null) {
422             return null;
423         }
424         if (!TextUtils.isEmpty(state.getErrorMessage())) {
425             return state.getErrorMessage().toString();
426         }
427         // ERROR_CODE_UNKNOWN_ERROR means there is no error in PlaybackState.
428         if (state.getErrorCode() != PlaybackStateCompat.ERROR_CODE_UNKNOWN_ERROR) {
429             Integer messageId = ERROR_CODE_MESSAGES_MAP.get(state.getErrorCode());
430             return messageId != null ? getString(messageId) : getString(
431                     R.string.default_error_message);
432         }
433         if (state.getState() == PlaybackStateCompat.STATE_ERROR) {
434             return getString(R.string.default_error_message);
435         }
436         return null;
437     }
438 
showDialog(PendingIntent intent, String message, String positiveBtnText, String negativeButtonText)439     private void showDialog(PendingIntent intent, String message, String positiveBtnText,
440             String negativeButtonText) {
441         AlertDialogBuilder dialog = new AlertDialogBuilder(this);
442         mDialog = dialog.setMessage(message)
443                 .setNegativeButton(negativeButtonText, null)
444                 .setPositiveButton(positiveBtnText, (dialogInterface, i) -> {
445                     try {
446                         intent.send();
447                     } catch (PendingIntent.CanceledException e) {
448                         if (Log.isLoggable(TAG, Log.ERROR)) {
449                             Log.e(TAG, "Pending intent canceled");
450                         }
451                     }
452                 })
453                 .show();
454     }
455 
maybeCancelDialog()456     private void maybeCancelDialog() {
457         if (mDialog != null) {
458             mDialog.cancel();
459             mDialog = null;
460         }
461     }
462 
showToast(String message)463     private void showToast(String message) {
464         mToast = Toast.makeText(this, message, Toast.LENGTH_LONG);
465         mToast.show();
466     }
467 
maybeCancelToast()468     private void maybeCancelToast() {
469         if (mToast != null) {
470             mToast.cancel();
471             mToast = null;
472         }
473     }
474 
475     @Override
onBackPressed()476     public void onBackPressed() {
477         super.onBackPressed();
478     }
479 
480     /**
481      * Sets the media source being browsed.
482      *
483      * @param mediaSource the new media source we are going to try to browse
484      */
onMediaSourceChanged(@ullable MediaSource mediaSource)485     private void onMediaSourceChanged(@Nullable MediaSource mediaSource) {
486         ComponentName savedMediaSource = getInnerViewModel().getSavedMediaSource();
487         if (Log.isLoggable(TAG, Log.INFO)) {
488             Log.i(TAG, "MediaSource changed from " + savedMediaSource + " to " + mediaSource);
489         }
490 
491         savedMediaSource = mediaSource != null ? mediaSource.getBrowseServiceComponentName() : null;
492         getInnerViewModel().saveMediaSource(savedMediaSource);
493 
494         mBrowseFragment.resetState();
495         mSearchFragment.resetState();
496 
497         mBrowseTreeHasChildren = false;
498         mCurrentPlaybackStateWrapper = null;
499         maybeCancelToast();
500         maybeCancelDialog();
501         Drawable icon = mediaSource != null
502                 ? new BitmapDrawable(getResources(), mediaSource.getRoundPackageIcon())
503                 : null;
504         Drawable searchIcon = mediaSource != null
505                 ? new BitmapDrawable(getResources(), mediaSource.getRoundPackageIcon())
506                 : null;
507         // The Drawables can't be shared as the layout manager scales the underlying drawable object
508         // when performing the layout thus can cause inconsistencies.
509         mAppBarView.setLogo(icon);
510         mAppBarView.setSearchIcon(searchIcon);
511         if (mediaSource != null) {
512             if (Log.isLoggable(TAG, Log.INFO)) {
513                 Log.i(TAG, "Browsing: " + mediaSource.getDisplayName());
514             }
515             updateTabs(null);
516             Mode mediaSourceMode = getInnerViewModel().getSavedMode();
517             // Changes the mode regardless of its previous value so that the views can be updated.
518             changeModeInternal(mediaSourceMode, false);
519             updateSourcePreferences(mediaSource.getPackageName());
520             // Always go through the trampoline activity to keep all the dispatching logic there.
521             startActivity(new Intent(Car.CAR_INTENT_ACTION_MEDIA_TEMPLATE));
522         } else {
523             updateTabs(new ArrayList<>());
524             updateSourcePreferences(null);
525         }
526     }
527 
528     // TODO(b/136274938): display the preference screen for each media service.
updateSourcePreferences(@ullable String packageName)529     private void updateSourcePreferences(@Nullable String packageName) {
530         mCurrentSourcePreferences = null;
531         if (packageName != null) {
532             Intent prefsIntent = new Intent(Intent.ACTION_APPLICATION_PREFERENCES);
533             prefsIntent.setPackage(packageName);
534             ResolveInfo info = getPackageManager().resolveActivity(prefsIntent, 0);
535             if (info != null && info.activityInfo != null && info.activityInfo.exported) {
536                 mCurrentSourcePreferences = new Intent(prefsIntent.getAction())
537                         .setClassName(info.activityInfo.packageName, info.activityInfo.name);
538                 mAppBarView.setSettingsDistractionOptimized(
539                         mCarPackageManagerUtils.isDistractionOptimized(info.activityInfo));
540             }
541         }
542         mAppBarView.setHasSettings(mCurrentSourcePreferences != null);
543         mAppBarView.setHasEqualizer(mShouldShowSoundSettings);
544     }
545 
546 
547     /**
548      * Updates the tabs displayed on the app bar, based on the top level items on the browse tree.
549      * If there is at least one browsable item, we show the browse content of that node. If there
550      * are only playable items, then we show those items. If there are not items at all, we show the
551      * empty message. If we receive null, we show the error message.
552      *
553      * @param items top level items, null if the items are still being loaded, or empty list if
554      *              items couldn't be loaded.
555      */
updateTabs(@ullable List<MediaItemMetadata> items)556     private void updateTabs(@Nullable List<MediaItemMetadata> items) {
557         // Keep mTopItems null when the items are being loaded so that updateAppBarTitle() can
558         // handle that case specifically.
559         List<MediaItemMetadata> browsableTopLevel = (items == null) ? null :
560                 items.stream().filter(MediaItemMetadata::isBrowsable).collect(Collectors.toList());
561 
562         if (Objects.equals(mTopItems, browsableTopLevel)) {
563             // When coming back to the app, the live data sends an update even if the list hasn't
564             // changed. Updating the tabs then recreates the browse fragment, which produces jank
565             // (b/131830876), and also resets the navigation to the top of the first tab...
566             return;
567         }
568         mTopItems = browsableTopLevel;
569 
570         if (mTopItems == null || mTopItems.isEmpty()) {
571             mAppBarView.setItems(null);
572             mAppBarView.setActiveItem(null);
573             if (items != null) {
574                 // Only do this when not loading the tabs or we loose the saved one.
575                 showTopItem(null);
576             }
577             updateAppBar();
578             return;
579         }
580 
581         ViewModel innerModel = getInnerViewModel();
582         MediaItemMetadata oldTab = innerModel.getSelectedTab();
583         try {
584             mAcceptTabSelection = false;
585             mAppBarView.setItems(mTopItems.size() == 1 ? null : mTopItems);
586             updateAppBar();
587 
588             if (browsableTopLevel.contains(oldTab)) {
589                 mAppBarView.setActiveItem(oldTab);
590             } else {
591                 showTopItem(browsableTopLevel.get(0));
592             }
593         }  finally {
594             mAcceptTabSelection = true;
595         }
596     }
597 
showTopItem(@ullable MediaItemMetadata item)598     private void showTopItem(@Nullable MediaItemMetadata item) {
599         getInnerViewModel().getBrowseStack().clear();
600         mBrowseFragment.navigateInto(item);
601     }
602 
setErrorFragment(Fragment fragment)603     private void setErrorFragment(Fragment fragment) {
604         getSupportFragmentManager().beginTransaction()
605                 .replace(R.id.error_container, fragment)
606                 .commitAllowingStateLoss();
607     }
608 
609     @Nullable
getCurrentBrowseFragment()610     private BrowseFragment getCurrentBrowseFragment() {
611         return mMode == Mode.SEARCHING ? mSearchFragment : mBrowseFragment;
612     }
613 
changeMode(Mode mode)614     private void changeMode(Mode mode) {
615         if (mMode == mode) {
616             if (Log.isLoggable(TAG, Log.INFO)) {
617                 Log.i(TAG, "Mode " + mMode + " change is ignored");
618             }
619             return;
620         }
621         changeModeInternal(mode, true);
622     }
623 
changeModeInternal(Mode mode, boolean hideViewAnimated)624     private void changeModeInternal(Mode mode, boolean hideViewAnimated) {
625         if (Log.isLoggable(TAG, Log.INFO)) {
626             Log.i(TAG, "Changing mode from: " + mMode + " to: " + mode);
627         }
628         int fadeOutDuration = hideViewAnimated ? mFadeDuration : 0;
629 
630         Mode oldMode = mMode;
631         getInnerViewModel().saveMode(mode);
632         mMode = mode;
633 
634         mPlaybackFragment.closeOverflowMenu();
635         updateMiniPlaybackControls(hideViewAnimated);
636 
637         switch (mMode) {
638             case FATAL_ERROR:
639                 ViewUtils.showViewAnimated(mErrorContainer, mFadeDuration);
640                 ViewUtils.hideViewAnimated(mPlaybackContainer, fadeOutDuration);
641                 ViewUtils.hideViewAnimated(mBrowseContainer, fadeOutDuration);
642                 ViewUtils.hideViewAnimated(mSearchContainer, fadeOutDuration);
643                 mAppBarView.setState(Toolbar.State.HOME);
644                 mAppBarView.showSearchIfSupported(false);
645                 ViewUtils.showViewAnimated(mAppBarView, mFadeDuration);
646                 break;
647             case PLAYBACK:
648                 mPlaybackContainer.setY(0);
649                 mPlaybackContainer.setAlpha(0f);
650                 ViewUtils.hideViewAnimated(mErrorContainer, fadeOutDuration);
651                 ViewUtils.showViewAnimated(mPlaybackContainer, mFadeDuration);
652                 ViewUtils.hideViewAnimated(mBrowseContainer, fadeOutDuration);
653                 ViewUtils.hideViewAnimated(mSearchContainer, fadeOutDuration);
654                 ViewUtils.hideViewAnimated(mAppBarView, fadeOutDuration);
655                 break;
656             case BROWSING:
657                 if (oldMode == Mode.PLAYBACK) {
658                     ViewUtils.hideViewAnimated(mErrorContainer, 0);
659                     ViewUtils.showViewAnimated(mBrowseContainer, 0);
660                     ViewUtils.hideViewAnimated(mSearchContainer, 0);
661                     ViewUtils.showViewAnimated(mAppBarView, 0);
662                     mPlaybackContainer.animate()
663                             .translationY(mRootView.getHeight())
664                             .setDuration(fadeOutDuration)
665                             .setListener(ViewUtils.hideViewAfterAnimation(mPlaybackContainer))
666                             .start();
667                 } else {
668                     ViewUtils.hideViewAnimated(mErrorContainer, fadeOutDuration);
669                     ViewUtils.hideViewAnimated(mPlaybackContainer, fadeOutDuration);
670                     ViewUtils.showViewAnimated(mBrowseContainer, mFadeDuration);
671                     ViewUtils.hideViewAnimated(mSearchContainer, fadeOutDuration);
672                     ViewUtils.showViewAnimated(mAppBarView, mFadeDuration);
673                 }
674                 updateAppBar();
675                 break;
676             case SEARCHING:
677                 ViewUtils.hideViewAnimated(mErrorContainer, fadeOutDuration);
678                 ViewUtils.hideViewAnimated(mPlaybackContainer, fadeOutDuration);
679                 ViewUtils.hideViewAnimated(mBrowseContainer, fadeOutDuration);
680                 ViewUtils.showViewAnimated(mSearchContainer, mFadeDuration);
681                 ViewUtils.showViewAnimated(mAppBarView, mFadeDuration);
682                 updateAppBar();
683                 break;
684         }
685     }
updateAppBarTitle()686     private void updateAppBarTitle() {
687         BrowseFragment fragment = getCurrentBrowseFragment();
688         boolean isStacked = fragment != null && !fragment.isAtTopStack();
689 
690         final CharSequence title;
691         if (isStacked) {
692             // If not at top level, show the current item as title
693             title = fragment.getCurrentMediaItem().getTitle();
694         } else if (mTopItems == null) {
695             // If still loading the tabs, force to show an empty bar.
696             title = "";
697         } else if (mTopItems.size() == 1) {
698             // If we finished loading tabs and there is only one, use that as title.
699             title = mTopItems.get(0).getTitle();
700         } else {
701             // Otherwise (no tabs or more than 1 tabs), show the current media source title.
702             MediaSourceViewModel mediaSourceViewModel = getMediaSourceViewModel();
703             MediaSource mediaSource = mediaSourceViewModel.getPrimaryMediaSource().getValue();
704             title = (mediaSource != null) ? mediaSource.getDisplayName()
705                     : getResources().getString(R.string.media_app_title);
706         }
707 
708         mAppBarView.setTitle(title);
709     }
710 
711     /**
712      * Update elements of the appbar that change depending on where we are in the browse.
713      */
updateAppBar()714     private void updateAppBar() {
715         BrowseFragment fragment = getCurrentBrowseFragment();
716         boolean isStacked = fragment != null && !fragment.isAtTopStack();
717         if (Log.isLoggable(TAG, Log.DEBUG)) {
718             Log.d(TAG, "App bar is in stacked state: " + isStacked);
719         }
720         Toolbar.State unstackedState = mMode == Mode.SEARCHING
721                 ? Toolbar.State.SEARCH
722                 : Toolbar.State.HOME;
723         updateAppBarTitle();
724         mAppBarView.setState(isStacked ? Toolbar.State.SUBPAGE : unstackedState);
725 
726         boolean showSearchItem = mMode != Mode.FATAL_ERROR && mMode != Mode.SEARCHING;
727         mAppBarView.showSearchIfSupported(showSearchItem);
728     }
729 
updateMiniPlaybackControls(boolean hideViewAnimated)730     private void updateMiniPlaybackControls(boolean hideViewAnimated) {
731         int fadeOutDuration = hideViewAnimated ? mFadeDuration : 0;
732         // Minimized control bar should be hidden in playback view.
733         final boolean shouldShowMiniPlaybackControls =
734                 mCanShowMiniPlaybackControls && mMode != Mode.PLAYBACK;
735         if (shouldShowMiniPlaybackControls) {
736             ViewUtils.showViewAnimated(mMiniPlaybackControls, mFadeDuration);
737         } else {
738             ViewUtils.hideViewAnimated(mMiniPlaybackControls, fadeOutDuration);
739         }
740         getInnerViewModel().setMiniControlsVisible(shouldShowMiniPlaybackControls);
741     }
742 
743     @Override
onBackStackChanged()744     public void onBackStackChanged() {
745         updateAppBar();
746     }
747 
748     @Override
onPlayableItemClicked(MediaItemMetadata item)749     public void onPlayableItemClicked(MediaItemMetadata item) {
750         mPlaybackController.playItem(item);
751         boolean switchToPlayback = getResources().getBoolean(
752                 R.bool.switch_to_playback_view_when_playable_item_is_clicked);
753         if (switchToPlayback) {
754             changeMode(Mode.PLAYBACK);
755         } else if (mMode == Mode.SEARCHING) {
756             changeMode(Mode.BROWSING);
757         }
758         setIntent(null);
759     }
760 
761     @Override
getActivity()762     public FragmentActivity getActivity() {
763         return this;
764     }
765 
getMediaSourceViewModel()766     public MediaSourceViewModel getMediaSourceViewModel() {
767         return MediaSourceViewModel.get(getApplication());
768     }
769 
getPlaybackViewModel()770     public PlaybackViewModel getPlaybackViewModel() {
771         return PlaybackViewModel.get(getApplication());
772     }
773 
getRootBrowserViewModel()774     private MediaBrowserViewModel getRootBrowserViewModel() {
775         return MediaBrowserViewModel.Factory.getInstanceForBrowseRoot(getMediaSourceViewModel(),
776                 ViewModelProviders.of(this));
777     }
778 
getInnerViewModel()779     public ViewModel getInnerViewModel() {
780         return ViewModelProviders.of(this).get(ViewModel.class);
781     }
782 
783     public static class ViewModel extends AndroidViewModel {
784 
785         static class MediaServiceState {
786             Mode mMode = Mode.BROWSING;
787             Stack<MediaItemMetadata> mBrowseStack = new Stack<>();
788             Stack<MediaItemMetadata> mSearchStack = new Stack<>();
789             String mSearchQuery;
790             boolean mQueueVisible = false;
791         }
792 
793         private boolean mNeedsInitialization = true;
794         private PlaybackViewModel mPlaybackViewModel;
795         private ComponentName mMediaSource;
796         private final Map<ComponentName, MediaServiceState> mStates = new HashMap<>();
797         private MutableLiveData<Boolean> mIsMiniControlsVisible = new MutableLiveData<>();
798 
ViewModel(@onNull Application application)799         public ViewModel(@NonNull Application application) {
800             super(application);
801         }
802 
init(@onNull PlaybackViewModel playbackViewModel)803         void init(@NonNull PlaybackViewModel playbackViewModel) {
804             if (mPlaybackViewModel == playbackViewModel) {
805                 return;
806             }
807             mPlaybackViewModel = playbackViewModel;
808             mNeedsInitialization = false;
809         }
810 
needsInitialization()811         boolean needsInitialization() {
812             return mNeedsInitialization;
813         }
814 
setMiniControlsVisible(boolean visible)815         void setMiniControlsVisible(boolean visible) {
816             mIsMiniControlsVisible.setValue(visible);
817         }
818 
getMiniControlsVisible()819         LiveData<Boolean> getMiniControlsVisible() {
820             return mIsMiniControlsVisible;
821         }
822 
getSavedState()823         MediaServiceState getSavedState() {
824             MediaServiceState state = mStates.get(mMediaSource);
825             if (state == null) {
826                 state = new MediaServiceState();
827                 mStates.put(mMediaSource, state);
828             }
829             return state;
830         }
831 
saveMode(Mode mode)832         void saveMode(Mode mode) {
833             getSavedState().mMode = mode;
834         }
835 
getSavedMode()836         Mode getSavedMode() {
837             return getSavedState().mMode;
838         }
839 
getSelectedTab()840         @Nullable MediaItemMetadata getSelectedTab() {
841             Stack<MediaItemMetadata> stack = getSavedState().mBrowseStack;
842             return (stack != null && !stack.empty()) ? stack.firstElement() : null;
843         }
844 
setQueueVisible(boolean visible)845         void setQueueVisible(boolean visible) {
846             getSavedState().mQueueVisible = visible;
847         }
848 
getQueueVisible()849         boolean getQueueVisible() {
850             return getSavedState().mQueueVisible;
851         }
852 
saveMediaSource(ComponentName mediaSource)853         void saveMediaSource(ComponentName mediaSource) {
854             mMediaSource = mediaSource;
855         }
856 
getSavedMediaSource()857         ComponentName getSavedMediaSource() {
858             return mMediaSource;
859         }
860 
getBrowseStack()861         Stack<MediaItemMetadata> getBrowseStack() {
862             return getSavedState().mBrowseStack;
863         }
864 
getSearchStack()865         Stack<MediaItemMetadata> getSearchStack() {
866             return getSavedState().mSearchStack;
867         }
868 
getSearchQuery()869         String getSearchQuery() {
870             return getSavedState().mSearchQuery;
871         }
872 
setSearchQuery(String searchQuery)873         void setSearchQuery(String searchQuery) {
874             getSavedState().mSearchQuery = searchQuery;
875         }
876     }
877 
878     private class ClosePlaybackDetector extends GestureDetector.SimpleOnGestureListener
879             implements View.OnTouchListener {
880 
881         private final ViewConfiguration mViewConfig;
882         private final GestureDetectorCompat mDetector;
883 
884 
ClosePlaybackDetector(Context context)885         ClosePlaybackDetector(Context context) {
886             mViewConfig = ViewConfiguration.get(context);
887             mDetector = new GestureDetectorCompat(context, this);
888         }
889 
890         @SuppressLint("ClickableViewAccessibility")
891         @Override
onTouch(View v, MotionEvent event)892         public boolean onTouch(View v, MotionEvent event) {
893             return mDetector.onTouchEvent(event);
894         }
895 
896         @Override
onDown(MotionEvent event)897         public boolean onDown(MotionEvent event) {
898             return (mMode == Mode.PLAYBACK);
899         }
900 
901         @Override
onFling(MotionEvent e1, MotionEvent e2, float vX, float vY)902         public boolean onFling(MotionEvent e1, MotionEvent e2, float vX, float vY) {
903             float dY = e2.getY() - e1.getY();
904             if (dY > mViewConfig.getScaledTouchSlop() &&
905                     Math.abs(vY) > mViewConfig.getScaledMinimumFlingVelocity()) {
906                 float dX = e2.getX() - e1.getX();
907                 float tan = Math.abs(dX) / dY;
908                 if (tan <= 0.58) { // Accept 30 degrees on each side of the down vector.
909                     changeMode(Mode.BROWSING);
910                 }
911             }
912             return true;
913         }
914     }
915 }
916