1 /*
2  * Copyright (C) 2015 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.launcher3.widget;
18 
19 import static android.view.View.MeasureSpec.makeMeasureSpec;
20 import static android.view.ViewGroup.LayoutParams.MATCH_PARENT;
21 import static android.view.ViewGroup.LayoutParams.WRAP_CONTENT;
22 
23 import static com.android.launcher3.LauncherSettings.Favorites.CONTAINER_WIDGETS_TRAY;
24 import static com.android.launcher3.Utilities.ATLEAST_S;
25 
26 import android.content.Context;
27 import android.graphics.Bitmap;
28 import android.graphics.drawable.Drawable;
29 import android.util.AttributeSet;
30 import android.util.Log;
31 import android.util.Size;
32 import android.view.Gravity;
33 import android.view.MotionEvent;
34 import android.view.View;
35 import android.view.ViewGroup;
36 import android.view.ViewPropertyAnimator;
37 import android.view.accessibility.AccessibilityNodeInfo;
38 import android.widget.FrameLayout;
39 import android.widget.ImageView;
40 import android.widget.LinearLayout;
41 import android.widget.RemoteViews;
42 import android.widget.TextView;
43 
44 import androidx.annotation.NonNull;
45 import androidx.annotation.Nullable;
46 
47 import com.android.launcher3.CheckLongPressHelper;
48 import com.android.launcher3.DeviceProfile;
49 import com.android.launcher3.Launcher;
50 import com.android.launcher3.R;
51 import com.android.launcher3.icons.BaseIconFactory;
52 import com.android.launcher3.icons.FastBitmapDrawable;
53 import com.android.launcher3.icons.RoundDrawableWrapper;
54 import com.android.launcher3.icons.cache.HandlerRunnable;
55 import com.android.launcher3.model.WidgetItem;
56 import com.android.launcher3.views.ActivityContext;
57 import com.android.launcher3.widget.util.WidgetSizes;
58 
59 import java.util.function.Consumer;
60 
61 /**
62  * Represents the individual cell of the widget inside the widget tray. The preview is drawn
63  * horizontally centered, and scaled down if needed.
64  *
65  * This view does not support padding. Since the image is scaled down to fit the view, padding will
66  * further decrease the scaling factor. Drag-n-drop uses the view bounds for showing a smooth
67  * transition from the view to drag view, so when adding padding support, DnD would need to
68  * consider the appropriate scaling factor.
69  */
70 public class WidgetCell extends LinearLayout {
71 
72     private static final String TAG = "WidgetCell";
73     private static final boolean DEBUG = false;
74 
75     private static final int FADE_IN_DURATION_MS = 90;
76 
77     /** Widget cell width is calculated by multiplying this factor to grid cell width. */
78     private static final float WIDTH_SCALE = 3f;
79 
80     /** Widget preview width is calculated by multiplying this factor to the widget cell width. */
81     private static final float PREVIEW_SCALE = 0.8f;
82 
83     /**
84      * The maximum dimension that can be used as the size in
85      * {@link android.view.View.MeasureSpec#makeMeasureSpec(int, int)}.
86      *
87      * <p>This is equal to (1 << MeasureSpec.MODE_SHIFT) - 1.
88      */
89     private static final int MAX_MEASURE_SPEC_DIMENSION = (1 << 30) - 1;
90 
91     /**
92      * The target preview width, in pixels, of a widget or a shortcut.
93      *
94      * <p>The actual preview width may be smaller than or equal to this value subjected to scaling.
95      */
96     protected int mTargetPreviewWidth;
97 
98     /**
99      * The target preview height, in pixels, of a widget or a shortcut.
100      *
101      * <p>The actual preview height may be smaller than or equal to this value subjected to scaling.
102      */
103     protected int mTargetPreviewHeight;
104 
105     protected int mPresetPreviewSize;
106 
107     private int mCellSize;
108 
109     /**
110      * The scale of the preview container.
111      */
112     private float mPreviewContainerScale = 1f;
113 
114     private FrameLayout mWidgetImageContainer;
115     private WidgetImageView mWidgetImage;
116     private ImageView mWidgetBadge;
117     private TextView mWidgetName;
118     private TextView mWidgetDims;
119     private TextView mWidgetDescription;
120 
121     protected WidgetItem mItem;
122 
123     private final DatabaseWidgetPreviewLoader mWidgetPreviewLoader;
124 
125     protected HandlerRunnable mActiveRequest;
126     private boolean mAnimatePreview = true;
127 
128     protected final ActivityContext mActivity;
129     private final CheckLongPressHelper mLongPressHelper;
130     private final float mEnforcedCornerRadius;
131 
132     private RemoteViews mRemoteViewsPreview;
133     private NavigableAppWidgetHostView mAppWidgetHostViewPreview;
134     private float mAppWidgetHostViewScale = 1f;
135     private int mSourceContainer = CONTAINER_WIDGETS_TRAY;
136 
WidgetCell(Context context)137     public WidgetCell(Context context) {
138         this(context, null);
139     }
140 
WidgetCell(Context context, AttributeSet attrs)141     public WidgetCell(Context context, AttributeSet attrs) {
142         this(context, attrs, 0);
143     }
144 
WidgetCell(Context context, AttributeSet attrs, int defStyle)145     public WidgetCell(Context context, AttributeSet attrs, int defStyle) {
146         super(context, attrs, defStyle);
147 
148         mActivity = ActivityContext.lookupContext(context);
149         mWidgetPreviewLoader = new DatabaseWidgetPreviewLoader(context);
150         mLongPressHelper = new CheckLongPressHelper(this);
151         mLongPressHelper.setLongPressTimeoutFactor(1);
152 
153         setContainerWidth();
154         setWillNotDraw(false);
155         setClipToPadding(false);
156         setAccessibilityDelegate(mActivity.getAccessibilityDelegate());
157         mEnforcedCornerRadius = RoundedCornerEnforcement.computeEnforcedRadius(context);
158     }
159 
setContainerWidth()160     private void setContainerWidth() {
161         mCellSize = (int) (mActivity.getDeviceProfile().allAppsIconSizePx * WIDTH_SCALE);
162         mPresetPreviewSize = (int) (mCellSize * PREVIEW_SCALE);
163         mTargetPreviewWidth = mTargetPreviewHeight = mPresetPreviewSize;
164     }
165 
166     @Override
onFinishInflate()167     protected void onFinishInflate() {
168         super.onFinishInflate();
169 
170         mWidgetImageContainer = findViewById(R.id.widget_preview_container);
171         mWidgetImage = findViewById(R.id.widget_preview);
172         mWidgetBadge = findViewById(R.id.widget_badge);
173         mWidgetName = findViewById(R.id.widget_name);
174         mWidgetDims = findViewById(R.id.widget_dims);
175         mWidgetDescription = findViewById(R.id.widget_description);
176     }
177 
setRemoteViewsPreview(RemoteViews view)178     public void setRemoteViewsPreview(RemoteViews view) {
179         mRemoteViewsPreview = view;
180     }
181 
182     @Nullable
getRemoteViewsPreview()183     public RemoteViews getRemoteViewsPreview() {
184         return mRemoteViewsPreview;
185     }
186 
187     /** Returns the app widget host view scale, which is a value between [0f, 1f]. */
getAppWidgetHostViewScale()188     public float getAppWidgetHostViewScale() {
189         return mAppWidgetHostViewScale;
190     }
191 
192     /**
193      * Called to clear the view and free attached resources. (e.g., {@link Bitmap}
194      */
clear()195     public void clear() {
196         if (DEBUG) {
197             Log.d(TAG, "reset called on:" + mWidgetName.getText());
198         }
199         mWidgetImage.animate().cancel();
200         mWidgetImage.setDrawable(null);
201         mWidgetImage.setVisibility(View.VISIBLE);
202         mWidgetBadge.setImageDrawable(null);
203         mWidgetBadge.setVisibility(View.GONE);
204         mWidgetName.setText(null);
205         mWidgetDims.setText(null);
206         mWidgetDescription.setText(null);
207         mWidgetDescription.setVisibility(GONE);
208         mTargetPreviewWidth = mTargetPreviewHeight = mPresetPreviewSize;
209 
210         if (mActiveRequest != null) {
211             mActiveRequest.cancel();
212             mActiveRequest = null;
213         }
214         mRemoteViewsPreview = null;
215         if (mAppWidgetHostViewPreview != null) {
216             mWidgetImageContainer.removeView(mAppWidgetHostViewPreview);
217         }
218         mAppWidgetHostViewPreview = null;
219         mAppWidgetHostViewScale = 1f;
220         mItem = null;
221     }
222 
setSourceContainer(int sourceContainer)223     public void setSourceContainer(int sourceContainer) {
224         this.mSourceContainer = sourceContainer;
225     }
226 
227     /**
228      * Applies the item to this view
229      */
applyFromCellItem(WidgetItem item)230     public void applyFromCellItem(WidgetItem item) {
231         applyFromCellItem(item, 1f);
232     }
233 
234     /**
235      * Applies the item to this view
236      */
applyFromCellItem(WidgetItem item, float previewScale)237     public void applyFromCellItem(WidgetItem item, float previewScale) {
238         applyFromCellItem(item, previewScale, this::applyPreview, null);
239     }
240 
241     /**
242      * Applies the item to this view
243      * @param item item to apply
244      * @param previewScale factor to scale the preview
245      * @param callback callback when preview is loaded in case the preview is being loaded or cached
246      * @param cachedPreview previously cached preview bitmap is present
247      */
applyFromCellItem(WidgetItem item, float previewScale, @NonNull Consumer<Bitmap> callback, @Nullable Bitmap cachedPreview)248     public void applyFromCellItem(WidgetItem item, float previewScale,
249             @NonNull Consumer<Bitmap> callback, @Nullable Bitmap cachedPreview) {
250         // setPreviewSize
251         DeviceProfile deviceProfile = mActivity.getDeviceProfile();
252         Size widgetSize = WidgetSizes.getWidgetItemSizePx(getContext(), deviceProfile, item);
253         mTargetPreviewWidth = widgetSize.getWidth();
254         mTargetPreviewHeight = widgetSize.getHeight();
255         mPreviewContainerScale = previewScale;
256 
257         applyPreviewOnAppWidgetHostView(item);
258 
259         Context context = getContext();
260         mItem = item;
261         mWidgetName.setText(mItem.label);
262         mWidgetName.setContentDescription(
263                 context.getString(R.string.widget_preview_context_description, mItem.label));
264         mWidgetDims.setText(context.getString(R.string.widget_dims_format,
265                 mItem.spanX, mItem.spanY));
266         mWidgetDims.setContentDescription(context.getString(
267                 R.string.widget_accessible_dims_format, mItem.spanX, mItem.spanY));
268         if (ATLEAST_S && mItem.widgetInfo != null) {
269             CharSequence description = mItem.widgetInfo.loadDescription(context);
270             if (description != null && description.length() > 0) {
271                 mWidgetDescription.setText(description);
272                 mWidgetDescription.setVisibility(VISIBLE);
273             } else {
274                 mWidgetDescription.setVisibility(GONE);
275             }
276         }
277 
278         if (item.activityInfo != null) {
279             setTag(new PendingAddShortcutInfo(item.activityInfo));
280         } else {
281             setTag(new PendingAddWidgetInfo(item.widgetInfo, mSourceContainer));
282         }
283 
284         ensurePreviewWithCallback(callback, cachedPreview);
285     }
286 
applyPreviewOnAppWidgetHostView(WidgetItem item)287     private void applyPreviewOnAppWidgetHostView(WidgetItem item) {
288         if (mRemoteViewsPreview != null) {
289             mAppWidgetHostViewPreview = createAppWidgetHostView(getContext());
290             setAppWidgetHostViewPreview(mAppWidgetHostViewPreview, item.widgetInfo,
291                     mRemoteViewsPreview);
292             return;
293         }
294 
295         if (!item.hasPreviewLayout()) return;
296 
297         Context context = getContext();
298         // If the context is a Launcher activity, DragView will show mAppWidgetHostViewPreview as
299         // a preview during drag & drop. And thus, we should use LauncherAppWidgetHostView, which
300         // supports applying local color extraction during drag & drop.
301         mAppWidgetHostViewPreview = isLauncherContext(context)
302                 ? new LauncherAppWidgetHostView(context)
303                 : createAppWidgetHostView(context);
304         LauncherAppWidgetProviderInfo launcherAppWidgetProviderInfo =
305                 LauncherAppWidgetProviderInfo.fromProviderInfo(context, item.widgetInfo.clone());
306         // A hack to force the initial layout to be the preview layout since there is no API for
307         // rendering a preview layout for work profile apps yet. For non-work profile layout, a
308         // proper solution is to use RemoteViews(PackageName, LayoutId).
309         launcherAppWidgetProviderInfo.initialLayout = item.widgetInfo.previewLayout;
310         setAppWidgetHostViewPreview(mAppWidgetHostViewPreview,
311                 launcherAppWidgetProviderInfo, /* remoteViews= */ null);
312     }
313 
setAppWidgetHostViewPreview( NavigableAppWidgetHostView appWidgetHostViewPreview, LauncherAppWidgetProviderInfo providerInfo, @Nullable RemoteViews remoteViews)314     private void setAppWidgetHostViewPreview(
315             NavigableAppWidgetHostView appWidgetHostViewPreview,
316             LauncherAppWidgetProviderInfo providerInfo,
317             @Nullable RemoteViews remoteViews) {
318         appWidgetHostViewPreview.setImportantForAccessibility(IMPORTANT_FOR_ACCESSIBILITY_NO);
319         appWidgetHostViewPreview.setAppWidget(/* appWidgetId= */ -1, providerInfo);
320         appWidgetHostViewPreview.updateAppWidget(remoteViews);
321     }
322 
getWidgetView()323     public WidgetImageView getWidgetView() {
324         return mWidgetImage;
325     }
326 
327     @Nullable
getAppWidgetHostViewPreview()328     public NavigableAppWidgetHostView getAppWidgetHostViewPreview() {
329         return mAppWidgetHostViewPreview;
330     }
331 
setAnimatePreview(boolean shouldAnimate)332     public void setAnimatePreview(boolean shouldAnimate) {
333         mAnimatePreview = shouldAnimate;
334     }
335 
applyPreview(Bitmap bitmap)336     private void applyPreview(Bitmap bitmap) {
337         if (bitmap != null) {
338             Drawable drawable = new RoundDrawableWrapper(
339                     new FastBitmapDrawable(bitmap), mEnforcedCornerRadius);
340 
341             // Scale down the preview size if it's wider than the cell.
342             float scale = 1f;
343             if (mTargetPreviewWidth > 0) {
344                 float maxWidth = mTargetPreviewWidth;
345                 float previewWidth = drawable.getIntrinsicWidth() * mPreviewContainerScale;
346                 scale = Math.min(maxWidth / previewWidth, 1);
347             }
348             setContainerSize(
349                     Math.round(drawable.getIntrinsicWidth() * scale * mPreviewContainerScale),
350                     Math.round(drawable.getIntrinsicHeight() * scale * mPreviewContainerScale));
351             mWidgetImage.setDrawable(drawable);
352             mWidgetImage.setVisibility(View.VISIBLE);
353             if (mAppWidgetHostViewPreview != null) {
354                 removeView(mAppWidgetHostViewPreview);
355                 mAppWidgetHostViewPreview = null;
356             }
357         }
358 
359         if (mAnimatePreview) {
360             mWidgetImageContainer.setAlpha(0f);
361             ViewPropertyAnimator anim = mWidgetImageContainer.animate();
362             anim.alpha(1.0f).setDuration(FADE_IN_DURATION_MS);
363         } else {
364             mWidgetImageContainer.setAlpha(1f);
365         }
366         if (mActiveRequest != null) {
367             mActiveRequest.cancel();
368             mActiveRequest = null;
369         }
370     }
371 
372     /** Used to show the badge when the widget is in the recommended section
373      */
showBadge()374     public void showBadge() {
375         Drawable badge = mWidgetPreviewLoader.getBadgeForUser(mItem.user,
376                 BaseIconFactory.getBadgeSizeForIconSize(
377                         mActivity.getDeviceProfile().allAppsIconSizePx));
378         if (badge == null) {
379             mWidgetBadge.setVisibility(View.GONE);
380         } else {
381             mWidgetBadge.setVisibility(View.VISIBLE);
382             mWidgetBadge.setImageDrawable(badge);
383         }
384     }
385 
setContainerSize(int width, int height)386     private void setContainerSize(int width, int height) {
387         LayoutParams layoutParams = (LayoutParams) mWidgetImageContainer.getLayoutParams();
388         layoutParams.width = width;
389         layoutParams.height = height;
390         mWidgetImageContainer.setLayoutParams(layoutParams);
391     }
392 
393     /**
394      * Ensures that the preview is already loaded or being loaded. If the preview is not loaded,
395      * it applies the provided cachedPreview. If that is null, it starts a loader and notifies the
396      * callback on successful load.
397      */
ensurePreviewWithCallback(Consumer<Bitmap> callback, @Nullable Bitmap cachedPreview)398     private void ensurePreviewWithCallback(Consumer<Bitmap> callback,
399             @Nullable Bitmap cachedPreview) {
400         if (mAppWidgetHostViewPreview != null) {
401             int containerWidth = (int) (mTargetPreviewWidth * mPreviewContainerScale);
402             int containerHeight = (int) (mTargetPreviewHeight * mPreviewContainerScale);
403             setContainerSize(containerWidth, containerHeight);
404             if (mAppWidgetHostViewPreview.getChildCount() == 1) {
405                 View widgetContent = mAppWidgetHostViewPreview.getChildAt(0);
406                 ViewGroup.LayoutParams layoutParams = widgetContent.getLayoutParams();
407                 // We only scale preview if both the width & height of the outermost view group are
408                 // not set to MATCH_PARENT.
409                 boolean shouldScale =
410                         layoutParams.width != MATCH_PARENT && layoutParams.height != MATCH_PARENT;
411                 if (shouldScale) {
412                     setNoClip(mWidgetImageContainer);
413                     setNoClip(mAppWidgetHostViewPreview);
414                     mAppWidgetHostViewScale = measureAndComputeWidgetPreviewScale();
415                     mAppWidgetHostViewPreview.setScaleToFit(mAppWidgetHostViewScale);
416                 }
417             }
418             FrameLayout.LayoutParams params = new FrameLayout.LayoutParams(
419                     containerWidth, containerHeight, Gravity.FILL);
420             mAppWidgetHostViewPreview.setLayoutParams(params);
421             mWidgetImageContainer.addView(mAppWidgetHostViewPreview, /* index= */ 0);
422             mWidgetImage.setVisibility(View.GONE);
423             applyPreview(null);
424             return;
425         }
426         if (cachedPreview != null) {
427             applyPreview(cachedPreview);
428             return;
429         }
430         if (mActiveRequest != null) {
431             return;
432         }
433         mActiveRequest = mWidgetPreviewLoader.loadPreview(
434                 mItem, new Size(mTargetPreviewWidth, mTargetPreviewHeight), callback);
435     }
436 
437     @Override
onTouchEvent(MotionEvent ev)438     public boolean onTouchEvent(MotionEvent ev) {
439         super.onTouchEvent(ev);
440         mLongPressHelper.onTouchEvent(ev);
441         return true;
442     }
443 
444     @Override
cancelLongPress()445     public void cancelLongPress() {
446         super.cancelLongPress();
447         mLongPressHelper.cancelLongPress();
448     }
449 
createAppWidgetHostView(Context context)450     private static NavigableAppWidgetHostView createAppWidgetHostView(Context context) {
451         return new NavigableAppWidgetHostView(context) {
452             @Override
453             protected boolean shouldAllowDirectClick() {
454                 return false;
455             }
456         };
457     }
458 
459     private static boolean isLauncherContext(Context context) {
460         return ActivityContext.lookupContext(context) instanceof Launcher;
461     }
462 
463     @Override
464     public CharSequence getAccessibilityClassName() {
465         return WidgetCell.class.getName();
466     }
467 
468     @Override
469     public void onInitializeAccessibilityNodeInfo(AccessibilityNodeInfo info) {
470         super.onInitializeAccessibilityNodeInfo(info);
471         info.removeAction(AccessibilityNodeInfo.AccessibilityAction.ACTION_CLICK);
472     }
473 
474     private static void setNoClip(ViewGroup view) {
475         view.setClipChildren(false);
476         view.setClipToPadding(false);
477     }
478 
479     private float measureAndComputeWidgetPreviewScale() {
480         if (mAppWidgetHostViewPreview.getChildCount() != 1) {
481             return 1f;
482         }
483 
484         // Measure the largest possible width & height that the app widget wants to display.
485         mAppWidgetHostViewPreview.measure(
486                 makeMeasureSpec(MAX_MEASURE_SPEC_DIMENSION, MeasureSpec.UNSPECIFIED),
487                 makeMeasureSpec(MAX_MEASURE_SPEC_DIMENSION, MeasureSpec.UNSPECIFIED));
488         if (mRemoteViewsPreview != null) {
489             // If RemoteViews contains multiple sizes, the best fit sized RemoteViews will be
490             // selected in onLayout. To work out the right measurement, let's layout and then
491             // measure again.
492             mAppWidgetHostViewPreview.layout(
493                     /* left= */ 0,
494                     /* top= */ 0,
495                     /* right= */ mTargetPreviewWidth,
496                     /* bottom= */ mTargetPreviewHeight);
497             mAppWidgetHostViewPreview.measure(
498                     makeMeasureSpec(mTargetPreviewWidth, MeasureSpec.UNSPECIFIED),
499                     makeMeasureSpec(mTargetPreviewHeight, MeasureSpec.UNSPECIFIED));
500 
501         }
502         View widgetContent = mAppWidgetHostViewPreview.getChildAt(0);
503         int appWidgetContentWidth = widgetContent.getMeasuredWidth();
504         int appWidgetContentHeight = widgetContent.getMeasuredHeight();
505         if (appWidgetContentWidth == 0 || appWidgetContentHeight == 0) {
506             return 1f;
507         }
508 
509         // If the width / height of the widget content is set to wrap content, overrides the width /
510         // height with the measured dimension. This avoids incorrect measurement after scaling.
511         FrameLayout.LayoutParams layoutParam =
512                 (FrameLayout.LayoutParams) widgetContent.getLayoutParams();
513         if (layoutParam.width == WRAP_CONTENT) {
514             layoutParam.width = widgetContent.getMeasuredWidth();
515         }
516         if (layoutParam.height == WRAP_CONTENT) {
517             layoutParam.height = widgetContent.getMeasuredHeight();
518         }
519         widgetContent.setLayoutParams(layoutParam);
520 
521         int horizontalPadding = mAppWidgetHostViewPreview.getPaddingStart()
522                 + mAppWidgetHostViewPreview.getPaddingEnd();
523         int verticalPadding = mAppWidgetHostViewPreview.getPaddingTop()
524                 + mAppWidgetHostViewPreview.getPaddingBottom();
525         return Math.min(
526                 (mTargetPreviewWidth - horizontalPadding) * mPreviewContainerScale
527                         / appWidgetContentWidth,
528                 (mTargetPreviewHeight - verticalPadding) * mPreviewContainerScale
529                         / appWidgetContentHeight);
530     }
531 }
532