1 /*
2  * Copyright (C) 2020 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.android.systemui.qs;
18 
19 import static com.android.internal.logging.nano.MetricsProto.MetricsEvent;
20 import static com.android.systemui.Flags.quickSettingsVisualHapticsLongpress;
21 
22 import android.annotation.NonNull;
23 import android.annotation.Nullable;
24 import android.content.ComponentName;
25 import android.content.res.Configuration;
26 import android.content.res.Configuration.Orientation;
27 import android.metrics.LogMaker;
28 import android.util.Log;
29 import android.view.View;
30 
31 import com.android.internal.annotations.VisibleForTesting;
32 import com.android.internal.logging.MetricsLogger;
33 import com.android.internal.logging.UiEventLogger;
34 import com.android.systemui.Dumpable;
35 import com.android.systemui.dump.DumpManager;
36 import com.android.systemui.haptics.qs.QSLongPressEffect;
37 import com.android.systemui.media.controls.ui.view.MediaHost;
38 import com.android.systemui.plugins.qs.QSTile;
39 import com.android.systemui.plugins.qs.QSTileView;
40 import com.android.systemui.qs.customize.QSCustomizerController;
41 import com.android.systemui.qs.external.CustomTile;
42 import com.android.systemui.qs.logging.QSLogger;
43 import com.android.systemui.qs.tileimpl.QSTileViewImpl;
44 import com.android.systemui.scene.shared.flag.SceneContainerFlag;
45 import com.android.systemui.statusbar.policy.SplitShadeStateController;
46 import com.android.systemui.util.ViewController;
47 import com.android.systemui.util.animation.DisappearParameters;
48 import com.android.systemui.util.kotlin.JavaAdapterKt;
49 
50 import kotlin.Unit;
51 import kotlin.jvm.functions.Function1;
52 
53 import kotlinx.coroutines.flow.StateFlow;
54 
55 import java.io.PrintWriter;
56 import java.util.ArrayList;
57 import java.util.Collection;
58 import java.util.List;
59 import java.util.Objects;
60 import java.util.function.Consumer;
61 import java.util.stream.Collectors;
62 
63 import javax.inject.Provider;
64 
65 
66 /**
67  * Controller for QSPanel views.
68  *
69  * @param <T> Type of QSPanel.
70  */
71 public abstract class QSPanelControllerBase<T extends QSPanel> extends ViewController<T>
72         implements Dumpable{
73     private static final String TAG = "QSPanelControllerBase";
74     protected final QSHost mHost;
75     private final QSCustomizerController mQsCustomizerController;
76     private final boolean mUsingMediaPlayer;
77     protected final MediaHost mMediaHost;
78     protected final MetricsLogger mMetricsLogger;
79     private final UiEventLogger mUiEventLogger;
80     protected final QSLogger mQSLogger;
81     private final DumpManager mDumpManager;
82     protected final ArrayList<TileRecord> mRecords = new ArrayList<>();
83     protected boolean mShouldUseSplitNotificationShade;
84 
85     @Nullable
86     private Consumer<Boolean> mMediaVisibilityChangedListener;
87     @Orientation
88     private int mLastOrientation;
89     private int mLastScreenLayout;
90     private String mCachedSpecs = "";
91     @Nullable
92     private QSTileRevealController mQsTileRevealController;
93     private float mRevealExpansion;
94 
95     private final QSHost.Callback mQSHostCallback = this::setTiles;
96 
97     private SplitShadeStateController mSplitShadeStateController;
98 
99     private final Provider<QSLongPressEffect> mLongPressEffectProvider;
100 
101     private boolean mDestroyed = false;
102 
103     private boolean mMediaVisibleFromInteractor;
104 
105     private final Consumer<Boolean> mMediaOrRecommendationVisibleConsumer = mediaVisible -> {
106         mMediaVisibleFromInteractor = mediaVisible;
107         setLayoutForMediaInScene();
108     };
109 
110     @VisibleForTesting
111     protected final QSPanel.OnConfigurationChangedListener mOnConfigurationChangedListener =
112             new QSPanel.OnConfigurationChangedListener() {
113                 @Override
114                 public void onConfigurationChange(Configuration newConfig) {
115                     final boolean previousSplitShadeState = mShouldUseSplitNotificationShade;
116                     final int previousOrientation = mLastOrientation;
117                     final int previousScreenLayout = mLastScreenLayout;
118                     mShouldUseSplitNotificationShade = mSplitShadeStateController
119                             .shouldUseSplitNotificationShade(getResources());
120                     mLastOrientation = newConfig.orientation;
121                     mLastScreenLayout = newConfig.screenLayout;
122 
123                     mQSLogger.logOnConfigurationChanged(
124                         /* oldOrientation= */ previousOrientation,
125                         /* newOrientation= */ mLastOrientation,
126                         /* oldShouldUseSplitShade= */ previousSplitShadeState,
127                         /* newShouldUseSplitShade= */ mShouldUseSplitNotificationShade,
128                         /* oldScreenLayout= */ previousScreenLayout,
129                         /* newScreenLayout= */ mLastScreenLayout,
130                         /* containerName= */ mView.getDumpableTag());
131 
132                     if (SceneContainerFlag.isEnabled()) {
133                         setLayoutForMediaInScene();
134                     } else {
135                         switchTileLayoutIfNeeded();
136                     }
137                     onConfigurationChanged();
138                     if (previousSplitShadeState != mShouldUseSplitNotificationShade) {
139                         onSplitShadeChanged(mShouldUseSplitNotificationShade);
140                     }
141                 }
142             };
143 
onConfigurationChanged()144     protected void onConfigurationChanged() { }
145 
onSplitShadeChanged(boolean shouldUseSplitNotificationShade)146     protected void onSplitShadeChanged(boolean shouldUseSplitNotificationShade) { }
147 
148     private final Function1<Boolean, Unit> mMediaHostVisibilityListener = (visible) -> {
149         if (mMediaVisibilityChangedListener != null) {
150             mMediaVisibilityChangedListener.accept(visible);
151         }
152         switchTileLayout(false);
153         return null;
154     };
155 
156     private boolean mUsingHorizontalLayout;
157 
158     @Nullable
159     private Runnable mUsingHorizontalLayoutChangedListener;
160 
QSPanelControllerBase( T view, QSHost host, QSCustomizerController qsCustomizerController, boolean usingMediaPlayer, MediaHost mediaHost, MetricsLogger metricsLogger, UiEventLogger uiEventLogger, QSLogger qsLogger, DumpManager dumpManager, SplitShadeStateController splitShadeStateController, Provider<QSLongPressEffect> longPressEffectProvider )161     protected QSPanelControllerBase(
162             T view,
163             QSHost host,
164             QSCustomizerController qsCustomizerController,
165             boolean usingMediaPlayer,
166             MediaHost mediaHost,
167             MetricsLogger metricsLogger,
168             UiEventLogger uiEventLogger,
169             QSLogger qsLogger,
170             DumpManager dumpManager,
171             SplitShadeStateController splitShadeStateController,
172             Provider<QSLongPressEffect> longPressEffectProvider
173     ) {
174         super(view);
175         mHost = host;
176         mQsCustomizerController = qsCustomizerController;
177         mUsingMediaPlayer = usingMediaPlayer;
178         mMediaHost = mediaHost;
179         mMetricsLogger = metricsLogger;
180         mUiEventLogger = uiEventLogger;
181         mQSLogger = qsLogger;
182         mDumpManager = dumpManager;
183         mSplitShadeStateController = splitShadeStateController;
184         mShouldUseSplitNotificationShade =
185                 mSplitShadeStateController.shouldUseSplitNotificationShade(getResources());
186         mLongPressEffectProvider = longPressEffectProvider;
187     }
188 
189     @Override
onInit()190     protected void onInit() {
191         mView.initialize(mQSLogger, mUsingMediaPlayer);
192         mQSLogger.logAllTilesChangeListening(mView.isListening(), mView.getDumpableTag(), "");
193         mHost.addCallback(mQSHostCallback);
194         if (SceneContainerFlag.isEnabled()) {
195             registerForMediaInteractorChanges();
196         }
197     }
198 
199     /**
200      * @return the media host for this panel
201      */
getMediaHost()202     public MediaHost getMediaHost() {
203         return mMediaHost;
204     }
205 
setSquishinessFraction(float squishinessFraction)206     public void setSquishinessFraction(float squishinessFraction) {
207         mView.setSquishinessFraction(squishinessFraction);
208     }
209 
210     @Override
destroy()211     public void destroy() {
212         // Don't call super as this may be called before the view is dettached and calling super
213         // will remove the attach listener. We don't need to do that, because once this object is
214         // detached from the graph, it will be gc.
215         mHost.removeCallback(mQSHostCallback);
216         mDestroyed = true;
217         for (TileRecord record : mRecords) {
218             record.tile.removeCallback(record.callback);
219             mView.removeTile(record);
220         }
221         mRecords.clear();
222     }
223 
224     @Override
onViewAttached()225     protected void onViewAttached() {
226         mQsTileRevealController = createTileRevealController();
227         if (mQsTileRevealController != null) {
228             mQsTileRevealController.setExpansion(mRevealExpansion);
229         }
230 
231         if (!SceneContainerFlag.isEnabled()) {
232             mMediaHost.addVisibilityChangeListener(mMediaHostVisibilityListener);
233         }
234         mView.addOnConfigurationChangedListener(mOnConfigurationChangedListener);
235         setTiles();
236         mLastOrientation = getResources().getConfiguration().orientation;
237         mLastScreenLayout = getResources().getConfiguration().screenLayout;
238         mQSLogger.logOnViewAttached(mLastOrientation, mView.getDumpableTag());
239         if (SceneContainerFlag.isEnabled()) {
240             setLayoutForMediaInScene();
241         }
242         switchTileLayout(true);
243 
244         mDumpManager.registerDumpable(mView.getDumpableTag(), this);
245     }
246 
registerForMediaInteractorChanges()247     private void registerForMediaInteractorChanges() {
248         JavaAdapterKt.collectFlow(
249                 mView,
250                 getMediaVisibleFlow(),
251                 mMediaOrRecommendationVisibleConsumer
252         );
253     }
254 
getMediaVisibleFlow()255     abstract StateFlow<Boolean> getMediaVisibleFlow();
256 
257     @Override
onViewDetached()258     protected void onViewDetached() {
259         mQSLogger.logOnViewDetached(mLastOrientation, mView.getDumpableTag());
260         mView.removeOnConfigurationChangedListener(mOnConfigurationChangedListener);
261 
262         mView.getTileLayout().setListening(false, mUiEventLogger);
263 
264         mMediaHost.removeVisibilityChangeListener(mMediaHostVisibilityListener);
265 
266         mDumpManager.unregisterDumpable(mView.getDumpableTag());
267     }
268 
269     @Nullable
createTileRevealController()270     protected QSTileRevealController createTileRevealController() {
271         return null;
272     }
273 
274     /** */
setTiles()275     public void setTiles() {
276         setTiles(mHost.getTiles(), false);
277     }
278 
279     /** */
setTiles(Collection<QSTile> tiles, boolean collapsedView)280     public void setTiles(Collection<QSTile> tiles, boolean collapsedView) {
281         if (mDestroyed) return;
282         // TODO(b/168904199): move this logic into QSPanelController.
283         if (!collapsedView && mQsTileRevealController != null) {
284             mQsTileRevealController.updateRevealedTiles(tiles);
285         }
286         boolean shouldChangeAll = false;
287         // If the new tiles are a prefix of the old tiles, we delete the extra tiles (from the old).
288         // If not (even if they share a prefix) we remove all and add all the new ones.
289         if (tiles.size() <= mRecords.size()) {
290             int i = 0;
291             // Iterate through the requested tiles and check if they are the same as the existing
292             // tiles.
293             for (QSTile tile : tiles) {
294                 if (tile != mRecords.get(i).tile) {
295                     shouldChangeAll = true;
296                     break;
297                 }
298                 i++;
299             }
300 
301             // If the first tiles are the same as the new ones, we reuse them and remove any extra
302             // tiles.
303             if (!shouldChangeAll && i < mRecords.size()) {
304                 List<TileRecord> extraRecords = mRecords.subList(i, mRecords.size());
305                 for (QSPanelControllerBase.TileRecord record : extraRecords) {
306                     mView.removeTile(record);
307                     record.tile.removeCallback(record.callback);
308                 }
309                 extraRecords.clear();
310                 mCachedSpecs = getTilesSpecs();
311             }
312         } else {
313             shouldChangeAll = true;
314         }
315 
316         // If we detected that the existing tiles are different than the requested tiles, clear them
317         // and add the new tiles.
318         if (shouldChangeAll) {
319             for (QSPanelControllerBase.TileRecord record : mRecords) {
320                 mView.removeTile(record);
321                 record.tile.removeCallback(record.callback);
322             }
323             mRecords.clear();
324             mCachedSpecs = "";
325             for (QSTile tile : tiles) {
326                 addTile(tile, collapsedView);
327             }
328         } else {
329             for (QSPanelControllerBase.TileRecord record : mRecords) {
330                 record.tile.addCallback(record.callback);
331             }
332         }
333     }
334 
335     /** */
refreshAllTiles()336     public void refreshAllTiles() {
337         for (QSPanelControllerBase.TileRecord r : mRecords) {
338             if (!r.tile.isListening()) {
339                 // Only refresh tiles that were not already in the listening state. Tiles that are
340                 // already listening is as if they are already expanded (for example, tiles that
341                 // are both in QQS and QS).
342                 r.tile.refreshState();
343             }
344         }
345     }
346 
addTile(final QSTile tile, boolean collapsedView)347     private void addTile(final QSTile tile, boolean collapsedView) {
348         QSLongPressEffect longPressEffect;
349         if (quickSettingsVisualHapticsLongpress()) {
350             longPressEffect = mLongPressEffectProvider.get();
351         } else {
352             longPressEffect = null;
353         }
354         final QSTileViewImpl tileView = new QSTileViewImpl(
355                 getContext(), collapsedView, longPressEffect);
356         final TileRecord r = new TileRecord(tile, tileView);
357         // TODO(b/250618218): Remove the QSLogger in QSTileViewImpl once we know the root cause of
358         // b/250618218.
359         try {
360             QSTileViewImpl qsTileView = (QSTileViewImpl) (r.tileView);
361             if (qsTileView != null) {
362                 qsTileView.setQsLogger(mQSLogger);
363             }
364         } catch (ClassCastException e) {
365             Log.e(TAG, "Failed to cast QSTileView to QSTileViewImpl", e);
366         }
367         mView.addTile(r);
368         mRecords.add(r);
369         mCachedSpecs = getTilesSpecs();
370     }
371 
372     /** */
clickTile(ComponentName tile)373     public void clickTile(ComponentName tile) {
374         final String spec = CustomTile.toSpec(tile);
375         for (TileRecord record : mRecords) {
376             if (record.tile.getTileSpec().equals(spec)) {
377                 record.tile.click(null /* view */);
378                 break;
379             }
380         }
381     }
382 
areThereTiles()383     boolean areThereTiles() {
384         return !mRecords.isEmpty();
385     }
386 
387     @Nullable
getTileView(QSTile tile)388     QSTileView getTileView(QSTile tile) {
389         for (QSPanelControllerBase.TileRecord r : mRecords) {
390             if (r.tile == tile) {
391                 return r.tileView;
392             }
393         }
394         return null;
395     }
396 
getTileView(String spec)397     QSTileView getTileView(String spec) {
398         for (QSPanelControllerBase.TileRecord r : mRecords) {
399             if (Objects.equals(r.tile.getTileSpec(), spec)) {
400                 return r.tileView;
401             }
402         }
403         return null;
404     }
405 
getTilesSpecs()406     private String getTilesSpecs() {
407         return mRecords.stream()
408                 .map(tileRecord ->  tileRecord.tile.getTileSpec())
409                 .collect(Collectors.joining(","));
410     }
411 
412     /** */
setExpanded(boolean expanded)413     public void setExpanded(boolean expanded) {
414         if (mView.isExpanded() == expanded) {
415             return;
416         }
417         mQSLogger.logPanelExpanded(expanded, mView.getDumpableTag());
418 
419         mView.setExpanded(expanded);
420         mMetricsLogger.visibility(MetricsEvent.QS_PANEL, expanded);
421         if (!expanded) {
422             mUiEventLogger.log(mView.closePanelEvent());
423             closeDetail();
424         } else {
425             mUiEventLogger.log(mView.openPanelEvent());
426             logTiles();
427         }
428     }
429 
430     /** */
closeDetail()431     public void closeDetail() {
432         if (mQsCustomizerController.isShown()) {
433             mQsCustomizerController.hide();
434             return;
435         }
436     }
437 
setListening(boolean listening)438     void setListening(boolean listening) {
439         if (mView.isListening() == listening) return;
440         mView.setListening(listening);
441 
442         if (mView.getTileLayout() != null) {
443             mQSLogger.logAllTilesChangeListening(listening, mView.getDumpableTag(), mCachedSpecs);
444             mView.getTileLayout().setListening(listening, mUiEventLogger);
445         }
446 
447         if (mView.isListening()) {
448             refreshAllTiles();
449         }
450     }
451 
switchTileLayoutIfNeeded()452     private void switchTileLayoutIfNeeded() {
453         switchTileLayout(/* force= */ false);
454     }
455 
switchTileLayout(boolean force)456     boolean switchTileLayout(boolean force) {
457         /* Whether or not the panel currently contains a media player. */
458         boolean horizontal = shouldUseHorizontalLayout();
459         if ((!SceneContainerFlag.isEnabled() && horizontal != mUsingHorizontalLayout) || force) {
460             mQSLogger.logSwitchTileLayout(horizontal, mUsingHorizontalLayout, force,
461                     mView.getDumpableTag());
462             mUsingHorizontalLayout = horizontal;
463             mView.setUsingHorizontalLayout(mUsingHorizontalLayout, mMediaHost.getHostView(), force);
464             updateMediaDisappearParameters();
465             if (mUsingHorizontalLayoutChangedListener != null) {
466                 mUsingHorizontalLayoutChangedListener.run();
467             }
468             return true;
469         }
470         return false;
471     }
472 
setLayoutForMediaInScene()473     private void setLayoutForMediaInScene() {
474         boolean withMedia = shouldUseHorizontalInScene();
475         mView.setColumnRowLayout(withMedia);
476     }
477 
478     /**
479      * Update the way the media disappears based on if we're using the horizontal layout
480      */
updateMediaDisappearParameters()481     void updateMediaDisappearParameters() {
482         if (!mUsingMediaPlayer) {
483             return;
484         }
485         DisappearParameters parameters = mMediaHost.getDisappearParameters();
486         if (mUsingHorizontalLayout) {
487             // Only height remaining
488             parameters.getDisappearSize().set(0.0f, 0.4f);
489             // Disappearing on the right side on the top
490             parameters.getGonePivot().set(1.0f, 0.0f);
491             // translating a bit horizontal
492             parameters.getContentTranslationFraction().set(0.25f, 1.0f);
493             parameters.setDisappearEnd(0.6f);
494         } else {
495             // Only width remaining
496             parameters.getDisappearSize().set(1.0f, 0.0f);
497             // Disappearing on the top
498             parameters.getGonePivot().set(0.0f, 0.0f);
499             // translating a bit vertical
500             parameters.getContentTranslationFraction().set(0.0f, 1f);
501             parameters.setDisappearEnd(0.95f);
502         }
503         parameters.setFadeStartPosition(0.95f);
504         parameters.setDisappearStart(0.0f);
505         mMediaHost.setDisappearParameters(parameters);
506     }
507 
shouldUseHorizontalLayout()508     boolean shouldUseHorizontalLayout() {
509         if (mShouldUseSplitNotificationShade) {
510             return false;
511         }
512         return mUsingMediaPlayer && mMediaHost.getVisible()
513                 && mLastOrientation == Configuration.ORIENTATION_LANDSCAPE
514                 && (mLastScreenLayout & Configuration.SCREENLAYOUT_LONG_MASK)
515                 == Configuration.SCREENLAYOUT_LONG_YES;
516     }
517 
shouldUseHorizontalInScene()518     boolean shouldUseHorizontalInScene() {
519         if (mShouldUseSplitNotificationShade) {
520             return false;
521         }
522         return mMediaVisibleFromInteractor
523                 && mLastOrientation == Configuration.ORIENTATION_LANDSCAPE
524                 && (mLastScreenLayout & Configuration.SCREENLAYOUT_LONG_MASK)
525                 == Configuration.SCREENLAYOUT_LONG_YES;
526     }
527 
logTiles()528     private void logTiles() {
529         for (int i = 0; i < mRecords.size(); i++) {
530             QSTile tile = mRecords.get(i).tile;
531             mMetricsLogger.write(tile.populate(new LogMaker(tile.getMetricsCategory())
532                     .setType(MetricsEvent.TYPE_OPEN)));
533         }
534     }
535 
536     /** Set the expansion on the associated {@link QSTileRevealController}. */
setRevealExpansion(float expansion)537     public void setRevealExpansion(float expansion) {
538         mRevealExpansion = expansion;
539         if (mQsTileRevealController != null) {
540             mQsTileRevealController.setExpansion(expansion);
541         }
542     }
543 
544     @Override
dump(PrintWriter pw, String[] args)545     public void dump(PrintWriter pw, String[] args) {
546         pw.println(getClass().getSimpleName() + ":");
547         pw.println("  Tile records:");
548         for (QSPanelControllerBase.TileRecord record : mRecords) {
549             if (record.tile instanceof Dumpable) {
550                 pw.print("    "); ((Dumpable) record.tile).dump(pw, args);
551                 pw.print("    "); pw.println(record.tileView.toString());
552             }
553         }
554         if (mMediaHost != null) {
555             pw.println("  media bounds: " + mMediaHost.getCurrentBounds());
556             pw.println("  horizontal layout: " + mUsingHorizontalLayout);
557             pw.println("  last orientation: " + mLastOrientation);
558         }
559         pw.println("  mShouldUseSplitNotificationShade: " + mShouldUseSplitNotificationShade);
560     }
561 
getTileLayout()562     public QSPanel.QSTileLayout getTileLayout() {
563         return mView.getTileLayout();
564     }
565 
566     /**
567      * Add a listener for when the media visibility changes.
568      */
setMediaVisibilityChangedListener(@onNull Consumer<Boolean> listener)569     public void setMediaVisibilityChangedListener(@NonNull Consumer<Boolean> listener) {
570         mMediaVisibilityChangedListener = listener;
571     }
572 
573     /**
574      * Add a listener when the horizontal layout changes
575      */
setUsingHorizontalLayoutChangeListener(Runnable listener)576     public void setUsingHorizontalLayoutChangeListener(Runnable listener) {
577         mUsingHorizontalLayoutChangedListener = listener;
578     }
579 
580     @Nullable
getBrightnessView()581     public View getBrightnessView() {
582         return mView.getBrightnessView();
583     }
584 
585     /**
586      * Set a listener to collapse/expand QS.
587      * @param action
588      */
setCollapseExpandAction(Runnable action)589     public void setCollapseExpandAction(Runnable action) {
590         mView.setCollapseExpandAction(action);
591     }
592 
593     /** Sets whether we are currently on lock screen. */
setIsOnKeyguard(boolean isOnKeyguard)594     public void setIsOnKeyguard(boolean isOnKeyguard) {
595         boolean isOnSplitShadeLockscreen = mShouldUseSplitNotificationShade && isOnKeyguard;
596         // When the split shade is expanding on lockscreen, the media container transitions from the
597         // lockscreen to QS.
598         // We have to prevent the media container position from moving during the transition to have
599         // a smooth translation animation without stuttering.
600         mView.setShouldMoveMediaOnExpansion(!isOnSplitShadeLockscreen);
601     }
602 
603     /** */
604     public static final class TileRecord {
TileRecord(QSTile tile, com.android.systemui.plugins.qs.QSTileView tileView)605         public TileRecord(QSTile tile, com.android.systemui.plugins.qs.QSTileView tileView) {
606             this.tile = tile;
607             this.tileView = tileView;
608         }
609 
610         public QSTile tile;
611         public com.android.systemui.plugins.qs.QSTileView tileView;
612         @Nullable
613         public QSTile.Callback callback;
614     }
615 }
616