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