Donate to e Foundation | Murena handsets with /e/OS | Own a part of Murena! Learn more

Commit c5fbb42d authored by Shamali P's avatar Shamali P
Browse files

Add a default widget category provider that uses application category.

The category provider can be customized by any launcher via resource
override. For instance, one can override it to provide custom categories
using an allowlist or use a different mechanism such as query play
services.

We still need to get proper strings for categories from UX writer.

Bug: 318410881
Test: WidgetRecommendationCategoryProviderTest
Flag: ACONFIG com.android.launcher3.enable_categorized_widget_recommendations DEVELOPMENT
Change-Id: I5c4e0d22eaffc8254ddd54356f8c62f00e22a3c4
parent c48c660b
Loading
Loading
Loading
Loading
+8 −0
Original line number Diff line number Diff line
@@ -194,6 +194,11 @@

    <string-array name="filtered_components" ></string-array>

    <!-- Widget component names to be included in weather category of widget suggestions. -->
    <string-array name="weather_recommendations"></string-array>
    <!-- Widget component names to be included in fitness category of widget suggestions. -->
    <string-array name="fitness_recommendations"></string-array>

    <!-- Name of the class used to generate colors from the wallpaper colors. Must be implementing the LauncherAppWidgetHostView.ColorGenerator interface. -->
    <string name="color_generator_class" translatable="false"/>

@@ -252,6 +257,9 @@
    <!--  Used for custom widgets  -->
    <array name="custom_widget_providers"/>

    <!--  Used for determining category of a widget presented in widget recommendations. -->
    <string name="widget_recommendation_category_provider_class" translatable="false"></string>

    <!-- Embed parameters -->
    <dimen name="activity_split_ratio"  format="float">0.5</dimen>
    <integer name="min_width_split">720</integer>
+6 −0
Original line number Diff line number Diff line
@@ -71,6 +71,12 @@
    <!-- Widget suggestions header title in the full widgets picker for large screen devices
    in landscape mode. [CHAR_LIMIT=50] -->
    <string name="suggested_widgets_header_title">Suggestions</string>
    <string name="productivity_widget_recommendation_category_label">Boost your day</string>
    <string name="news_widget_recommendation_category_label">News For You</string>
    <string name="social_and_entertainment_widget_recommendation_category_label">Your Chill Zone</string>
    <string name="fitness_widget_recommendation_category_label">Reach Your Fitness Goals</string>
    <string name="weather_widget_recommendation_category_label">Stay Ahead of the Weather</string>
    <string name="others_widget_recommendation_category_label">You Might Also Like</string>
    <!-- Label for showing the number of widgets an app has in the full widgets picker.
         [CHAR_LIMIT=25][ICU SYNTAX] -->
    <string name="widgets_count">
+62 −0
Original line number Diff line number Diff line
/*
 * Copyright (C) 2024 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 androidx.annotation.Nullable;
import androidx.annotation.StringRes;

import java.util.Objects;

/**
 * A category of widget recommendations displayed in the widget picker (launched from "Widgets"
 * option in the pop-up opened on long press of launcher workspace).
 */
public class WidgetRecommendationCategory implements Comparable<WidgetRecommendationCategory> {
    /** Resource id that holds the user friendly label for the category. */
    @StringRes
    public final int categoryTitleRes;
    /**
     * Relative order of this category with respect to other categories.
     *
     * <p>Category with lowest order is displayed first in the recommendations section.</p>
     */
    public final int order;

    public WidgetRecommendationCategory(@StringRes int categoryTitleRes, int order) {
        this.categoryTitleRes = categoryTitleRes;
        this.order = order;
    }

    @Override
    public int hashCode() {
        return Objects.hash(categoryTitleRes, order);
    }

    @Override
    public boolean equals(@Nullable Object obj) {
        if (!(obj instanceof WidgetRecommendationCategory category)) {
            return false;
        }
        return categoryTitleRes == category.categoryTitleRes
                && order == category.order;
    }

    @Override
    public int compareTo(WidgetRecommendationCategory widgetRecommendationCategory) {
        return order - widgetRecommendationCategory.order;
    }
}
+126 −0
Original line number Diff line number Diff line
/*
 * Copyright (C) 2024 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.content.Context;
import android.content.pm.ApplicationInfo;
import android.content.pm.PackageManager;
import android.util.Log;

import androidx.annotation.WorkerThread;

import com.android.launcher3.R;
import com.android.launcher3.model.WidgetItem;
import com.android.launcher3.util.Preconditions;
import com.android.launcher3.util.ResourceBasedOverride;

/**
 * A {@link ResourceBasedOverride} that categorizes widget recommendations.
 *
 * <p>Override the {@code widget_recommendation_category_provider_class} resource to provide your
 * own implementation. Method {@code getWidgetRecommendationCategory} is called per widget to get
 * the category.</p>
 */
public class WidgetRecommendationCategoryProvider implements ResourceBasedOverride {
    private static final String TAG = "WidgetRecommendationCategoryProvider";

    /**
     * Retrieve instance of this object that can be overridden in runtime based on the build
     * variant of the application.
     */
    public static WidgetRecommendationCategoryProvider newInstance(Context context) {
        Preconditions.assertWorkerThread();
        return Overrides.getObject(
                WidgetRecommendationCategoryProvider.class, context.getApplicationContext(),
                R.string.widget_recommendation_category_provider_class);
    }

    /**
     * Returns a {@link WidgetRecommendationCategory} for the provided widget item that can be used
     * to display the recommendation grouped by categories.
     */
    @WorkerThread
    public WidgetRecommendationCategory getWidgetRecommendationCategory(Context context,
            WidgetItem item) {
        // This is a default implementation that uses application category to derive the category to
        // be displayed. The implementation can be overridden in individual launcher customization
        // via the overridden WidgetRecommendationCategoryProvider resource.

        Preconditions.assertWorkerThread();
        PackageManager pm = context.getPackageManager();
        if (item.widgetInfo != null && item.widgetInfo.getComponent() != null) {
            String widgetComponentName = item.widgetInfo.getComponent().getClassName();
            try {
                int predictionCategory = pm.getApplicationInfo(
                        item.widgetInfo.getComponent().getPackageName(), 0 /* flags */).category;
                return getCategoryFromApplicationCategory(context, predictionCategory,
                        widgetComponentName);
            } catch (PackageManager.NameNotFoundException e) {
                Log.e(TAG, "Failed to retrieve application category when determining the "
                        + "widget category for " + widgetComponentName, e);
            }
        }
        return null;
    }

    /** Maps application category to an appropriate displayable category. */
    private static WidgetRecommendationCategory getCategoryFromApplicationCategory(
            Context context, int applicationCategory, String componentName) {
        if (applicationCategory == ApplicationInfo.CATEGORY_PRODUCTIVITY) {
            return new WidgetRecommendationCategory(
                    R.string.productivity_widget_recommendation_category_label, /*order=*/0);
        }

        if (applicationCategory == ApplicationInfo.CATEGORY_NEWS) {
            return new WidgetRecommendationCategory(
                    R.string.news_widget_recommendation_category_label, /*order=*/1);
        }

        if (applicationCategory == ApplicationInfo.CATEGORY_SOCIAL
                || applicationCategory == ApplicationInfo.CATEGORY_AUDIO
                || applicationCategory == ApplicationInfo.CATEGORY_VIDEO
                || applicationCategory == ApplicationInfo.CATEGORY_IMAGE) {
            return new WidgetRecommendationCategory(
                    R.string.social_and_entertainment_widget_recommendation_category_label,
                    /*order=*/4);
        }

        // Fitness & weather categories don't map to a specific application category, so, we
        // maintain an allowlist.
        String[] weatherRecommendationAllowlist =
                context.getResources().getStringArray(R.array.weather_recommendations);
        for (String allowedWeatherComponentName : weatherRecommendationAllowlist) {
            if (componentName.equalsIgnoreCase(allowedWeatherComponentName)) {
                return new WidgetRecommendationCategory(
                        R.string.weather_widget_recommendation_category_label, /*order=*/2);
            }
        }

        String[] fitnessRecommendationAllowlist =
                context.getResources().getStringArray(R.array.fitness_recommendations);
        for (String allowedFitnessComponentName : fitnessRecommendationAllowlist) {
            if (componentName.equalsIgnoreCase(allowedFitnessComponentName)) {
                return new WidgetRecommendationCategory(
                        R.string.fitness_widget_recommendation_category_label, /*order=*/3);
            }
        }

        return new WidgetRecommendationCategory(
                R.string.others_widget_recommendation_category_label, /*order=*/5);
    }

}
+156 −0
Original line number Diff line number Diff line
/*
 * Copyright (C) 2024 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 static android.content.pm.ApplicationInfo.CATEGORY_AUDIO;
import static android.content.pm.ApplicationInfo.CATEGORY_IMAGE;
import static android.content.pm.ApplicationInfo.CATEGORY_NEWS;
import static android.content.pm.ApplicationInfo.CATEGORY_PRODUCTIVITY;
import static android.content.pm.ApplicationInfo.CATEGORY_SOCIAL;
import static android.content.pm.ApplicationInfo.CATEGORY_UNDEFINED;
import static android.content.pm.ApplicationInfo.CATEGORY_VIDEO;

import static androidx.test.core.app.ApplicationProvider.getApplicationContext;
import static androidx.test.platform.app.InstrumentationRegistry.getInstrumentation;

import static com.google.common.truth.Truth.assertThat;

import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyInt;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.Mockito.doAnswer;
import static org.mockito.Mockito.when;

import android.appwidget.AppWidgetManager;
import android.appwidget.AppWidgetProviderInfo;
import android.content.ComponentName;
import android.content.Context;
import android.content.ContextWrapper;
import android.content.pm.ApplicationInfo;
import android.content.pm.PackageManager;
import android.os.Process;

import androidx.test.core.content.pm.ApplicationInfoBuilder;
import androidx.test.ext.junit.runners.AndroidJUnit4;
import androidx.test.filters.SmallTest;

import com.android.launcher3.InvariantDeviceProfile;
import com.android.launcher3.R;
import com.android.launcher3.icons.IconCache;
import com.android.launcher3.model.WidgetItem;
import com.android.launcher3.util.Executors;
import com.android.launcher3.widget.LauncherAppWidgetProviderInfo;

import com.google.common.collect.ImmutableMap;

import org.junit.Before;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.mockito.Mock;
import org.mockito.MockitoAnnotations;

import java.util.Map;

@SmallTest
@RunWith(AndroidJUnit4.class)
public class WidgetRecommendationCategoryProviderTest {
    private static final String TEST_PACKAGE = "com.foo.test";
    private static final String TEST_APP_NAME = "foo";
    public static final WidgetRecommendationCategory SOCIAL_AND_ENTERTAINMENT_CATEGORY =
            new WidgetRecommendationCategory(
                    R.string.social_and_entertainment_widget_recommendation_category_label,
                    /*order=*/4);
    private final ApplicationInfo mTestAppInfo = ApplicationInfoBuilder.newBuilder().setPackageName(
            TEST_PACKAGE).setName(TEST_APP_NAME).build();
    private Context mContext;
    @Mock
    private IconCache mIconCache;

    private WidgetItem mTestWidgetItem;
    @Mock
    private PackageManager mPackageManager;
    private InvariantDeviceProfile mTestProfile;

    @Before
    public void setUp() {
        MockitoAnnotations.initMocks(this);
        mContext = new ContextWrapper(getInstrumentation().getTargetContext()) {
            @Override
            public PackageManager getPackageManager() {
                return mPackageManager;
            }
        };
        mTestProfile = new InvariantDeviceProfile();
        mTestProfile.numRows = 5;
        mTestProfile.numColumns = 5;
        createTestWidgetItem();
    }

    @Test
    public void getWidgetRecommendationCategory_returnsMappedCategory() throws Exception {
        ImmutableMap<Integer, WidgetRecommendationCategory> testCategories = ImmutableMap.of(
                CATEGORY_PRODUCTIVITY, new WidgetRecommendationCategory(
                        R.string.productivity_widget_recommendation_category_label,
                        /*order=*/
                        0),
                CATEGORY_NEWS, new WidgetRecommendationCategory(
                        R.string.news_widget_recommendation_category_label, /*order=*/1),
                CATEGORY_SOCIAL, SOCIAL_AND_ENTERTAINMENT_CATEGORY,
                CATEGORY_AUDIO, SOCIAL_AND_ENTERTAINMENT_CATEGORY,
                CATEGORY_IMAGE, SOCIAL_AND_ENTERTAINMENT_CATEGORY,
                CATEGORY_VIDEO, SOCIAL_AND_ENTERTAINMENT_CATEGORY,
                CATEGORY_UNDEFINED, new WidgetRecommendationCategory(
                        R.string.others_widget_recommendation_category_label, /*order=*/5));

        for (Map.Entry<Integer, WidgetRecommendationCategory> testCategory :
                testCategories.entrySet()) {

            mTestAppInfo.category = testCategory.getKey();
            when(mPackageManager.getApplicationInfo(anyString(), anyInt())).thenReturn(
                    mTestAppInfo);

            WidgetRecommendationCategory category = Executors.MODEL_EXECUTOR.submit(() ->
                    new WidgetRecommendationCategoryProvider().getWidgetRecommendationCategory(
                            mContext,
                            mTestWidgetItem)).get();

            assertThat(category).isEqualTo(testCategory.getValue());
        }
    }

    private void createTestWidgetItem() {
        String widgetLabel = "Foo Widget";
        String widgetClassName = ".mWidget";

        doAnswer(invocation -> widgetLabel).when(mIconCache).getTitleNoCache(any());

        AppWidgetProviderInfo providerInfo = AppWidgetManager.getInstance(getApplicationContext())
                .getInstalledProvidersForPackage(
                        getInstrumentation().getContext().getPackageName(), Process.myUserHandle())
                .get(0);
        providerInfo.provider = ComponentName.createRelative(TEST_PACKAGE, widgetClassName);

        LauncherAppWidgetProviderInfo launcherAppWidgetProviderInfo =
                LauncherAppWidgetProviderInfo.fromProviderInfo(mContext, providerInfo);
        launcherAppWidgetProviderInfo.spanX = 2;
        launcherAppWidgetProviderInfo.spanY = 2;
        launcherAppWidgetProviderInfo.label = widgetLabel;
        mTestWidgetItem = new WidgetItem(launcherAppWidgetProviderInfo, mTestProfile, mIconCache,
                mContext
        );
    }
}