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 static java.lang.Math.max; 27 import static java.lang.Math.min; 28 29 import android.appwidget.AppWidgetManager; 30 import android.appwidget.AppWidgetProviderInfo; 31 import android.content.ClipData; 32 import android.content.ClipDescription; 33 import android.content.Intent; 34 import android.os.Bundle; 35 import android.util.Log; 36 import android.view.View; 37 import android.view.WindowInsetsController; 38 import android.view.WindowManager; 39 import android.window.BackEvent; 40 import android.window.OnBackAnimationCallback; 41 import android.window.OnBackInvokedDispatcher; 42 43 import androidx.annotation.NonNull; 44 import androidx.annotation.Nullable; 45 46 import com.android.launcher3.dragndrop.SimpleDragLayer; 47 import com.android.launcher3.model.StringCache; 48 import com.android.launcher3.model.WidgetItem; 49 import com.android.launcher3.model.WidgetPredictionsRequester; 50 import com.android.launcher3.model.WidgetsModel; 51 import com.android.launcher3.model.data.ItemInfo; 52 import com.android.launcher3.model.data.PackageItemInfo; 53 import com.android.launcher3.widget.WidgetCell; 54 import com.android.launcher3.widget.model.WidgetsListBaseEntriesBuilder; 55 import com.android.launcher3.widget.model.WidgetsListBaseEntry; 56 import com.android.launcher3.widget.picker.WidgetCategoryFilter; 57 import com.android.launcher3.widget.picker.WidgetsFullSheet; 58 import com.android.launcher3.widget.picker.model.WidgetPickerDataProvider; 59 import com.android.systemui.animation.back.FlingOnBackAnimationCallback; 60 61 import java.util.ArrayList; 62 import java.util.HashSet; 63 import java.util.List; 64 import java.util.Locale; 65 import java.util.Map; 66 import java.util.Set; 67 import java.util.function.Predicate; 68 import java.util.regex.Pattern; 69 70 /** An Activity that can host Launcher's widget picker. */ 71 public class WidgetPickerActivity extends BaseActivity implements 72 WidgetPredictionsRequester.WidgetPredictionsListener { 73 private static final String TAG = "WidgetPickerActivity"; 74 /** 75 * Name of the extra that indicates that a widget being dragged. 76 * 77 * <p>When set to "true" in the result of startActivityForResult, the client that launched the 78 * picker knows that activity was closed due to pending drag. 79 */ 80 private static final String EXTRA_IS_PENDING_WIDGET_DRAG = "is_pending_widget_drag"; 81 82 // Intent extras that specify the desired widget width and height. If these are not specified in 83 // the intent, then widgets will not be filtered for size. 84 private static final String EXTRA_DESIRED_WIDGET_WIDTH = "desired_widget_width"; 85 private static final String EXTRA_DESIRED_WIDGET_HEIGHT = "desired_widget_height"; 86 // Unlike the AppWidgetManager.EXTRA_CATEGORY_FILTER, this filter removes certain categories. 87 // Filter is ignore if it is not a negative value. 88 // Example usage: WIDGET_CATEGORY_HOME_SCREEN.inv() and WIDGET_CATEGORY_NOT_KEYGUARD.inv() 89 private static final String EXTRA_CATEGORY_EXCLUSION_FILTER = "category_exclusion_filter"; 90 /** 91 * Widgets currently added by the user in the UI surface. 92 * <p>This allows widget picker to exclude existing widgets from suggestions.</p> 93 */ 94 private static final String EXTRA_ADDED_APP_WIDGETS = "added_app_widgets"; 95 /** 96 * Intent extra for the string representing the title displayed within the picker header. 97 */ 98 private static final String EXTRA_PICKER_TITLE = "picker_title"; 99 /** 100 * Intent extra for the string representing the description displayed within the picker header. 101 */ 102 private static final String EXTRA_PICKER_DESCRIPTION = "picker_description"; 103 104 /** 105 * A unique identifier of the surface hosting the widgets; 106 * <p>"widgets" is reserved for home screen surface.</p> 107 * <p>"widgets_hub" is reserved for glanceable hub surface.</p> 108 */ 109 private static final String EXTRA_UI_SURFACE = "ui_surface"; 110 private static final Pattern UI_SURFACE_PATTERN = 111 Pattern.compile("^(widgets|widgets_hub)$"); 112 113 /** 114 * User ids that should be filtered out of the widget lists created by this activity. 115 */ 116 private static final String EXTRA_USER_ID_FILTER = "filtered_user_ids"; 117 118 private SimpleDragLayer<WidgetPickerActivity> mDragLayer; 119 private WidgetsModel mModel; 120 private LauncherAppState mApp; 121 private StringCache mStringCache; 122 private WidgetPredictionsRequester mWidgetPredictionsRequester; 123 private WidgetPickerDataProvider mWidgetPickerDataProvider; 124 125 private int mDesiredWidgetWidth; 126 private int mDesiredWidgetHeight; 127 private WidgetCategoryFilter mWidgetCategoryInclusionFilter; 128 private WidgetCategoryFilter mWidgetCategoryExclusionFilter; 129 @Nullable 130 private String mUiSurface; 131 // Widgets existing on the host surface. 132 @NonNull 133 private List<AppWidgetProviderInfo> mAddedWidgets = new ArrayList<>(); 134 @Nullable 135 private String mTitle; 136 @Nullable 137 private String mDescription; 138 139 /** A set of user ids that should be filtered out from the selected widgets. */ 140 @NonNull 141 Set<Integer> mFilteredUserIds = new HashSet<>(); 142 143 @Nullable 144 private WidgetsFullSheet mWidgetSheet; 145 146 private final Predicate<WidgetItem> mNoShortcutsFilter = widget -> { 147 final WidgetAcceptabilityVerdict verdict = 148 isWidgetAcceptable(widget, /* applySizeFilter=*/ false); 149 verdict.maybeLogVerdict(); 150 return verdict.isAcceptable; 151 }; 152 private final Predicate<WidgetItem> mHostSizeAndNoShortcutsFilter = widget -> { 153 final WidgetAcceptabilityVerdict verdict = 154 isWidgetAcceptable(widget, /* applySizeFilter=*/ true); 155 verdict.maybeLogVerdict(); 156 return verdict.isAcceptable; 157 }; 158 159 @Override onCreate(Bundle savedInstanceState)160 protected void onCreate(Bundle savedInstanceState) { 161 super.onCreate(savedInstanceState); 162 163 getWindow().addFlags(WindowManager.LayoutParams.FLAG_SHOW_WHEN_LOCKED); 164 getWindow().clearFlags(WindowManager.LayoutParams.FLAG_SHOW_WALLPAPER); 165 166 mApp = LauncherAppState.getInstance(this); 167 InvariantDeviceProfile idp = mApp.getInvariantDeviceProfile(); 168 mDeviceProfile = idp.getDeviceProfile(this); 169 mModel = new WidgetsModel(mApp.getContext()); 170 mWidgetPickerDataProvider = new WidgetPickerDataProvider(this); 171 172 setContentView(R.layout.widget_picker_activity); 173 mDragLayer = findViewById(R.id.drag_layer); 174 mDragLayer.recreateControllers(); 175 176 WindowInsetsController wc = mDragLayer.getWindowInsetsController(); 177 wc.hide(navigationBars() + statusBars()); 178 179 parseIntentExtras(); 180 refreshAndBindWidgets(); 181 } 182 183 @Override registerBackDispatcher()184 protected void registerBackDispatcher() { 185 getOnBackInvokedDispatcher().registerOnBackInvokedCallback( 186 OnBackInvokedDispatcher.PRIORITY_DEFAULT, 187 new BackAnimationCallback()); 188 } 189 parseIntentExtras()190 private void parseIntentExtras() { 191 mTitle = getIntent().getStringExtra(EXTRA_PICKER_TITLE); 192 mDescription = getIntent().getStringExtra(EXTRA_PICKER_DESCRIPTION); 193 194 // A value of 0 for either size means that no filtering will occur in that dimension. If 195 // both values are 0, then no size filtering will occur. 196 mDesiredWidgetWidth = 197 getIntent().getIntExtra(EXTRA_DESIRED_WIDGET_WIDTH, 0); 198 mDesiredWidgetHeight = 199 getIntent().getIntExtra(EXTRA_DESIRED_WIDGET_HEIGHT, 0); 200 201 // Defaults to '0' to indicate that there isn't a category filter. 202 // Negative value indicates it's an exclusion filter (e.g. NOT_KEYGUARD_CATEGORY.inv()) 203 // Positive value indicates it's inclusion filter (e.g. HOME_SCREEN or KEYGUARD) 204 // Note: A filter can either be inclusion or exclusion filter; not both. 205 int inclusionFilter = getIntent().getIntExtra(AppWidgetManager.EXTRA_CATEGORY_FILTER, 0); 206 if (inclusionFilter < 0) { 207 Log.w(TAG, "Invalid EXTRA_CATEGORY_FILTER: " + inclusionFilter); 208 } 209 mWidgetCategoryInclusionFilter = new WidgetCategoryFilter(max(0, inclusionFilter)); 210 int exclusionFilter = getIntent().getIntExtra(EXTRA_CATEGORY_EXCLUSION_FILTER, 0); 211 if (exclusionFilter > 0) { 212 Log.w(TAG, "Invalid EXTRA_CATEGORY_EXCLUSION_FILTER: " + exclusionFilter); 213 } 214 mWidgetCategoryExclusionFilter = new WidgetCategoryFilter(min(0 , exclusionFilter)); 215 216 String uiSurfaceParam = getIntent().getStringExtra(EXTRA_UI_SURFACE); 217 if (uiSurfaceParam != null && UI_SURFACE_PATTERN.matcher(uiSurfaceParam).matches()) { 218 mUiSurface = uiSurfaceParam; 219 } 220 ArrayList<AppWidgetProviderInfo> addedWidgets = getIntent().getParcelableArrayListExtra( 221 EXTRA_ADDED_APP_WIDGETS, AppWidgetProviderInfo.class); 222 if (addedWidgets != null) { 223 mAddedWidgets = addedWidgets; 224 } 225 ArrayList<Integer> filteredUsers = getIntent().getIntegerArrayListExtra( 226 EXTRA_USER_ID_FILTER); 227 mFilteredUserIds.clear(); 228 if (filteredUsers != null) { 229 mFilteredUserIds.addAll(filteredUsers); 230 } 231 } 232 233 @NonNull 234 @Override getWidgetPickerDataProvider()235 public WidgetPickerDataProvider getWidgetPickerDataProvider() { 236 return mWidgetPickerDataProvider; 237 } 238 239 @Override getDragLayer()240 public SimpleDragLayer<WidgetPickerActivity> getDragLayer() { 241 return mDragLayer; 242 } 243 244 @Override getItemOnClickListener()245 public View.OnClickListener getItemOnClickListener() { 246 return v -> { 247 final AppWidgetProviderInfo info = 248 (v instanceof WidgetCell) ? ((WidgetCell) v).getWidgetItem().widgetInfo : null; 249 if (info == null || info.provider == null) { 250 return; 251 } 252 253 setResult(RESULT_OK, new Intent() 254 .putExtra(Intent.EXTRA_COMPONENT_NAME, info.provider) 255 .putExtra(Intent.EXTRA_USER, info.getProfile())); 256 257 finish(); 258 }; 259 } 260 261 @Override getAllAppsItemLongClickListener()262 public View.OnLongClickListener getAllAppsItemLongClickListener() { 263 return view -> { 264 if (!(view instanceof WidgetCell widgetCell)) return false; 265 266 if (widgetCell.getWidgetView().getDrawable() == null 267 && widgetCell.getAppWidgetHostViewPreview() == null) { 268 // The widget preview hasn't been loaded; so, we abort the drag. 269 return false; 270 } 271 272 final AppWidgetProviderInfo info = widgetCell.getWidgetItem().widgetInfo; 273 if (info == null || info.provider == null) { 274 return false; 275 } 276 277 View dragView = widgetCell.getDragAndDropView(); 278 if (dragView == null) { 279 return false; 280 } 281 282 ClipData clipData = new ClipData( 283 new ClipDescription( 284 /* label= */ "", // not displayed anywhere; so, set to empty. 285 new String[]{MIMETYPE_TEXT_INTENT} 286 ), 287 new ClipData.Item(new Intent() 288 .putExtra(Intent.EXTRA_USER, info.getProfile()) 289 .putExtra(Intent.EXTRA_COMPONENT_NAME, info.provider)) 290 ); 291 292 // Set result indicating activity was closed due a widget being dragged. 293 setResult(RESULT_OK, new Intent() 294 .putExtra(EXTRA_IS_PENDING_WIDGET_DRAG, true)); 295 296 // DRAG_FLAG_GLOBAL permits dragging data beyond app window. 297 return dragView.startDragAndDrop( 298 clipData, 299 new View.DragShadowBuilder(dragView), 300 /* myLocalState= */ null, 301 View.DRAG_FLAG_GLOBAL 302 ); 303 }; 304 } 305 306 /** 307 * Updates the model with widgets, applies filters and launches the widgets sheet once 308 * widgets are available 309 */ 310 private void refreshAndBindWidgets() { 311 MODEL_EXECUTOR.execute(() -> { 312 mModel.update(null); 313 314 StringCache stringCache = new StringCache(); 315 stringCache.loadStrings(this); 316 317 bindStringCache(stringCache); 318 bindWidgets(mModel.getWidgetsByPackageItemForPicker()); 319 // Open sheet once widgets are available, so that it doesn't interrupt the open 320 // animation. 321 openWidgetsSheet(); 322 if (mUiSurface != null) { 323 mWidgetPredictionsRequester = new WidgetPredictionsRequester( 324 getApplicationContext(), mUiSurface, 325 mModel.getWidgetsByComponentKeyForPicker()); 326 mWidgetPredictionsRequester.request(mAddedWidgets, this); 327 } 328 }); 329 } 330 331 private void bindStringCache(final StringCache stringCache) { 332 MAIN_EXECUTOR.execute(() -> mStringCache = stringCache); 333 } 334 335 private void bindWidgets(Map<PackageItemInfo, List<WidgetItem>> widgets) { 336 WidgetsListBaseEntriesBuilder builder = new WidgetsListBaseEntriesBuilder( 337 mApp.getContext()); 338 final List<WidgetsListBaseEntry> allWidgets = builder.build(widgets, mNoShortcutsFilter); 339 340 // Default list is shown if host has additionally enforced size filtering. 341 @Nullable Predicate<WidgetItem> defaultListFilter = 342 hasHostSizeFilters() ? mHostSizeAndNoShortcutsFilter : null; 343 344 MAIN_EXECUTOR.execute(() -> { 345 mWidgetPickerDataProvider.setHostSpecifiedDefaultWidgetsFilter(defaultListFilter); 346 mWidgetPickerDataProvider.setWidgets(allWidgets); 347 }); 348 } 349 350 private void openWidgetsSheet() { 351 MAIN_EXECUTOR.execute(() -> { 352 mWidgetSheet = WidgetsFullSheet.show(this, true); 353 mWidgetSheet.mayUpdateTitleAndDescription(mTitle, mDescription); 354 mWidgetSheet.disableNavBarScrim(true); 355 mWidgetSheet.addOnCloseListener(this::finish); 356 }); 357 } 358 359 @Override 360 public void onPredictionsAvailable(List<ItemInfo> recommendedWidgets) { 361 // Bind recommendations once picker has finished open animation. 362 MAIN_EXECUTOR.getHandler().postDelayed( 363 () -> mWidgetPickerDataProvider.setWidgetRecommendations(recommendedWidgets), 364 mDeviceProfile.bottomSheetOpenDuration); 365 } 366 367 @Override 368 protected void onDestroy() { 369 super.onDestroy(); 370 mWidgetPickerDataProvider.destroy(); 371 if (mWidgetPredictionsRequester != null) { 372 mWidgetPredictionsRequester.clear(); 373 } 374 } 375 376 @Nullable 377 @Override 378 public StringCache getStringCache() { 379 return mStringCache; 380 } 381 382 /** 383 * Animation callback for different predictive back animation states for the widget picker. 384 */ 385 private class BackAnimationCallback extends FlingOnBackAnimationCallback { 386 @Nullable 387 OnBackAnimationCallback mActiveOnBackAnimationCallback; 388 389 @Override 390 public void onBackStartedCompat(@NonNull BackEvent backEvent) { 391 if (mActiveOnBackAnimationCallback != null) { 392 mActiveOnBackAnimationCallback.onBackCancelled(); 393 } 394 if (mWidgetSheet != null) { 395 mActiveOnBackAnimationCallback = mWidgetSheet; 396 mActiveOnBackAnimationCallback.onBackStarted(backEvent); 397 } 398 } 399 400 @Override 401 public void onBackInvokedCompat() { 402 if (mActiveOnBackAnimationCallback == null) { 403 return; 404 } 405 mActiveOnBackAnimationCallback.onBackInvoked(); 406 mActiveOnBackAnimationCallback = null; 407 } 408 409 @Override 410 public void onBackProgressedCompat(@NonNull BackEvent backEvent) { 411 if (mActiveOnBackAnimationCallback == null) { 412 return; 413 } 414 mActiveOnBackAnimationCallback.onBackProgressed(backEvent); 415 } 416 417 @Override 418 public void onBackCancelledCompat() { 419 if (mActiveOnBackAnimationCallback == null) { 420 return; 421 } 422 mActiveOnBackAnimationCallback.onBackCancelled(); 423 mActiveOnBackAnimationCallback = null; 424 } 425 } 426 427 private boolean hasHostSizeFilters() { 428 // If optional filters such as size filter are present, we display them as default widgets. 429 return mDesiredWidgetWidth != 0 || mDesiredWidgetHeight != 0; 430 } 431 432 private WidgetAcceptabilityVerdict isWidgetAcceptable(WidgetItem widget, 433 boolean applySizeFilter) { 434 final AppWidgetProviderInfo info = widget.widgetInfo; 435 if (info == null) { 436 return rejectWidget(widget, "shortcut"); 437 } 438 439 if (mFilteredUserIds.contains(widget.user.getIdentifier())) { 440 return rejectWidget( 441 widget, 442 "widget user: %d is being filtered", 443 widget.user.getIdentifier()); 444 } 445 446 if (!mWidgetCategoryInclusionFilter.matches(info.widgetCategory) 447 || !mWidgetCategoryExclusionFilter.matches(info.widgetCategory)) { 448 return rejectWidget( 449 widget, 450 "doesn't match category filter [inclusion=%d, exclusion=%d, widget=%d]", 451 mWidgetCategoryInclusionFilter.getCategoryMask(), 452 mWidgetCategoryExclusionFilter.getCategoryMask(), 453 info.widgetCategory); 454 } 455 456 if (applySizeFilter) { 457 if (mDesiredWidgetWidth == 0 && mDesiredWidgetHeight == 0) { 458 // Accept the widget if the desired dimensions are unspecified. 459 return acceptWidget(widget); 460 } 461 462 final boolean isHorizontallyResizable = 463 (info.resizeMode & AppWidgetProviderInfo.RESIZE_HORIZONTAL) != 0; 464 if (mDesiredWidgetWidth > 0 && isHorizontallyResizable) { 465 if (info.maxResizeWidth > 0 466 && info.maxResizeWidth >= info.minWidth 467 && info.maxResizeWidth < mDesiredWidgetWidth) { 468 return rejectWidget( 469 widget, 470 "maxResizeWidth[%d] < mDesiredWidgetWidth[%d]", 471 info.maxResizeWidth, 472 mDesiredWidgetWidth); 473 } 474 475 final int minWidth = min(info.minResizeWidth, info.minWidth); 476 if (minWidth > mDesiredWidgetWidth) { 477 return rejectWidget( 478 widget, 479 "min(minWidth[%d], minResizeWidth[%d]) > mDesiredWidgetWidth[%d]", 480 info.minWidth, 481 info.minResizeWidth, 482 mDesiredWidgetWidth); 483 } 484 } 485 486 final boolean isVerticallyResizable = 487 (info.resizeMode & AppWidgetProviderInfo.RESIZE_VERTICAL) != 0; 488 if (mDesiredWidgetHeight > 0 && isVerticallyResizable) { 489 if (info.maxResizeHeight > 0 490 && info.maxResizeHeight >= info.minHeight 491 && info.maxResizeHeight < mDesiredWidgetHeight) { 492 return rejectWidget( 493 widget, 494 "maxResizeHeight[%d] < mDesiredWidgetHeight[%d]", 495 info.maxResizeHeight, 496 mDesiredWidgetHeight); 497 } 498 499 final int minHeight = min(info.minResizeHeight, info.minHeight); 500 if (minHeight > mDesiredWidgetHeight) { 501 return rejectWidget( 502 widget, 503 "min(minHeight[%d], minResizeHeight[%d]) > mDesiredWidgetHeight[%d]", 504 info.minHeight, 505 info.minResizeHeight, 506 mDesiredWidgetHeight); 507 } 508 } 509 510 if (!isHorizontallyResizable || !isVerticallyResizable) { 511 return rejectWidget(widget, "not resizeable"); 512 } 513 } 514 515 return acceptWidget(widget); 516 } 517 518 private static WidgetAcceptabilityVerdict rejectWidget( 519 WidgetItem widget, String rejectionReason, Object... args) { 520 return new WidgetAcceptabilityVerdict( 521 false, 522 widget.widgetInfo != null 523 ? widget.widgetInfo.provider.flattenToShortString() 524 : widget.label, 525 String.format(Locale.ENGLISH, rejectionReason, args)); 526 } 527 528 private static WidgetAcceptabilityVerdict acceptWidget(WidgetItem widget) { 529 return new WidgetAcceptabilityVerdict( 530 true, widget.widgetInfo.provider.flattenToShortString(), ""); 531 } 532 533 private record WidgetAcceptabilityVerdict( 534 boolean isAcceptable, String widgetLabel, String reason) { 535 void maybeLogVerdict() { 536 // Only log a verdict if a reason is specified. 537 if (Log.isLoggable(TAG, Log.DEBUG) && !reason.isEmpty()) { 538 Log.i(TAG, String.format( 539 Locale.ENGLISH, 540 "%s: %s because %s", 541 widgetLabel, 542 isAcceptable ? "accepted" : "rejected", 543 reason)); 544 } 545 } 546 } 547 } 548