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