1 /* 2 * Copyright (C) 2021 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.internal.view.inline; 17 18 import static android.view.autofill.AutofillFeatureFlags.DEVICE_CONFIG_AUTOFILL_TOOLTIP_SHOW_UP_DELAY; 19 import static android.view.autofill.Helper.sVerbose; 20 21 import android.annotation.NonNull; 22 import android.annotation.Nullable; 23 import android.content.Context; 24 import android.content.ContextWrapper; 25 import android.graphics.Rect; 26 import android.graphics.drawable.Drawable; 27 import android.provider.DeviceConfig; 28 import android.provider.Settings; 29 import android.transition.Transition; 30 import android.util.Slog; 31 import android.view.Gravity; 32 import android.view.View; 33 import android.view.ViewGroup; 34 import android.view.ViewParent; 35 import android.view.WindowManager; 36 import android.widget.LinearLayout; 37 import android.widget.PopupWindow; 38 import android.widget.inline.InlineContentView; 39 40 import java.io.PrintWriter; 41 import java.lang.ref.WeakReference; 42 43 /** 44 * UI container for the inline suggestion tooltip. 45 */ 46 public final class InlineTooltipUi extends PopupWindow implements AutoCloseable { 47 private static final String TAG = "InlineTooltipUi"; 48 49 private static final int FIRST_TIME_SHOW_DEFAULT_DELAY_MS = 250; 50 51 private final WindowManager mWm; 52 private final ViewGroup mContentContainer; 53 54 private boolean mShowing; 55 56 private WindowManager.LayoutParams mWindowLayoutParams; 57 58 private DelayShowRunnable mDelayShowTooltip; 59 60 private boolean mHasEverDetached; 61 62 private boolean mDelayShowAtStart = true; 63 private boolean mDelaying = false; 64 private int mShowDelayConfigMs; 65 66 private final Rect mTmpRect = new Rect(); 67 68 private final View.OnAttachStateChangeListener mAnchorOnAttachStateChangeListener = 69 new View.OnAttachStateChangeListener() { 70 @Override 71 public void onViewAttachedToWindow(View v) { 72 /* ignore - handled by the super class */ 73 } 74 75 @Override 76 public void onViewDetachedFromWindow(View v) { 77 mHasEverDetached = true; 78 dismiss(); 79 } 80 }; 81 82 private final View.OnLayoutChangeListener mAnchoredOnLayoutChangeListener = 83 new View.OnLayoutChangeListener() { 84 int mHeight; 85 @Override 86 public void onLayoutChange(View v, int left, int top, int right, int bottom, 87 int oldLeft, int oldTop, int oldRight, int oldBottom) { 88 if (mHasEverDetached) { 89 // If the tooltip is ever detached, skip adjusting the position, 90 // because it only accepts to attach once and does not show again 91 // after detaching. 92 return; 93 } 94 95 if (mHeight != bottom - top) { 96 mHeight = bottom - top; 97 adjustPosition(); 98 } 99 } 100 }; 101 InlineTooltipUi(@onNull Context context)102 public InlineTooltipUi(@NonNull Context context) { 103 mContentContainer = new LinearLayout(new ContextWrapper(context)); 104 mWm = context.getSystemService(WindowManager.class); 105 106 // That's a default delay time, and it will scale via the value of 107 // Settings.Global.ANIMATOR_DURATION_SCALE 108 mShowDelayConfigMs = DeviceConfig.getInt( 109 DeviceConfig.NAMESPACE_AUTOFILL, 110 DEVICE_CONFIG_AUTOFILL_TOOLTIP_SHOW_UP_DELAY, 111 FIRST_TIME_SHOW_DEFAULT_DELAY_MS); 112 113 setTouchModal(false); 114 setOutsideTouchable(true); 115 setInputMethodMode(INPUT_METHOD_NOT_NEEDED); 116 setFocusable(false); 117 } 118 119 /** 120 * Sets the content view for inline suggestions tooltip 121 * @param v the content view of {@link android.widget.inline.InlineContentView} 122 */ setTooltipView(@onNull InlineContentView v)123 public void setTooltipView(@NonNull InlineContentView v) { 124 mContentContainer.removeAllViews(); 125 mContentContainer.addView(v); 126 mContentContainer.setVisibility(View.VISIBLE); 127 } 128 129 @Override close()130 public void close() { 131 dismiss(); 132 } 133 134 @Override hasContentView()135 protected boolean hasContentView() { 136 return true; 137 } 138 139 @Override hasDecorView()140 protected boolean hasDecorView() { 141 return true; 142 } 143 144 @Override getDecorViewLayoutParams()145 protected WindowManager.LayoutParams getDecorViewLayoutParams() { 146 return mWindowLayoutParams; 147 } 148 149 /** 150 * The effective {@code update} method that should be called by its clients. 151 */ update(View anchor)152 public void update(View anchor) { 153 if (anchor == null) { 154 final View oldAnchor = getAnchor(); 155 if (oldAnchor != null) { 156 removeDelayShowTooltip(oldAnchor); 157 } 158 return; 159 } 160 161 if (mDelayShowAtStart) { 162 // To avoid showing when the anchor is doing the fade in animation. That will 163 // cause the tooltip to show in the wrong position and jump at the start. 164 mDelayShowAtStart = false; 165 mDelaying = true; 166 167 if (mDelayShowTooltip == null) { 168 mDelayShowTooltip = new DelayShowRunnable(anchor); 169 } 170 171 int delayTimeMs = mShowDelayConfigMs; 172 try { 173 final float scale = WindowManager.fixScale(Settings.Global.getFloat( 174 anchor.getContext().getContentResolver(), 175 Settings.Global.ANIMATOR_DURATION_SCALE)); 176 delayTimeMs *= scale; 177 } catch (Settings.SettingNotFoundException e) { 178 // do nothing 179 } 180 anchor.postDelayed(mDelayShowTooltip, delayTimeMs); 181 } else if (!mDelaying) { 182 // Note: If we are going to reuse the tooltip, we need to take care the delay in 183 // the case that update for the new anchor. 184 updateInner(anchor); 185 } 186 } 187 removeDelayShowTooltip(View anchor)188 private void removeDelayShowTooltip(View anchor) { 189 if (mDelayShowTooltip != null) { 190 anchor.removeCallbacks(mDelayShowTooltip); 191 mDelayShowTooltip = null; 192 } 193 } 194 updateInner(View anchor)195 private void updateInner(View anchor) { 196 if (mHasEverDetached) { 197 return; 198 } 199 // set to the application type with the highest z-order 200 setWindowLayoutType(WindowManager.LayoutParams.TYPE_APPLICATION_ABOVE_SUB_PANEL); 201 202 final int offsetY = -anchor.getHeight() - getPreferHeight(anchor); 203 204 if (!isShowing()) { 205 setWidth(WindowManager.LayoutParams.WRAP_CONTENT); 206 setHeight(WindowManager.LayoutParams.WRAP_CONTENT); 207 showAsDropDown(anchor, 0 , offsetY, Gravity.TOP | Gravity.CENTER_HORIZONTAL); 208 } else { 209 update(anchor, 0 , offsetY, WindowManager.LayoutParams.WRAP_CONTENT, 210 WindowManager.LayoutParams.WRAP_CONTENT); 211 } 212 } 213 getPreferHeight(View anchor)214 private int getPreferHeight(View anchor) { 215 // The first time to show up, the height of tooltip is zero, so make its height 216 // the same as anchor. 217 final int achoredHeight = mContentContainer.getHeight(); 218 return (achoredHeight == 0) ? anchor.getHeight() : achoredHeight; 219 } 220 221 @Override findDropDownPosition(View anchor, WindowManager.LayoutParams outParams, int xOffset, int yOffset, int width, int height, int gravity, boolean allowScroll)222 protected boolean findDropDownPosition(View anchor, WindowManager.LayoutParams outParams, 223 int xOffset, int yOffset, int width, int height, int gravity, boolean allowScroll) { 224 boolean isAbove = super.findDropDownPosition(anchor, outParams, xOffset, yOffset, width, 225 height, gravity, allowScroll); 226 // Make the tooltips y fo position is above or under the parent of the anchor, 227 // otherwise suggestions doesn't clickable. 228 ViewParent parent = anchor.getParent(); 229 if (parent instanceof View) { 230 final Rect r = mTmpRect; 231 ((View) parent).getGlobalVisibleRect(r); 232 if (isAbove) { 233 outParams.y = r.top - getPreferHeight(anchor); 234 } else { 235 outParams.y = r.bottom + 1; 236 } 237 } 238 239 return isAbove; 240 } 241 242 @Override update(View anchor, WindowManager.LayoutParams params)243 protected void update(View anchor, WindowManager.LayoutParams params) { 244 // update content view for the anchor is scrolling 245 if (anchor.isVisibleToUser()) { 246 show(params); 247 } else { 248 hide(); 249 } 250 } 251 252 @Override showAsDropDown(View anchor, int xoff, int yoff, int gravity)253 public void showAsDropDown(View anchor, int xoff, int yoff, int gravity) { 254 if (isShowing()) { 255 return; 256 } 257 258 setShowing(true); 259 setDropDown(true); 260 attachToAnchor(anchor, xoff, yoff, gravity); 261 final WindowManager.LayoutParams p = mWindowLayoutParams = createPopupLayoutParams( 262 anchor.getWindowToken()); 263 final boolean aboveAnchor = findDropDownPosition(anchor, p, xoff, yoff, 264 p.width, p.height, gravity, getAllowScrollingAnchorParent()); 265 updateAboveAnchor(aboveAnchor); 266 p.accessibilityIdOfAnchor = anchor.getAccessibilityViewId(); 267 p.packageName = anchor.getContext().getPackageName(); 268 show(p); 269 } 270 271 @Override attachToAnchor(View anchor, int xoff, int yoff, int gravity)272 protected void attachToAnchor(View anchor, int xoff, int yoff, int gravity) { 273 super.attachToAnchor(anchor, xoff, yoff, gravity); 274 anchor.addOnAttachStateChangeListener(mAnchorOnAttachStateChangeListener); 275 } 276 277 @Override detachFromAnchor()278 protected void detachFromAnchor() { 279 final View anchor = getAnchor(); 280 if (anchor != null) { 281 anchor.removeOnAttachStateChangeListener(mAnchorOnAttachStateChangeListener); 282 removeDelayShowTooltip(anchor); 283 } 284 mHasEverDetached = true; 285 super.detachFromAnchor(); 286 } 287 288 @Override dismiss()289 public void dismiss() { 290 if (!isShowing() || isTransitioningToDismiss()) { 291 return; 292 } 293 294 setTransitioningToDismiss(true); 295 296 hide(); 297 detachFromAnchor(); 298 if (getOnDismissListener() != null) { 299 getOnDismissListener().onDismiss(); 300 } 301 super.dismiss(); 302 } 303 adjustPosition()304 private void adjustPosition() { 305 View anchor = getAnchor(); 306 if (anchor == null) return; 307 update(anchor); 308 } 309 show(WindowManager.LayoutParams params)310 private void show(WindowManager.LayoutParams params) { 311 mWindowLayoutParams = params; 312 313 try { 314 params.packageName = "android"; 315 params.setTitle("Autofill Inline Tooltip"); // Title is set for debugging purposes 316 if (!mShowing) { 317 if (sVerbose) { 318 Slog.v(TAG, "show()"); 319 } 320 params.flags = WindowManager.LayoutParams.FLAG_NOT_TOUCH_MODAL 321 | WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE; 322 params.privateFlags |= 323 WindowManager.LayoutParams.PRIVATE_FLAG_NOT_MAGNIFIABLE; 324 mContentContainer.addOnLayoutChangeListener(mAnchoredOnLayoutChangeListener); 325 mWm.addView(mContentContainer, params); 326 mShowing = true; 327 } else { 328 mWm.updateViewLayout(mContentContainer, params); 329 } 330 } catch (WindowManager.BadTokenException e) { 331 Slog.d(TAG, "Failed with token " + params.token + " gone."); 332 } catch (IllegalStateException e) { 333 // WM throws an ISE if mContentView was added twice; this should never happen - 334 // since show() and hide() are always called in the UIThread - but when it does, 335 // it should not crash the system. 336 Slog.wtf(TAG, "Exception showing window " + params, e); 337 } 338 } 339 hide()340 private void hide() { 341 try { 342 if (mShowing) { 343 if (sVerbose) { 344 Slog.v(TAG, "hide()"); 345 } 346 mContentContainer.removeOnLayoutChangeListener(mAnchoredOnLayoutChangeListener); 347 mWm.removeView(mContentContainer); 348 mShowing = false; 349 } 350 } catch (IllegalStateException e) { 351 // WM might thrown an ISE when removing the mContentView; this should never 352 // happen - since show() and hide() are always called in the UIThread - but if it 353 // does, it should not crash the system. 354 Slog.e(TAG, "Exception hiding window ", e); 355 } 356 } 357 358 @Override getAnimationStyle()359 public int getAnimationStyle() { 360 throw new IllegalStateException("You can't call this!"); 361 } 362 363 @Override getBackground()364 public Drawable getBackground() { 365 throw new IllegalStateException("You can't call this!"); 366 } 367 368 @Override getContentView()369 public View getContentView() { 370 throw new IllegalStateException("You can't call this!"); 371 } 372 373 @Override getElevation()374 public float getElevation() { 375 throw new IllegalStateException("You can't call this!"); 376 } 377 378 @Override getEnterTransition()379 public Transition getEnterTransition() { 380 throw new IllegalStateException("You can't call this!"); 381 } 382 383 @Override getExitTransition()384 public Transition getExitTransition() { 385 throw new IllegalStateException("You can't call this!"); 386 } 387 388 @Override setBackgroundDrawable(Drawable background)389 public void setBackgroundDrawable(Drawable background) { 390 throw new IllegalStateException("You can't call this!"); 391 } 392 393 @Override setContentView(View contentView)394 public void setContentView(View contentView) { 395 if (contentView != null) { 396 throw new IllegalStateException("You can't call this!"); 397 } 398 } 399 400 @Override setElevation(float elevation)401 public void setElevation(float elevation) { 402 throw new IllegalStateException("You can't call this!"); 403 } 404 405 @Override setEnterTransition(Transition enterTransition)406 public void setEnterTransition(Transition enterTransition) { 407 throw new IllegalStateException("You can't call this!"); 408 } 409 410 @Override setExitTransition(Transition exitTransition)411 public void setExitTransition(Transition exitTransition) { 412 throw new IllegalStateException("You can't call this!"); 413 } 414 415 @Override setTouchInterceptor(View.OnTouchListener l)416 public void setTouchInterceptor(View.OnTouchListener l) { 417 throw new IllegalStateException("You can't call this!"); 418 } 419 420 /** 421 * Dumps status 422 */ dump(@onNull PrintWriter pw, @Nullable String prefix)423 public void dump(@NonNull PrintWriter pw, @Nullable String prefix) { 424 425 pw.print(prefix); 426 427 if (mContentContainer != null) { 428 pw.print(prefix); pw.print("Window: "); 429 final String prefix2 = prefix + " "; 430 pw.println(); 431 pw.print(prefix2); pw.print("showing: "); pw.println(mShowing); 432 pw.print(prefix2); pw.print("view: "); pw.println(mContentContainer); 433 if (mWindowLayoutParams != null) { 434 pw.print(prefix2); pw.print("params: "); pw.println(mWindowLayoutParams); 435 } 436 pw.print(prefix2); pw.print("screen coordinates: "); 437 if (mContentContainer == null) { 438 pw.println("N/A"); 439 } else { 440 final int[] coordinates = mContentContainer.getLocationOnScreen(); 441 pw.print(coordinates[0]); pw.print("x"); pw.println(coordinates[1]); 442 } 443 } 444 } 445 446 private class DelayShowRunnable implements Runnable { 447 WeakReference<View> mAnchor; 448 DelayShowRunnable(View anchor)449 DelayShowRunnable(View anchor) { 450 mAnchor = new WeakReference<>(anchor); 451 } 452 453 @Override run()454 public void run() { 455 mDelaying = false; 456 final View anchor = mAnchor.get(); 457 if (anchor != null) { 458 updateInner(anchor); 459 } 460 } 461 setAnchor(View anchor)462 public void setAnchor(View anchor) { 463 mAnchor.clear(); 464 mAnchor = new WeakReference<>(anchor); 465 } 466 } 467 } 468