1 /*
2  * Copyright (C) 2023 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;
18 
19 import static android.content.ClipDescription.MIMETYPE_TEXT_INTENT;
20 import static android.view.WindowInsets.Type.navigationBars;
21 import static android.view.WindowInsets.Type.statusBars;
22 
23 import static com.android.launcher3.util.Executors.MAIN_EXECUTOR;
24 import static com.android.launcher3.util.Executors.MODEL_EXECUTOR;
25 
26 import android.appwidget.AppWidgetManager;
27 import android.appwidget.AppWidgetProviderInfo;
28 import android.content.ClipData;
29 import android.content.ClipDescription;
30 import android.content.Intent;
31 import android.os.Bundle;
32 import android.util.Log;
33 import android.view.View;
34 import android.view.WindowInsetsController;
35 import android.view.WindowManager;
36 
37 import androidx.annotation.NonNull;
38 import androidx.annotation.Nullable;
39 
40 import com.android.launcher3.dragndrop.SimpleDragLayer;
41 import com.android.launcher3.model.WidgetItem;
42 import com.android.launcher3.model.WidgetPredictionsRequester;
43 import com.android.launcher3.model.WidgetsModel;
44 import com.android.launcher3.model.data.ItemInfo;
45 import com.android.launcher3.popup.PopupDataProvider;
46 import com.android.launcher3.util.PackageUserKey;
47 import com.android.launcher3.widget.BaseWidgetSheet;
48 import com.android.launcher3.widget.WidgetCell;
49 import com.android.launcher3.widget.model.WidgetsListBaseEntry;
50 import com.android.launcher3.widget.model.WidgetsListHeaderEntry;
51 import com.android.launcher3.widget.picker.WidgetsFullSheet;
52 
53 import java.util.ArrayList;
54 import java.util.List;
55 import java.util.Locale;
56 import java.util.Map;
57 import java.util.regex.Pattern;
58 import java.util.stream.Collectors;
59 
60 /** An Activity that can host Launcher's widget picker. */
61 public class WidgetPickerActivity extends BaseActivity {
62     private static final String TAG = "WidgetPickerActivity";
63     /**
64      * Name of the extra that indicates that a widget being dragged.
65      *
66      * <p>When set to "true" in the result of startActivityForResult, the client that launched the
67      * picker knows that activity was closed due to pending drag.
68      */
69     private static final String EXTRA_IS_PENDING_WIDGET_DRAG = "is_pending_widget_drag";
70 
71     // Intent extras that specify the desired widget width and height. If these are not specified in
72     // the intent, then widgets will not be filtered for size.
73     private static final String EXTRA_DESIRED_WIDGET_WIDTH = "desired_widget_width";
74     private static final String EXTRA_DESIRED_WIDGET_HEIGHT = "desired_widget_height";
75     /**
76      * Widgets currently added by the user in the UI surface.
77      * <p>This allows widget picker to exclude existing widgets from suggestions.</p>
78      */
79     private static final String EXTRA_ADDED_APP_WIDGETS = "added_app_widgets";
80     /**
81      * A unique identifier of the surface hosting the widgets;
82      * <p>"widgets" is reserved for home screen surface.</p>
83      * <p>"widgets_hub" is reserved for glanceable hub surface.</p>
84      */
85     private static final String EXTRA_UI_SURFACE = "ui_surface";
86     private static final Pattern UI_SURFACE_PATTERN =
87             Pattern.compile("^(widgets|widgets_hub)$");
88     private SimpleDragLayer<WidgetPickerActivity> mDragLayer;
89     private WidgetsModel mModel;
90     private LauncherAppState mApp;
91     private WidgetPredictionsRequester mWidgetPredictionsRequester;
92     private final PopupDataProvider mPopupDataProvider = new PopupDataProvider(i -> {});
93 
94     private int mDesiredWidgetWidth;
95     private int mDesiredWidgetHeight;
96     private int mWidgetCategoryFilter;
97     @Nullable
98     private String mUiSurface;
99     // Widgets existing on the host surface.
100     @NonNull
101     private List<AppWidgetProviderInfo> mAddedWidgets = new ArrayList<>();
102 
103     @Override
onCreate(Bundle savedInstanceState)104     protected void onCreate(Bundle savedInstanceState) {
105         super.onCreate(savedInstanceState);
106 
107         getWindow().addFlags(WindowManager.LayoutParams.FLAG_SHOW_WHEN_LOCKED);
108         getWindow().clearFlags(WindowManager.LayoutParams.FLAG_SHOW_WALLPAPER);
109 
110         mApp = LauncherAppState.getInstance(this);
111         InvariantDeviceProfile idp = mApp.getInvariantDeviceProfile();
112         mDeviceProfile = idp.getDeviceProfile(this);
113         mModel = new WidgetsModel();
114 
115         setContentView(R.layout.widget_picker_activity);
116         mDragLayer = findViewById(R.id.drag_layer);
117         mDragLayer.recreateControllers();
118 
119         WindowInsetsController wc = mDragLayer.getWindowInsetsController();
120         wc.hide(navigationBars() + statusBars());
121 
122         BaseWidgetSheet widgetSheet = WidgetsFullSheet.show(this, true);
123         widgetSheet.disableNavBarScrim(true);
124         widgetSheet.addOnCloseListener(this::finish);
125 
126         parseIntentExtras();
127         refreshAndBindWidgets();
128     }
129 
parseIntentExtras()130     private void parseIntentExtras() {
131         // A value of 0 for either size means that no filtering will occur in that dimension. If
132         // both values are 0, then no size filtering will occur.
133         mDesiredWidgetWidth =
134                 getIntent().getIntExtra(EXTRA_DESIRED_WIDGET_WIDTH, 0);
135         mDesiredWidgetHeight =
136                 getIntent().getIntExtra(EXTRA_DESIRED_WIDGET_HEIGHT, 0);
137 
138         // Defaults to '0' to indicate that there isn't a category filter.
139         mWidgetCategoryFilter =
140                 getIntent().getIntExtra(AppWidgetManager.EXTRA_CATEGORY_FILTER, 0);
141 
142         String uiSurfaceParam = getIntent().getStringExtra(EXTRA_UI_SURFACE);
143         if (uiSurfaceParam != null && UI_SURFACE_PATTERN.matcher(uiSurfaceParam).matches()) {
144             mUiSurface = uiSurfaceParam;
145         }
146         ArrayList<AppWidgetProviderInfo> addedWidgets = getIntent().getParcelableArrayListExtra(
147                 EXTRA_ADDED_APP_WIDGETS, AppWidgetProviderInfo.class);
148         if (addedWidgets != null) {
149             mAddedWidgets = addedWidgets;
150         }
151     }
152 
153     @NonNull
154     @Override
getPopupDataProvider()155     public PopupDataProvider getPopupDataProvider() {
156         return mPopupDataProvider;
157     }
158 
159     @Override
getDragLayer()160     public SimpleDragLayer<WidgetPickerActivity> getDragLayer() {
161         return mDragLayer;
162     }
163 
164     @Override
getItemOnClickListener()165     public View.OnClickListener getItemOnClickListener() {
166         return v -> {
167             final AppWidgetProviderInfo info =
168                     (v instanceof WidgetCell) ? ((WidgetCell) v).getWidgetItem().widgetInfo : null;
169             if (info == null || info.provider == null) {
170                 return;
171             }
172 
173             setResult(RESULT_OK, new Intent()
174                     .putExtra(Intent.EXTRA_COMPONENT_NAME, info.provider)
175                     .putExtra(Intent.EXTRA_USER, info.getProfile()));
176 
177             finish();
178         };
179     }
180 
181     @Override
getAllAppsItemLongClickListener()182     public View.OnLongClickListener getAllAppsItemLongClickListener() {
183         return view -> {
184             if (!(view instanceof WidgetCell widgetCell)) return false;
185 
186             if (widgetCell.getWidgetView().getDrawable() == null
187                     && widgetCell.getAppWidgetHostViewPreview() == null) {
188                 // The widget preview hasn't been loaded; so, we abort the drag.
189                 return false;
190             }
191 
192             final AppWidgetProviderInfo info = widgetCell.getWidgetItem().widgetInfo;
193             if (info == null || info.provider == null) {
194                 return false;
195             }
196 
197             View dragView = widgetCell.getDragAndDropView();
198             if (dragView == null) {
199                 return false;
200             }
201 
202             ClipData clipData = new ClipData(
203                     new ClipDescription(
204                             /* label= */ "", // not displayed anywhere; so, set to empty.
205                             new String[]{MIMETYPE_TEXT_INTENT}
206                     ),
207                     new ClipData.Item(new Intent()
208                             .putExtra(Intent.EXTRA_USER, info.getProfile())
209                             .putExtra(Intent.EXTRA_COMPONENT_NAME, info.provider))
210             );
211 
212             // Set result indicating activity was closed due a widget being dragged.
213             setResult(RESULT_OK, new Intent()
214                     .putExtra(EXTRA_IS_PENDING_WIDGET_DRAG, true));
215 
216             // DRAG_FLAG_GLOBAL permits dragging data beyond app window.
217             return dragView.startDragAndDrop(
218                     clipData,
219                     new View.DragShadowBuilder(dragView),
220                     /* myLocalState= */ null,
221                     View.DRAG_FLAG_GLOBAL
222             );
223         };
224     }
225 
226     /** Updates the model with widgets and provides them after applying the provided filter. */
227     private void refreshAndBindWidgets() {
228         MODEL_EXECUTOR.execute(() -> {
229             LauncherAppState app = LauncherAppState.getInstance(this);
230             mModel.update(app, null);
231             final List<WidgetsListBaseEntry> allWidgets =
232                     mModel.getFilteredWidgetsListForPicker(
233                             app.getContext(),
234                             /*widgetItemFilter=*/ widget -> {
235                                 final WidgetAcceptabilityVerdict verdict =
236                                         isWidgetAcceptable(widget);
237                                 verdict.maybeLogVerdict();
238                                 return verdict.isAcceptable;
239                             }
240                     );
241             bindWidgets(allWidgets);
242             if (mUiSurface != null) {
243                 Map<PackageUserKey, List<WidgetItem>> allWidgetsMap = allWidgets.stream()
244                         .filter(WidgetsListHeaderEntry.class::isInstance)
245                         .collect(Collectors.toMap(
246                                 entry -> PackageUserKey.fromPackageItemInfo(entry.mPkgItem),
247                                 entry -> entry.mWidgets)
248                         );
249                 mWidgetPredictionsRequester = new WidgetPredictionsRequester(app.getContext(),
250                         mUiSurface, allWidgetsMap);
251                 mWidgetPredictionsRequester.request(mAddedWidgets, this::bindRecommendedWidgets);
252             }
253         });
254     }
255 
256     private void bindWidgets(List<WidgetsListBaseEntry> widgets) {
257         MAIN_EXECUTOR.execute(() -> mPopupDataProvider.setAllWidgets(widgets));
258     }
259 
260     private void bindRecommendedWidgets(List<ItemInfo> recommendedWidgets) {
261         MAIN_EXECUTOR.execute(() -> mPopupDataProvider.setRecommendedWidgets(recommendedWidgets));
262     }
263 
264     @Override
265     protected void onDestroy() {
266         super.onDestroy();
267         if (mWidgetPredictionsRequester != null) {
268             mWidgetPredictionsRequester.clear();
269         }
270     }
271 
272     private WidgetAcceptabilityVerdict isWidgetAcceptable(WidgetItem widget) {
273         final AppWidgetProviderInfo info = widget.widgetInfo;
274         if (info == null) {
275             return rejectWidget(widget, "shortcut");
276         }
277 
278         if (mWidgetCategoryFilter > 0 && (info.widgetCategory & mWidgetCategoryFilter) == 0) {
279             return rejectWidget(
280                     widget,
281                     "doesn't match category filter [filter=%d, widget=%d]",
282                     mWidgetCategoryFilter,
283                     info.widgetCategory);
284         }
285 
286         if (mDesiredWidgetWidth == 0 && mDesiredWidgetHeight == 0) {
287             // Accept the widget if the desired dimensions are unspecified.
288             return acceptWidget(widget);
289         }
290 
291         final boolean isHorizontallyResizable =
292                 (info.resizeMode & AppWidgetProviderInfo.RESIZE_HORIZONTAL) != 0;
293         if (mDesiredWidgetWidth > 0 && isHorizontallyResizable) {
294             if (info.maxResizeWidth > 0
295                     && info.maxResizeWidth >= info.minWidth
296                     && info.maxResizeWidth < mDesiredWidgetWidth) {
297                 return rejectWidget(
298                         widget,
299                         "maxResizeWidth[%d] < mDesiredWidgetWidth[%d]",
300                         info.maxResizeWidth,
301                         mDesiredWidgetWidth);
302             }
303 
304             final int minWidth = Math.min(info.minResizeWidth, info.minWidth);
305             if (minWidth > mDesiredWidgetWidth) {
306                 return rejectWidget(
307                         widget,
308                         "min(minWidth[%d], minResizeWidth[%d]) > mDesiredWidgetWidth[%d]",
309                         info.minWidth,
310                         info.minResizeWidth,
311                         mDesiredWidgetWidth);
312             }
313         }
314 
315         final boolean isVerticallyResizable =
316                 (info.resizeMode & AppWidgetProviderInfo.RESIZE_VERTICAL) != 0;
317         if (mDesiredWidgetHeight > 0 && isVerticallyResizable) {
318             if (info.maxResizeHeight > 0
319                     && info.maxResizeHeight >= info.minHeight
320                     && info.maxResizeHeight < mDesiredWidgetHeight) {
321                 return rejectWidget(
322                         widget,
323                         "maxResizeHeight[%d] < mDesiredWidgetHeight[%d]",
324                         info.maxResizeHeight,
325                         mDesiredWidgetHeight);
326             }
327 
328             final int minHeight = Math.min(info.minResizeHeight, info.minHeight);
329             if (minHeight > mDesiredWidgetHeight) {
330                 return rejectWidget(
331                         widget,
332                         "min(minHeight[%d], minResizeHeight[%d]) > mDesiredWidgetHeight[%d]",
333                         info.minHeight,
334                         info.minResizeHeight,
335                         mDesiredWidgetHeight);
336             }
337         }
338 
339         if (!isHorizontallyResizable || !isVerticallyResizable) {
340             return rejectWidget(widget, "not resizeable");
341         }
342 
343         return acceptWidget(widget);
344     }
345 
346     private static WidgetAcceptabilityVerdict rejectWidget(
347             WidgetItem widget, String rejectionReason, Object... args) {
348         return new WidgetAcceptabilityVerdict(
349                 false,
350                 widget.widgetInfo != null
351                         ? widget.widgetInfo.provider.flattenToShortString()
352                         : widget.label,
353                 String.format(Locale.ENGLISH, rejectionReason, args));
354     }
355 
356     private static WidgetAcceptabilityVerdict acceptWidget(WidgetItem widget) {
357         return new WidgetAcceptabilityVerdict(
358                 true, widget.widgetInfo.provider.flattenToShortString(), "");
359     }
360 
361     private record WidgetAcceptabilityVerdict(
362             boolean isAcceptable, String widgetLabel, String reason) {
363         void maybeLogVerdict() {
364             // Only log a verdict if a reason is specified.
365             if (Log.isLoggable(TAG, Log.DEBUG) && !reason.isEmpty()) {
366                 Log.i(TAG, String.format(
367                         Locale.ENGLISH,
368                         "%s: %s because %s",
369                         widgetLabel,
370                         isAcceptable ? "accepted" : "rejected",
371                         reason));
372             }
373         }
374     }
375 }
376