Support exclusion widget category filter in the widget picker

The newly added NOT_KEYGUARD category enables hosts that shows all
widgets to let widgets opt out from being displayed in keyguard like
surfaces.

Bug: 394047125
Test: Unit tests and support app
Flag: EXEMPT bugfix

Change-Id: Ibaab0c8a052700b77289cd571bca33e3d96fa09f
diff --git a/quickstep/src/com/android/launcher3/WidgetPickerActivity.java b/quickstep/src/com/android/launcher3/WidgetPickerActivity.java
index dc0f899..1cf7dda 100644
--- a/quickstep/src/com/android/launcher3/WidgetPickerActivity.java
+++ b/quickstep/src/com/android/launcher3/WidgetPickerActivity.java
@@ -23,6 +23,8 @@
 import static com.android.launcher3.util.Executors.MAIN_EXECUTOR;
 import static com.android.launcher3.util.Executors.MODEL_EXECUTOR;
 
+import static java.lang.Math.max;
+import static java.lang.Math.min;
 import static java.util.Collections.emptyList;
 
 import android.appwidget.AppWidgetManager;
@@ -53,6 +55,7 @@
 import com.android.launcher3.widget.WidgetCell;
 import com.android.launcher3.widget.model.WidgetsListBaseEntriesBuilder;
 import com.android.launcher3.widget.model.WidgetsListBaseEntry;
+import com.android.launcher3.widget.picker.WidgetCategoryFilter;
 import com.android.launcher3.widget.picker.WidgetsFullSheet;
 import com.android.launcher3.widget.picker.model.WidgetPickerDataProvider;
 import com.android.systemui.animation.back.FlingOnBackAnimationCallback;
@@ -81,6 +84,10 @@
     // 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";
+    // Unlike the AppWidgetManager.EXTRA_CATEGORY_FILTER, this filter removes certain categories.
+    // Filter is ignore if it is not a negative value.
+    // Example usage: WIDGET_CATEGORY_HOME_SCREEN.inv() and WIDGET_CATEGORY_NOT_KEYGUARD.inv()
+    private static final String EXTRA_CATEGORY_EXCLUSION_FILTER = "category_exclusion_filter";
     /**
      * Widgets currently added by the user in the UI surface.
      * <p>This allows widget picker to exclude existing widgets from suggestions.</p>
@@ -120,7 +127,8 @@
 
     private int mDesiredWidgetWidth;
     private int mDesiredWidgetHeight;
-    private int mWidgetCategoryFilter;
+    private WidgetCategoryFilter mWidgetCategoryInclusionFilter;
+    private WidgetCategoryFilter mWidgetCategoryExclusionFilter;
     @Nullable
     private String mUiSurface;
     // Widgets existing on the host surface.
@@ -194,8 +202,19 @@
                 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);
+        // Negative value indicates it's an exclusion filter (e.g. NOT_KEYGUARD_CATEGORY.inv())
+        // Positive value indicates it's inclusion filter (e.g. HOME_SCREEN or KEYGUARD)
+        // Note: A filter can either be inclusion or exclusion filter; not both.
+        int inclusionFilter = getIntent().getIntExtra(AppWidgetManager.EXTRA_CATEGORY_FILTER, 0);
+        if (inclusionFilter < 0) {
+            Log.w(TAG, "Invalid EXTRA_CATEGORY_FILTER: " + inclusionFilter);
+        }
+        mWidgetCategoryInclusionFilter = new WidgetCategoryFilter(max(0, inclusionFilter));
+        int exclusionFilter = getIntent().getIntExtra(EXTRA_CATEGORY_EXCLUSION_FILTER, 0);
+        if (exclusionFilter > 0) {
+            Log.w(TAG, "Invalid EXTRA_CATEGORY_EXCLUSION_FILTER: " + exclusionFilter);
+        }
+        mWidgetCategoryExclusionFilter = new WidgetCategoryFilter(min(0 , exclusionFilter));
 
         String uiSurfaceParam = getIntent().getStringExtra(EXTRA_UI_SURFACE);
         if (uiSurfaceParam != null && UI_SURFACE_PATTERN.matcher(uiSurfaceParam).matches()) {
@@ -436,11 +455,13 @@
                     widget.user.getIdentifier());
         }
 
-        if (mWidgetCategoryFilter > 0 && (info.widgetCategory & mWidgetCategoryFilter) == 0) {
+        if (!mWidgetCategoryInclusionFilter.matches(info.widgetCategory)
+                || !mWidgetCategoryExclusionFilter.matches(info.widgetCategory)) {
             return rejectWidget(
                     widget,
-                    "doesn't match category filter [filter=%d, widget=%d]",
-                    mWidgetCategoryFilter,
+                    "doesn't match category filter [inclusion=%d, exclusion=%d, widget=%d]",
+                    mWidgetCategoryInclusionFilter.getCategoryMask(),
+                    mWidgetCategoryExclusionFilter.getCategoryMask(),
                     info.widgetCategory);
         }
 
@@ -463,7 +484,7 @@
                             mDesiredWidgetWidth);
                 }
 
-                final int minWidth = Math.min(info.minResizeWidth, info.minWidth);
+                final int minWidth = min(info.minResizeWidth, info.minWidth);
                 if (minWidth > mDesiredWidgetWidth) {
                     return rejectWidget(
                             widget,
@@ -487,7 +508,7 @@
                             mDesiredWidgetHeight);
                 }
 
-                final int minHeight = Math.min(info.minResizeHeight, info.minHeight);
+                final int minHeight = min(info.minResizeHeight, info.minHeight);
                 if (minHeight > mDesiredWidgetHeight) {
                     return rejectWidget(
                             widget,
diff --git a/quickstep/src/com/android/launcher3/widget/picker/WidgetCategoryFilter.kt b/quickstep/src/com/android/launcher3/widget/picker/WidgetCategoryFilter.kt
new file mode 100644
index 0000000..69feb4a
--- /dev/null
+++ b/quickstep/src/com/android/launcher3/widget/picker/WidgetCategoryFilter.kt
@@ -0,0 +1,38 @@
+/*
+ * Copyright (C) 2025 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.widget.picker
+
+/**
+ * A filter that can be applied on the widgetCategory attribute from appwidget-provider to identify
+ * if the widget can be displayed on a specific widget surface.
+ * - Negative value (e.g. "category_a.inv() and category_b.inv()" excludes the widgets with given
+ *   categories.
+ * - Positive value (e.g. "category_a or category_b" includes widgets with those categories.
+ * - 0 means no filter.
+ */
+class WidgetCategoryFilter(val categoryMask: Int) {
+    /** Applies the [categoryMask] to return if the [widgetCategory] matches. */
+    fun matches(widgetCategory: Int): Boolean {
+        return if (categoryMask > 0) { // inclusion filter
+            (widgetCategory and categoryMask) != 0
+        } else if (categoryMask < 0) { // exclusion filter
+            (widgetCategory and categoryMask) == widgetCategory
+        } else {
+            true // no filter
+        }
+    }
+}
diff --git a/quickstep/tests/multivalentTests/src/com/android/launcher3/widget/picker/WidgetCategoryFilterTest.kt b/quickstep/tests/multivalentTests/src/com/android/launcher3/widget/picker/WidgetCategoryFilterTest.kt
new file mode 100644
index 0000000..9b0a95a
--- /dev/null
+++ b/quickstep/tests/multivalentTests/src/com/android/launcher3/widget/picker/WidgetCategoryFilterTest.kt
@@ -0,0 +1,133 @@
+/*
+ * Copyright (C) 2025 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.widget.picker
+
+import android.appwidget.AppWidgetProviderInfo.WIDGET_CATEGORY_HOME_SCREEN
+import android.appwidget.AppWidgetProviderInfo.WIDGET_CATEGORY_KEYGUARD
+import android.appwidget.AppWidgetProviderInfo.WIDGET_CATEGORY_NOT_KEYGUARD
+import android.appwidget.AppWidgetProviderInfo.WIDGET_CATEGORY_SEARCHBOX
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import com.google.common.truth.Truth.assertThat
+import org.junit.Test
+import org.junit.runner.RunWith
+
+@RunWith(AndroidJUnit4::class)
+class WidgetCategoryFilterTest {
+
+    @Test
+    fun filterValueZero_everythingMatches() {
+        val noFilter = WidgetCategoryFilter(categoryMask = 0)
+
+        noFilter.assertMatches(WIDGET_CATEGORY_HOME_SCREEN)
+        noFilter.assertMatches(WIDGET_CATEGORY_KEYGUARD)
+        noFilter.assertMatches(WIDGET_CATEGORY_NOT_KEYGUARD)
+        noFilter.assertMatches(WIDGET_CATEGORY_SEARCHBOX)
+        noFilter.assertMatches(WIDGET_CATEGORY_HOME_SCREEN or WIDGET_CATEGORY_KEYGUARD)
+        noFilter.assertMatches(WIDGET_CATEGORY_HOME_SCREEN or WIDGET_CATEGORY_NOT_KEYGUARD)
+        noFilter.assertMatches(
+            WIDGET_CATEGORY_HOME_SCREEN or WIDGET_CATEGORY_SEARCHBOX or WIDGET_CATEGORY_NOT_KEYGUARD
+        )
+        noFilter.assertMatches(
+            WIDGET_CATEGORY_HOME_SCREEN or WIDGET_CATEGORY_KEYGUARD or WIDGET_CATEGORY_NOT_KEYGUARD
+        )
+        noFilter.assertMatches(WIDGET_CATEGORY_SEARCHBOX or WIDGET_CATEGORY_KEYGUARD)
+    }
+
+    @Test
+    fun includeHomeScreen_matchesOnlyIfHomeScreenExists() {
+        val filter = WidgetCategoryFilter(WIDGET_CATEGORY_HOME_SCREEN)
+
+        filter.assertMatches(WIDGET_CATEGORY_HOME_SCREEN)
+        filter.assertMatches(WIDGET_CATEGORY_KEYGUARD or WIDGET_CATEGORY_HOME_SCREEN)
+        filter.assertMatches(WIDGET_CATEGORY_SEARCHBOX or WIDGET_CATEGORY_HOME_SCREEN)
+        filter.assertMatches(WIDGET_CATEGORY_NOT_KEYGUARD or WIDGET_CATEGORY_HOME_SCREEN)
+
+        filter.assertDoesNotMatch(WIDGET_CATEGORY_KEYGUARD)
+        filter.assertDoesNotMatch(WIDGET_CATEGORY_NOT_KEYGUARD)
+        filter.assertDoesNotMatch(WIDGET_CATEGORY_SEARCHBOX)
+        filter.assertDoesNotMatch(WIDGET_CATEGORY_KEYGUARD or WIDGET_CATEGORY_SEARCHBOX)
+    }
+
+    @Test
+    fun includeHomeScreenOrKeyguard_matchesIfEitherHomeScreenOrKeyguardExists() {
+        val filter = WidgetCategoryFilter(WIDGET_CATEGORY_HOME_SCREEN or WIDGET_CATEGORY_KEYGUARD)
+
+        filter.assertMatches(WIDGET_CATEGORY_HOME_SCREEN)
+        filter.assertMatches(WIDGET_CATEGORY_KEYGUARD)
+        filter.assertMatches(WIDGET_CATEGORY_KEYGUARD or WIDGET_CATEGORY_HOME_SCREEN)
+        filter.assertMatches(WIDGET_CATEGORY_SEARCHBOX or WIDGET_CATEGORY_HOME_SCREEN)
+        filter.assertMatches(WIDGET_CATEGORY_SEARCHBOX or WIDGET_CATEGORY_KEYGUARD)
+        filter.assertMatches(WIDGET_CATEGORY_NOT_KEYGUARD or WIDGET_CATEGORY_HOME_SCREEN)
+        filter.assertMatches(WIDGET_CATEGORY_NOT_KEYGUARD or WIDGET_CATEGORY_KEYGUARD)
+
+        filter.assertDoesNotMatch(WIDGET_CATEGORY_NOT_KEYGUARD)
+        filter.assertDoesNotMatch(WIDGET_CATEGORY_SEARCHBOX)
+        filter.assertDoesNotMatch(WIDGET_CATEGORY_SEARCHBOX or WIDGET_CATEGORY_NOT_KEYGUARD)
+    }
+
+    @Test
+    fun excludeNotKeyguard_doesNotMatchIfNotKeyguardExists() {
+        val filter = WidgetCategoryFilter(WIDGET_CATEGORY_NOT_KEYGUARD.inv())
+
+        filter.assertMatches(WIDGET_CATEGORY_HOME_SCREEN)
+        filter.assertMatches(WIDGET_CATEGORY_KEYGUARD)
+        filter.assertMatches(WIDGET_CATEGORY_SEARCHBOX)
+        filter.assertMatches(WIDGET_CATEGORY_KEYGUARD or WIDGET_CATEGORY_HOME_SCREEN)
+        filter.assertMatches(WIDGET_CATEGORY_SEARCHBOX or WIDGET_CATEGORY_HOME_SCREEN)
+        filter.assertMatches(WIDGET_CATEGORY_SEARCHBOX or WIDGET_CATEGORY_KEYGUARD)
+
+        filter.assertDoesNotMatch(WIDGET_CATEGORY_NOT_KEYGUARD)
+        filter.assertDoesNotMatch(WIDGET_CATEGORY_NOT_KEYGUARD or WIDGET_CATEGORY_HOME_SCREEN)
+        filter.assertDoesNotMatch(WIDGET_CATEGORY_NOT_KEYGUARD or WIDGET_CATEGORY_KEYGUARD)
+        filter.assertDoesNotMatch(WIDGET_CATEGORY_SEARCHBOX or WIDGET_CATEGORY_NOT_KEYGUARD)
+        filter.assertDoesNotMatch(
+            WIDGET_CATEGORY_NOT_KEYGUARD or WIDGET_CATEGORY_KEYGUARD or WIDGET_CATEGORY_HOME_SCREEN
+        )
+    }
+
+    @Test
+    fun multipleExclusions_doesNotMatchIfExcludedCategoriesExist() {
+        val filter =
+            WidgetCategoryFilter(
+                WIDGET_CATEGORY_HOME_SCREEN.inv() and WIDGET_CATEGORY_NOT_KEYGUARD.inv()
+            )
+
+        filter.assertMatches(WIDGET_CATEGORY_SEARCHBOX)
+        filter.assertMatches(WIDGET_CATEGORY_KEYGUARD)
+        filter.assertMatches(WIDGET_CATEGORY_SEARCHBOX or WIDGET_CATEGORY_KEYGUARD)
+
+        filter.assertDoesNotMatch(WIDGET_CATEGORY_HOME_SCREEN)
+        filter.assertDoesNotMatch(WIDGET_CATEGORY_KEYGUARD or WIDGET_CATEGORY_HOME_SCREEN)
+        filter.assertDoesNotMatch(WIDGET_CATEGORY_SEARCHBOX or WIDGET_CATEGORY_HOME_SCREEN)
+
+        filter.assertDoesNotMatch(WIDGET_CATEGORY_NOT_KEYGUARD)
+        filter.assertDoesNotMatch(WIDGET_CATEGORY_NOT_KEYGUARD or WIDGET_CATEGORY_HOME_SCREEN)
+        filter.assertDoesNotMatch(WIDGET_CATEGORY_NOT_KEYGUARD or WIDGET_CATEGORY_KEYGUARD)
+        filter.assertDoesNotMatch(WIDGET_CATEGORY_SEARCHBOX or WIDGET_CATEGORY_NOT_KEYGUARD)
+        filter.assertDoesNotMatch(
+            WIDGET_CATEGORY_NOT_KEYGUARD or WIDGET_CATEGORY_KEYGUARD or WIDGET_CATEGORY_HOME_SCREEN
+        )
+    }
+
+    private fun WidgetCategoryFilter.assertMatches(category: Int) {
+        assertThat(matches(category)).isTrue()
+    }
+
+    private fun WidgetCategoryFilter.assertDoesNotMatch(category: Int) {
+        assertThat(matches(category)).isFalse()
+    }
+}