/*
* Copyright (C) 2023 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.android.launcher3;
import static android.content.ClipDescription.MIMETYPE_TEXT_INTENT;
import static android.view.WindowInsets.Type.navigationBars;
import static android.view.WindowInsets.Type.statusBars;
import static com.android.launcher3.util.Executors.MAIN_EXECUTOR;
import static com.android.launcher3.util.Executors.MODEL_EXECUTOR;
import android.appwidget.AppWidgetManager;
import android.appwidget.AppWidgetProviderInfo;
import android.content.ClipData;
import android.content.ClipDescription;
import android.content.Intent;
import android.os.Bundle;
import android.util.Log;
import android.view.View;
import android.view.WindowInsetsController;
import android.view.WindowManager;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import com.android.launcher3.dragndrop.SimpleDragLayer;
import com.android.launcher3.model.WidgetItem;
import com.android.launcher3.model.WidgetPredictionsRequester;
import com.android.launcher3.model.WidgetsModel;
import com.android.launcher3.model.data.ItemInfo;
import com.android.launcher3.popup.PopupDataProvider;
import com.android.launcher3.util.PackageUserKey;
import com.android.launcher3.widget.BaseWidgetSheet;
import com.android.launcher3.widget.WidgetCell;
import com.android.launcher3.widget.model.WidgetsListBaseEntry;
import com.android.launcher3.widget.model.WidgetsListHeaderEntry;
import com.android.launcher3.widget.picker.WidgetsFullSheet;
import java.util.ArrayList;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.regex.Pattern;
import java.util.stream.Collectors;
/** An Activity that can host Launcher's widget picker. */
public class WidgetPickerActivity extends BaseActivity {
private static final String TAG = "WidgetPickerActivity";
/**
* Name of the extra that indicates that a widget being dragged.
*
*
When set to "true" in the result of startActivityForResult, the client that launched the
* picker knows that activity was closed due to pending drag.
*/
private static final String EXTRA_IS_PENDING_WIDGET_DRAG = "is_pending_widget_drag";
// Intent extras that specify the desired widget width and height. If these are not specified in
// the intent, then widgets will not be filtered for size.
private static final String EXTRA_DESIRED_WIDGET_WIDTH = "desired_widget_width";
private static final String EXTRA_DESIRED_WIDGET_HEIGHT = "desired_widget_height";
/**
* Widgets currently added by the user in the UI surface.
*
This allows widget picker to exclude existing widgets from suggestions.
*/
private static final String EXTRA_ADDED_APP_WIDGETS = "added_app_widgets";
/**
* A unique identifier of the surface hosting the widgets;
* "widgets" is reserved for home screen surface.
* "widgets_hub" is reserved for glanceable hub surface.
*/
private static final String EXTRA_UI_SURFACE = "ui_surface";
private static final Pattern UI_SURFACE_PATTERN =
Pattern.compile("^(widgets|widgets_hub)$");
private SimpleDragLayer mDragLayer;
private WidgetsModel mModel;
private LauncherAppState mApp;
private WidgetPredictionsRequester mWidgetPredictionsRequester;
private final PopupDataProvider mPopupDataProvider = new PopupDataProvider(i -> {});
private int mDesiredWidgetWidth;
private int mDesiredWidgetHeight;
private int mWidgetCategoryFilter;
@Nullable
private String mUiSurface;
// Widgets existing on the host surface.
@NonNull
private List mAddedWidgets = new ArrayList<>();
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
getWindow().addFlags(WindowManager.LayoutParams.FLAG_SHOW_WHEN_LOCKED);
getWindow().clearFlags(WindowManager.LayoutParams.FLAG_SHOW_WALLPAPER);
mApp = LauncherAppState.getInstance(this);
InvariantDeviceProfile idp = mApp.getInvariantDeviceProfile();
mDeviceProfile = idp.getDeviceProfile(this);
mModel = new WidgetsModel();
setContentView(R.layout.widget_picker_activity);
mDragLayer = findViewById(R.id.drag_layer);
mDragLayer.recreateControllers();
WindowInsetsController wc = mDragLayer.getWindowInsetsController();
wc.hide(navigationBars() + statusBars());
BaseWidgetSheet widgetSheet = WidgetsFullSheet.show(this, true);
widgetSheet.disableNavBarScrim(true);
widgetSheet.addOnCloseListener(this::finish);
parseIntentExtras();
refreshAndBindWidgets();
}
private void parseIntentExtras() {
// A value of 0 for either size means that no filtering will occur in that dimension. If
// both values are 0, then no size filtering will occur.
mDesiredWidgetWidth =
getIntent().getIntExtra(EXTRA_DESIRED_WIDGET_WIDTH, 0);
mDesiredWidgetHeight =
getIntent().getIntExtra(EXTRA_DESIRED_WIDGET_HEIGHT, 0);
// Defaults to '0' to indicate that there isn't a category filter.
mWidgetCategoryFilter =
getIntent().getIntExtra(AppWidgetManager.EXTRA_CATEGORY_FILTER, 0);
String uiSurfaceParam = getIntent().getStringExtra(EXTRA_UI_SURFACE);
if (uiSurfaceParam != null && UI_SURFACE_PATTERN.matcher(uiSurfaceParam).matches()) {
mUiSurface = uiSurfaceParam;
}
ArrayList addedWidgets = getIntent().getParcelableArrayListExtra(
EXTRA_ADDED_APP_WIDGETS, AppWidgetProviderInfo.class);
if (addedWidgets != null) {
mAddedWidgets = addedWidgets;
}
}
@NonNull
@Override
public PopupDataProvider getPopupDataProvider() {
return mPopupDataProvider;
}
@Override
public SimpleDragLayer getDragLayer() {
return mDragLayer;
}
@Override
public View.OnClickListener getItemOnClickListener() {
return v -> {
final AppWidgetProviderInfo info =
(v instanceof WidgetCell) ? ((WidgetCell) v).getWidgetItem().widgetInfo : null;
if (info == null || info.provider == null) {
return;
}
setResult(RESULT_OK, new Intent()
.putExtra(Intent.EXTRA_COMPONENT_NAME, info.provider)
.putExtra(Intent.EXTRA_USER, info.getProfile()));
finish();
};
}
@Override
public View.OnLongClickListener getAllAppsItemLongClickListener() {
return view -> {
if (!(view instanceof WidgetCell widgetCell)) return false;
if (widgetCell.getWidgetView().getDrawable() == null
&& widgetCell.getAppWidgetHostViewPreview() == null) {
// The widget preview hasn't been loaded; so, we abort the drag.
return false;
}
final AppWidgetProviderInfo info = widgetCell.getWidgetItem().widgetInfo;
if (info == null || info.provider == null) {
return false;
}
View dragView = widgetCell.getDragAndDropView();
if (dragView == null) {
return false;
}
ClipData clipData = new ClipData(
new ClipDescription(
/* label= */ "", // not displayed anywhere; so, set to empty.
new String[]{MIMETYPE_TEXT_INTENT}
),
new ClipData.Item(new Intent()
.putExtra(Intent.EXTRA_USER, info.getProfile())
.putExtra(Intent.EXTRA_COMPONENT_NAME, info.provider))
);
// Set result indicating activity was closed due a widget being dragged.
setResult(RESULT_OK, new Intent()
.putExtra(EXTRA_IS_PENDING_WIDGET_DRAG, true));
// DRAG_FLAG_GLOBAL permits dragging data beyond app window.
return dragView.startDragAndDrop(
clipData,
new View.DragShadowBuilder(dragView),
/* myLocalState= */ null,
View.DRAG_FLAG_GLOBAL
);
};
}
/** Updates the model with widgets and provides them after applying the provided filter. */
private void refreshAndBindWidgets() {
MODEL_EXECUTOR.execute(() -> {
LauncherAppState app = LauncherAppState.getInstance(this);
mModel.update(app, null);
final List allWidgets =
mModel.getFilteredWidgetsListForPicker(
app.getContext(),
/*widgetItemFilter=*/ widget -> {
final WidgetAcceptabilityVerdict verdict =
isWidgetAcceptable(widget);
verdict.maybeLogVerdict();
return verdict.isAcceptable;
}
);
bindWidgets(allWidgets);
if (mUiSurface != null) {
Map> allWidgetsMap = allWidgets.stream()
.filter(WidgetsListHeaderEntry.class::isInstance)
.collect(Collectors.toMap(
entry -> PackageUserKey.fromPackageItemInfo(entry.mPkgItem),
entry -> entry.mWidgets)
);
mWidgetPredictionsRequester = new WidgetPredictionsRequester(app.getContext(),
mUiSurface, allWidgetsMap);
mWidgetPredictionsRequester.request(mAddedWidgets, this::bindRecommendedWidgets);
}
});
}
private void bindWidgets(List widgets) {
MAIN_EXECUTOR.execute(() -> mPopupDataProvider.setAllWidgets(widgets));
}
private void bindRecommendedWidgets(List recommendedWidgets) {
MAIN_EXECUTOR.execute(() -> mPopupDataProvider.setRecommendedWidgets(recommendedWidgets));
}
@Override
protected void onDestroy() {
super.onDestroy();
if (mWidgetPredictionsRequester != null) {
mWidgetPredictionsRequester.clear();
}
}
private WidgetAcceptabilityVerdict isWidgetAcceptable(WidgetItem widget) {
final AppWidgetProviderInfo info = widget.widgetInfo;
if (info == null) {
return rejectWidget(widget, "shortcut");
}
if (mWidgetCategoryFilter > 0 && (info.widgetCategory & mWidgetCategoryFilter) == 0) {
return rejectWidget(
widget,
"doesn't match category filter [filter=%d, widget=%d]",
mWidgetCategoryFilter,
info.widgetCategory);
}
if (mDesiredWidgetWidth == 0 && mDesiredWidgetHeight == 0) {
// Accept the widget if the desired dimensions are unspecified.
return acceptWidget(widget);
}
final boolean isHorizontallyResizable =
(info.resizeMode & AppWidgetProviderInfo.RESIZE_HORIZONTAL) != 0;
if (mDesiredWidgetWidth > 0 && isHorizontallyResizable) {
if (info.maxResizeWidth > 0
&& info.maxResizeWidth >= info.minWidth
&& info.maxResizeWidth < mDesiredWidgetWidth) {
return rejectWidget(
widget,
"maxResizeWidth[%d] < mDesiredWidgetWidth[%d]",
info.maxResizeWidth,
mDesiredWidgetWidth);
}
final int minWidth = Math.min(info.minResizeWidth, info.minWidth);
if (minWidth > mDesiredWidgetWidth) {
return rejectWidget(
widget,
"min(minWidth[%d], minResizeWidth[%d]) > mDesiredWidgetWidth[%d]",
info.minWidth,
info.minResizeWidth,
mDesiredWidgetWidth);
}
}
final boolean isVerticallyResizable =
(info.resizeMode & AppWidgetProviderInfo.RESIZE_VERTICAL) != 0;
if (mDesiredWidgetHeight > 0 && isVerticallyResizable) {
if (info.maxResizeHeight > 0
&& info.maxResizeHeight >= info.minHeight
&& info.maxResizeHeight < mDesiredWidgetHeight) {
return rejectWidget(
widget,
"maxResizeHeight[%d] < mDesiredWidgetHeight[%d]",
info.maxResizeHeight,
mDesiredWidgetHeight);
}
final int minHeight = Math.min(info.minResizeHeight, info.minHeight);
if (minHeight > mDesiredWidgetHeight) {
return rejectWidget(
widget,
"min(minHeight[%d], minResizeHeight[%d]) > mDesiredWidgetHeight[%d]",
info.minHeight,
info.minResizeHeight,
mDesiredWidgetHeight);
}
}
if (!isHorizontallyResizable || !isVerticallyResizable) {
return rejectWidget(widget, "not resizeable");
}
return acceptWidget(widget);
}
private static WidgetAcceptabilityVerdict rejectWidget(
WidgetItem widget, String rejectionReason, Object... args) {
return new WidgetAcceptabilityVerdict(
false,
widget.widgetInfo != null
? widget.widgetInfo.provider.flattenToShortString()
: widget.label,
String.format(Locale.ENGLISH, rejectionReason, args));
}
private static WidgetAcceptabilityVerdict acceptWidget(WidgetItem widget) {
return new WidgetAcceptabilityVerdict(
true, widget.widgetInfo.provider.flattenToShortString(), "");
}
private record WidgetAcceptabilityVerdict(
boolean isAcceptable, String widgetLabel, String reason) {
void maybeLogVerdict() {
// Only log a verdict if a reason is specified.
if (Log.isLoggable(TAG, Log.DEBUG) && !reason.isEmpty()) {
Log.i(TAG, String.format(
Locale.ENGLISH,
"%s: %s because %s",
widgetLabel,
isAcceptable ? "accepted" : "rejected",
reason));
}
}
}
}