1 /* 2 * Copyright (C) 2022 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.accessibility.floatingmenu; 18 19 import static android.view.ViewGroup.LayoutParams.WRAP_CONTENT; 20 21 import android.annotation.SuppressLint; 22 import android.content.ComponentCallbacks; 23 import android.content.Context; 24 import android.content.res.Configuration; 25 import android.graphics.PointF; 26 import android.graphics.Rect; 27 import android.graphics.drawable.GradientDrawable; 28 import android.view.ViewGroup; 29 import android.view.ViewTreeObserver; 30 import android.widget.FrameLayout; 31 32 import androidx.annotation.NonNull; 33 import androidx.lifecycle.Observer; 34 import androidx.recyclerview.widget.DiffUtil; 35 import androidx.recyclerview.widget.LinearLayoutManager; 36 import androidx.recyclerview.widget.RecyclerView; 37 38 import com.android.internal.accessibility.dialog.AccessibilityTarget; 39 import com.android.modules.expresslog.Counter; 40 import com.android.settingslib.bluetooth.HearingAidDeviceManager; 41 import com.android.systemui.Flags; 42 import com.android.systemui.util.settings.SecureSettings; 43 44 import java.util.ArrayList; 45 import java.util.Collections; 46 import java.util.List; 47 48 /** 49 * The container view displays the accessibility features. 50 */ 51 @SuppressLint("ViewConstructor") 52 class MenuView extends FrameLayout implements 53 ViewTreeObserver.OnComputeInternalInsetsListener, ComponentCallbacks { 54 private static final int INDEX_MENU_ITEM = 0; 55 private final List<AccessibilityTarget> mTargetFeatures = new ArrayList<>(); 56 private final AccessibilityTargetAdapter mAdapter; 57 private final MenuViewModel mMenuViewModel; 58 private final Rect mBoundsInParent = new Rect(); 59 private final RecyclerView mTargetFeaturesView; 60 private final ViewTreeObserver.OnDrawListener mSystemGestureExcludeUpdater = 61 this::updateSystemGestureExcludeRects; 62 private final Observer<MenuFadeEffectInfo> mFadeEffectInfoObserver = 63 this::onMenuFadeEffectInfoChanged; 64 private final Observer<Boolean> mMoveToTuckedObserver = this::onMoveToTucked; 65 private final Observer<Position> mPercentagePositionObserver = this::onPercentagePosition; 66 private final Observer<Integer> mSizeTypeObserver = this::onSizeTypeChanged; 67 private final Observer<List<AccessibilityTarget>> mTargetFeaturesObserver = 68 this::onTargetFeaturesChanged; 69 private final Observer<Integer> mHearingDeviceStatusObserver = 70 this::updateHearingDeviceStatus; 71 private final Observer<Integer> mHearingDeviceTargetIndexObserver = 72 this::updateHearingDeviceTargetIndex; 73 private final MenuViewAppearance mMenuViewAppearance; 74 private boolean mIsMoveToTucked; 75 76 private final MenuAnimationController mMenuAnimationController; 77 private OnTargetFeaturesChangeListener mFeaturesChangeListener; 78 private OnMoveToTuckedListener mMoveToTuckedListener; 79 private SecureSettings mSecureSettings; 80 MenuView(Context context, MenuViewModel menuViewModel, MenuViewAppearance menuViewAppearance, SecureSettings secureSettings)81 MenuView(Context context, MenuViewModel menuViewModel, MenuViewAppearance menuViewAppearance, 82 SecureSettings secureSettings) { 83 super(context); 84 85 mMenuViewModel = menuViewModel; 86 mMenuViewAppearance = menuViewAppearance; 87 mSecureSettings = secureSettings; 88 mMenuAnimationController = new MenuAnimationController(this, menuViewAppearance); 89 mAdapter = new AccessibilityTargetAdapter(mTargetFeatures); 90 mTargetFeaturesView = new RecyclerView(context); 91 mTargetFeaturesView.setAdapter(mAdapter); 92 mTargetFeaturesView.setLayoutManager(new LinearLayoutManager(context)); 93 mTargetFeaturesView.setClipChildren(false); 94 setLayoutParams(new FrameLayout.LayoutParams(WRAP_CONTENT, WRAP_CONTENT)); 95 // Avoid drawing out of bounds of the parent view 96 setClipToOutline(true); 97 98 loadLayoutResources(); 99 100 addView(mTargetFeaturesView); 101 102 setClickable(false); 103 setFocusable(false); 104 setImportantForAccessibility(IMPORTANT_FOR_ACCESSIBILITY_NO); 105 } 106 107 @Override onComputeInternalInsets(ViewTreeObserver.InternalInsetsInfo inoutInfo)108 public void onComputeInternalInsets(ViewTreeObserver.InternalInsetsInfo inoutInfo) { 109 inoutInfo.setTouchableInsets(ViewTreeObserver.InternalInsetsInfo.TOUCHABLE_INSETS_REGION); 110 if (getVisibility() == VISIBLE) { 111 inoutInfo.touchableRegion.union(mBoundsInParent); 112 } 113 } 114 115 @Override onConfigurationChanged(@onNull Configuration newConfig)116 public void onConfigurationChanged(@NonNull Configuration newConfig) { 117 loadLayoutResources(); 118 119 mTargetFeaturesView.setOverScrollMode(mMenuViewAppearance.getMenuScrollMode()); 120 } 121 122 @Override onLowMemory()123 public void onLowMemory() { 124 // Do nothing. 125 } 126 127 @Override onAttachedToWindow()128 protected void onAttachedToWindow() { 129 super.onAttachedToWindow(); 130 131 getContext().registerComponentCallbacks(this); 132 } 133 134 @Override onDetachedFromWindow()135 protected void onDetachedFromWindow() { 136 super.onDetachedFromWindow(); 137 138 getContext().unregisterComponentCallbacks(this); 139 } 140 setOnTargetFeaturesChangeListener(OnTargetFeaturesChangeListener listener)141 void setOnTargetFeaturesChangeListener(OnTargetFeaturesChangeListener listener) { 142 mFeaturesChangeListener = listener; 143 } 144 setMoveToTuckedListener(OnMoveToTuckedListener listener)145 void setMoveToTuckedListener(OnMoveToTuckedListener listener) { 146 mMoveToTuckedListener = listener; 147 } 148 addOnItemTouchListenerToList(RecyclerView.OnItemTouchListener listener)149 void addOnItemTouchListenerToList(RecyclerView.OnItemTouchListener listener) { 150 mTargetFeaturesView.addOnItemTouchListener(listener); 151 } 152 getMenuAnimationController()153 MenuAnimationController getMenuAnimationController() { 154 return mMenuAnimationController; 155 } 156 157 @SuppressLint("NotifyDataSetChanged") onItemSizeChanged()158 private void onItemSizeChanged() { 159 mAdapter.setItemPadding(mMenuViewAppearance.getMenuPadding()); 160 mAdapter.setIconWidthHeight(mMenuViewAppearance.getMenuIconSize()); 161 mAdapter.setBadgeWidthHeight(mMenuViewAppearance.getBadgeIconSize()); 162 mAdapter.notifyDataSetChanged(); 163 } 164 165 @SuppressLint("NotifyDataSetChanged") onSideChanged()166 void onSideChanged() { 167 // Badge should be on different side of Menu view's side. 168 mAdapter.setBadgeOnLeftSide(!mMenuViewAppearance.isMenuOnLeftSide()); 169 mAdapter.notifyDataSetChanged(); 170 } 171 onSizeChanged()172 private void onSizeChanged() { 173 mBoundsInParent.set(mBoundsInParent.left, mBoundsInParent.top, 174 mBoundsInParent.left + mMenuViewAppearance.getMenuWidth(), 175 mBoundsInParent.top + mMenuViewAppearance.getMenuHeight()); 176 177 final FrameLayout.LayoutParams layoutParams = (FrameLayout.LayoutParams) getLayoutParams(); 178 layoutParams.height = mMenuViewAppearance.getMenuHeight(); 179 setLayoutParams(layoutParams); 180 } 181 onEdgeChangedIfNeeded()182 void onEdgeChangedIfNeeded() { 183 final Rect draggableBounds = mMenuViewAppearance.getMenuDraggableBounds(); 184 if (getTranslationX() != draggableBounds.left 185 && getTranslationX() != draggableBounds.right) { 186 return; 187 } 188 189 onEdgeChanged(); 190 } 191 onEdgeChanged()192 void onEdgeChanged() { 193 final int[] insets = mMenuViewAppearance.getMenuInsets(); 194 getContainerViewInsetLayer().setLayerInset(INDEX_MENU_ITEM, insets[0], insets[1], insets[2], 195 insets[3]); 196 197 final GradientDrawable gradientDrawable = getContainerViewGradient(); 198 gradientDrawable.setStroke(mMenuViewAppearance.getMenuStrokeWidth(), 199 mMenuViewAppearance.getMenuStrokeColor()); 200 mMenuAnimationController.startRadiiAnimation(mMenuViewAppearance.getMenuRadii()); 201 } 202 setRadii(float[] radii)203 void setRadii(float[] radii) { 204 getContainerViewGradient().setCornerRadii(radii); 205 } 206 onMoveToTucked(boolean isMoveToTucked)207 private void onMoveToTucked(boolean isMoveToTucked) { 208 mIsMoveToTucked = isMoveToTucked; 209 210 onPositionChanged(); 211 } 212 onPercentagePosition(Position percentagePosition)213 private void onPercentagePosition(Position percentagePosition) { 214 mMenuViewAppearance.setPercentagePosition(percentagePosition); 215 216 onPositionChanged(); 217 onSideChanged(); 218 } 219 onPositionChanged()220 void onPositionChanged() { 221 onPositionChanged(/* animateMovement = */ false); 222 } 223 onPositionChanged(boolean animateMovement)224 void onPositionChanged(boolean animateMovement) { 225 final PointF position; 226 if (isMoveToTucked()) { 227 position = mMenuAnimationController.getTuckedMenuPosition(); 228 } else { 229 position = getMenuPosition(); 230 } 231 232 // We can skip animating if FAB is not visible 233 if (animateMovement && getVisibility() == VISIBLE) { 234 mMenuAnimationController.moveToPosition(position, /* animateMovement = */ true); 235 // onArrivalAtPosition() is called at the end of the animation. 236 } else { 237 mMenuAnimationController.moveToPosition(position); 238 onArrivalAtPosition(true); // no animation, so we call this immediately. 239 } 240 } 241 onArrivalAtPosition(boolean moveToEdgeIfTucked)242 void onArrivalAtPosition(boolean moveToEdgeIfTucked) { 243 final PointF position = getMenuPosition(); 244 onBoundsInParentChanged((int) position.x, (int) position.y); 245 246 if (isMoveToTucked() && moveToEdgeIfTucked) { 247 mMenuAnimationController.moveToEdgeAndHide(); 248 } 249 } 250 251 @SuppressLint("NotifyDataSetChanged") onSizeTypeChanged(int newSizeType)252 private void onSizeTypeChanged(int newSizeType) { 253 mMenuAnimationController.fadeInNowIfEnabled(); 254 255 mMenuViewAppearance.setSizeType(newSizeType); 256 257 mAdapter.setItemPadding(mMenuViewAppearance.getMenuPadding()); 258 mAdapter.setIconWidthHeight(mMenuViewAppearance.getMenuIconSize()); 259 mAdapter.setBadgeWidthHeight(mMenuViewAppearance.getBadgeIconSize()); 260 261 mAdapter.notifyDataSetChanged(); 262 263 onSizeChanged(); 264 onEdgeChanged(); 265 onPositionChanged(); 266 267 mMenuAnimationController.fadeOutIfEnabled(); 268 } 269 onTargetFeaturesChanged(List<AccessibilityTarget> newTargetFeatures)270 private void onTargetFeaturesChanged(List<AccessibilityTarget> newTargetFeatures) { 271 mMenuAnimationController.fadeInNowIfEnabled(); 272 273 final List<AccessibilityTarget> targetFeatures = 274 Collections.unmodifiableList(mTargetFeatures.stream().toList()); 275 mTargetFeatures.clear(); 276 mTargetFeatures.addAll(newTargetFeatures); 277 mMenuViewAppearance.setTargetFeaturesSize(newTargetFeatures.size()); 278 mTargetFeaturesView.setOverScrollMode(mMenuViewAppearance.getMenuScrollMode()); 279 DiffUtil.calculateDiff( 280 new MenuTargetsCallback(targetFeatures, newTargetFeatures)).dispatchUpdatesTo( 281 mAdapter); 282 283 onSizeChanged(); 284 onEdgeChanged(); 285 onPositionChanged(); 286 287 boolean shouldSendFeatureChangeNotification = 288 com.android.systemui.Flags.floatingMenuNotifyTargetsChangedOnStrictDiff() 289 ? !areFeatureListsIdentical(targetFeatures, newTargetFeatures) 290 : true; 291 if (mFeaturesChangeListener != null && shouldSendFeatureChangeNotification) { 292 mFeaturesChangeListener.onChange(newTargetFeatures); 293 } 294 295 mMenuAnimationController.fadeOutIfEnabled(); 296 } 297 298 /** 299 * Returns true if the given feature lists are identical lists, i.e. the same list of {@link 300 * AccessibilityTarget} (equality checked via UID) in the same order. 301 */ areFeatureListsIdentical( List<AccessibilityTarget> currentFeatures, List<AccessibilityTarget> newFeatures)302 private boolean areFeatureListsIdentical( 303 List<AccessibilityTarget> currentFeatures, List<AccessibilityTarget> newFeatures) { 304 if (currentFeatures.size() != newFeatures.size()) { 305 return false; 306 } 307 308 for (int i = 0; i < currentFeatures.size(); i++) { 309 if (currentFeatures.get(i).getUid() != newFeatures.get(i).getUid()) { 310 return false; 311 } 312 } 313 314 return true; 315 } 316 onMenuFadeEffectInfoChanged(MenuFadeEffectInfo fadeEffectInfo)317 private void onMenuFadeEffectInfoChanged(MenuFadeEffectInfo fadeEffectInfo) { 318 mMenuAnimationController.updateOpacityWith(fadeEffectInfo.isFadeEffectEnabled(), 319 fadeEffectInfo.getOpacity()); 320 } 321 getMenuDraggableBounds()322 Rect getMenuDraggableBounds() { 323 return mMenuViewAppearance.getMenuDraggableBounds(); 324 } 325 getMenuDraggableBoundsExcludeIme()326 Rect getMenuDraggableBoundsExcludeIme() { 327 return mMenuViewAppearance.getMenuDraggableBoundsExcludeIme(); 328 } 329 getMenuHeight()330 int getMenuHeight() { 331 return mMenuViewAppearance.getMenuHeight(); 332 } 333 getMenuWidth()334 int getMenuWidth() { 335 return mMenuViewAppearance.getMenuWidth(); 336 } 337 getMenuPosition()338 PointF getMenuPosition() { 339 return mMenuViewAppearance.getMenuPosition(); 340 } 341 getTargetFeaturesView()342 RecyclerView getTargetFeaturesView() { 343 return mTargetFeaturesView; 344 } 345 persistPositionAndUpdateEdge(Position percentagePosition)346 void persistPositionAndUpdateEdge(Position percentagePosition) { 347 mMenuViewModel.updateMenuSavingPosition(percentagePosition); 348 mMenuViewAppearance.setPercentagePosition(percentagePosition); 349 350 onEdgeChangedIfNeeded(); 351 onSideChanged(); 352 } 353 isMoveToTucked()354 boolean isMoveToTucked() { 355 return mIsMoveToTucked; 356 } 357 updateMenuMoveToTucked(boolean isMoveToTucked)358 void updateMenuMoveToTucked(boolean isMoveToTucked) { 359 mIsMoveToTucked = isMoveToTucked; 360 mMenuViewModel.updateMenuMoveToTucked(isMoveToTucked); 361 if (mMoveToTuckedListener != null) { 362 mMoveToTuckedListener.onMoveToTuckedChanged(isMoveToTucked); 363 } 364 } 365 366 367 /** 368 * Uses the touch events from the parent view to identify if users clicked the extra 369 * space of the menu view. If yes, will use the percentage position and update the 370 * translations of the menu view to meet the effect of moving out from the edge. It’s only 371 * used when the menu view is hidden to the screen edge. 372 * 373 * @param x the current x of the touch event from the parent {@link MenuViewLayer} of the 374 * {@link MenuView}. 375 * @param y the current y of the touch event from the parent {@link MenuViewLayer} of the 376 * {@link MenuView}. 377 * @return true if consume the touch event, otherwise false. 378 */ maybeMoveOutEdgeAndShow(int x, int y)379 boolean maybeMoveOutEdgeAndShow(int x, int y) { 380 // Utilizes the touch region of the parent view to implement that users could tap extra 381 // the space region to show the menu from the edge. 382 if (!isMoveToTucked() || !mBoundsInParent.contains(x, y)) { 383 return false; 384 } 385 386 mMenuAnimationController.fadeInNowIfEnabled(); 387 388 mMenuAnimationController.moveOutEdgeAndShow(); 389 390 mMenuAnimationController.fadeOutIfEnabled(); 391 return true; 392 } 393 show()394 void show() { 395 mMenuViewModel.getPercentagePositionData().observeForever(mPercentagePositionObserver); 396 mMenuViewModel.getFadeEffectInfoData().observeForever(mFadeEffectInfoObserver); 397 mMenuViewModel.getTargetFeaturesData().observeForever(mTargetFeaturesObserver); 398 mMenuViewModel.getSizeTypeData().observeForever(mSizeTypeObserver); 399 mMenuViewModel.getMoveToTuckedData().observeForever(mMoveToTuckedObserver); 400 if (com.android.settingslib.flags.Flags.hearingDeviceSetConnectionStatusReport()) { 401 mMenuViewModel.loadHearingDeviceStatus().observeForever(mHearingDeviceStatusObserver); 402 mMenuViewModel.getHearingDeviceTargetIndexData().observeForever( 403 mHearingDeviceTargetIndexObserver); 404 } 405 setVisibility(VISIBLE); 406 mMenuViewModel.registerObserversAndCallbacks(); 407 getViewTreeObserver().addOnComputeInternalInsetsListener(this); 408 getViewTreeObserver().addOnDrawListener(mSystemGestureExcludeUpdater); 409 } 410 hide()411 void hide() { 412 setVisibility(GONE); 413 mBoundsInParent.setEmpty(); 414 mMenuViewModel.getPercentagePositionData().removeObserver(mPercentagePositionObserver); 415 mMenuViewModel.getFadeEffectInfoData().removeObserver(mFadeEffectInfoObserver); 416 mMenuViewModel.getTargetFeaturesData().removeObserver(mTargetFeaturesObserver); 417 mMenuViewModel.getSizeTypeData().removeObserver(mSizeTypeObserver); 418 mMenuViewModel.getMoveToTuckedData().removeObserver(mMoveToTuckedObserver); 419 mMenuViewModel.getHearingDeviceStatusData().removeObserver(mHearingDeviceStatusObserver); 420 mMenuViewModel.getHearingDeviceTargetIndexData().removeObserver( 421 mHearingDeviceTargetIndexObserver); 422 mMenuViewModel.unregisterObserversAndCallbacks(); 423 getViewTreeObserver().removeOnComputeInternalInsetsListener(this); 424 getViewTreeObserver().removeOnDrawListener(mSystemGestureExcludeUpdater); 425 } 426 onDraggingStart()427 void onDraggingStart() { 428 final int[] insets = mMenuViewAppearance.getMenuMovingStateInsets(); 429 getContainerViewInsetLayer().setLayerInset(INDEX_MENU_ITEM, insets[0], insets[1], insets[2], 430 insets[3]); 431 432 mMenuAnimationController.startRadiiAnimation( 433 mMenuViewAppearance.getMenuMovingStateRadii()); 434 } 435 onBoundsInParentChanged(int newLeft, int newTop)436 void onBoundsInParentChanged(int newLeft, int newTop) { 437 mBoundsInParent.offsetTo(newLeft, newTop); 438 } 439 loadLayoutResources()440 void loadLayoutResources() { 441 mMenuViewAppearance.update(); 442 443 mTargetFeaturesView.setContentDescription(mMenuViewAppearance.getContentDescription()); 444 setBackground(mMenuViewAppearance.getMenuBackground()); 445 setElevation(mMenuViewAppearance.getMenuElevation()); 446 onItemSizeChanged(); 447 onSizeChanged(); 448 onEdgeChanged(); 449 onPositionChanged(); 450 } 451 incrementTexMetric(String metric)452 void incrementTexMetric(String metric) { 453 if (!Flags.floatingMenuDragToEdit()) { 454 return; 455 } 456 Counter.logIncrement(metric); 457 } 458 getContainerViewInsetLayer()459 private InstantInsetLayerDrawable getContainerViewInsetLayer() { 460 return (InstantInsetLayerDrawable) getBackground(); 461 } 462 getContainerViewGradient()463 private GradientDrawable getContainerViewGradient() { 464 return (GradientDrawable) getContainerViewInsetLayer().getDrawable(INDEX_MENU_ITEM); 465 } 466 updateSystemGestureExcludeRects()467 private void updateSystemGestureExcludeRects() { 468 final ViewGroup parentView = (ViewGroup) getParent(); 469 parentView.setSystemGestureExclusionRects(Collections.singletonList(mBoundsInParent)); 470 } 471 updateHearingDeviceStatus(@earingAidDeviceManager.ConnectionStatus int status)472 private void updateHearingDeviceStatus(@HearingAidDeviceManager.ConnectionStatus int status) { 473 final int haStatus = mMenuViewModel.getHearingDeviceStatusData().getValue(); 474 final int haPosition = mMenuViewModel.getHearingDeviceTargetIndexData().getValue(); 475 if (haPosition >= 0) { 476 mContext.getMainExecutor().execute( 477 () -> mAdapter.onHearingDeviceStatusChanged(haStatus, haPosition)); 478 } 479 } 480 updateHearingDeviceTargetIndex(int position)481 private void updateHearingDeviceTargetIndex(int position) { 482 final int haStatus = mMenuViewModel.getHearingDeviceStatusData().getValue(); 483 final int haPosition = mMenuViewModel.getHearingDeviceTargetIndexData().getValue(); 484 if (haPosition >= 0) { 485 mContext.getMainExecutor().execute( 486 () -> mAdapter.onHearingDeviceStatusChanged(haStatus, haPosition)); 487 } 488 } 489 490 /** 491 * Interface definition for the {@link AccessibilityTarget} list changes. 492 */ 493 interface OnTargetFeaturesChangeListener { 494 /** 495 * Called when the list of accessibility target features was updated. This will be 496 * invoked when the end of {@code onTargetFeaturesChanged}. 497 * 498 * @param newTargetFeatures the list related to the current accessibility features. 499 */ onChange(List<AccessibilityTarget> newTargetFeatures)500 void onChange(List<AccessibilityTarget> newTargetFeatures); 501 } 502 503 /** 504 * Interface containing a callback for when MoveToTucked changes. 505 */ 506 interface OnMoveToTuckedListener { onMoveToTuckedChanged(boolean moveToTucked)507 void onMoveToTuckedChanged(boolean moveToTucked); 508 } 509 } 510