Add some category methods

- Extract CursorUtils for Category and Item
- Add related methods of getCategories and getCategoryItems in
  PickerViewModdel
- Fix getItem with categogry issue
- Support localizations for default categories.

Test: atest CategoryTest
Bug: 185801192
Change-Id: I5eb39f94e28c779d20176063e12e300a4e5957a1
Merged-In: I5eb39f94e28c779d20176063e12e300a4e5957a1
(cherry picked from commit b6cf17bb072a6983bbdc0816d97489b7765420ad)
diff --git a/res/values/strings.xml b/res/values/strings.xml
index fb6a708..1b93546 100644
--- a/res/values/strings.xml
+++ b/res/values/strings.xml
@@ -115,6 +115,17 @@
     <!-- Text shown on the add button for multi-select in PhotoPicker. [CHAR LIMIT=30]  -->
     <string name="picker_add_button_multi_select">Add (<xliff:g id="count" example="42">^1</xliff:g>)</string>
 
+    <!-- Title for the category in the picker that offers items in Camera folder. [CHAR LIMIT=24] -->
+    <string name="picker_category_camera">Camera</string>
+    <!-- Title for the category in the picker that offers downloaded items. [CHAR LIMIT=24] -->
+    <string name="picker_category_downloads">Downloads</string>
+    <!-- Title for the category in the picker that offers favorite items. [CHAR LIMIT=24] -->
+    <string name="picker_category_favorites">Favorites</string>
+    <!-- Title for the category in the picker that offers screenshots. [CHAR LIMIT=24] -->
+    <string name="picker_category_screenshots">Screenshots</string>
+    <!-- Title for the category in the picker that offers videos. [CHAR LIMIT=24] -->
+    <string name="picker_category_videos">@string/root_videos</string>
+
     <!-- ========================= BEGIN AUTO-GENERATED BY gen_strings.py ========================= -->
 
     <!-- ========================= WRITE STRINGS ========================= -->
diff --git a/src/com/android/providers/media/photopicker/data/ItemsProvider.java b/src/com/android/providers/media/photopicker/data/ItemsProvider.java
index 9499d42..bf1a769 100644
--- a/src/com/android/providers/media/photopicker/data/ItemsProvider.java
+++ b/src/com/android/providers/media/photopicker/data/ItemsProvider.java
@@ -94,7 +94,7 @@
             int offset, int limit, @Nullable String mimeType, @NonNull UserId userId) throws
             IllegalArgumentException, IllegalStateException {
         // Validate incoming params
-        if (category != null && Category.isValidCategory(category)) {
+        if (category != null && !Category.isValidCategory(category)) {
             throw new IllegalArgumentException("ItemsProvider does not support the given "
                     + "category: " + category);
         }
@@ -160,9 +160,10 @@
         }
 
         return new String[] {
-                category,
-                String.valueOf(getMediaStoreUriForItem(c.getLong(0))),
-                String.valueOf(c.getCount())
+                category, // category name
+                String.valueOf(getMediaStoreUriForItem(c.getLong(0))), // coverUri
+                String.valueOf(c.getCount()), // item count
+                category // category type
         };
     }
 
diff --git a/src/com/android/providers/media/photopicker/data/model/Category.java b/src/com/android/providers/media/photopicker/data/model/Category.java
index e1a063e..5a17ede 100644
--- a/src/com/android/providers/media/photopicker/data/model/Category.java
+++ b/src/com/android/providers/media/photopicker/data/model/Category.java
@@ -16,16 +16,26 @@
 
 package com.android.providers.media.photopicker.data.model;
 
+import static com.android.providers.media.photopicker.util.CursorUtils.getCursorInt;
+import static com.android.providers.media.photopicker.util.CursorUtils.getCursorString;
+
 import android.annotation.StringDef;
+import android.content.Context;
 import android.database.Cursor;
+import android.net.Uri;
 import android.os.Environment;
 import android.provider.MediaStore;
 import android.provider.MediaStore.Files.FileColumns;
 import android.util.ArrayMap;
 
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+import androidx.annotation.VisibleForTesting;
+
+import com.android.providers.media.R;
+
 import java.lang.annotation.Retention;
 import java.lang.annotation.RetentionPolicy;
-import java.util.ArrayList;
 import java.util.Arrays;
 import java.util.Collections;
 import java.util.List;
@@ -123,11 +133,11 @@
     }
 
     private static String[] CATEGORIES = {
-            CATEGORY_SCREENSHOTS,
+            CATEGORY_FAVORITES,
             CATEGORY_CAMERA,
             CATEGORY_VIDEOS,
-            CATEGORY_FAVORITES,
-            CATEGORY_DOWNLOADS
+            CATEGORY_SCREENSHOTS,
+            CATEGORY_DOWNLOADS,
     };
 
     public static List<String> CATEGORIES_LIST = Collections.unmodifiableList(
@@ -137,6 +147,19 @@
         return CATEGORIES_LIST.contains(category);
     }
 
+    @CategoryType
+    private String mCategoryType;
+    private String mCategoryName;
+    private Uri mCoverUri;
+    private int mItemCount;
+
+    private Category() {}
+
+    @VisibleForTesting
+    Category(@NonNull Cursor cursor) {
+        updateFromCursor(cursor);
+    }
+
     /**
      * Defines category columns for each category
      */
@@ -144,13 +167,95 @@
         public static String NAME = "name";
         public static String COVER_URI = "cover_uri";
         public static String NUMBER_OF_ITEMS = "number_of_items";
+        public static String CATEGORY_TYPE = "category_type";
 
         public static String[] getAllColumns() {
             return new String[] {
                     NAME,
                     COVER_URI,
-                    NUMBER_OF_ITEMS
+                    NUMBER_OF_ITEMS,
+                    CATEGORY_TYPE,
             };
         }
     }
+
+    /**
+     * @return localized category name if {@code context} is not null and {@link #mCategoryType} is
+     * in {@link #CATEGORIES}, {@link #mCategoryName} otherwise.
+     */
+    public String getCategoryName(@Nullable Context context) {
+        if (context != null) {
+            final String categoryName = getCategoryName(context, mCategoryType);
+            if (categoryName != null) {
+                return categoryName;
+            }
+        }
+        return mCategoryName;
+    }
+
+    /**
+     * @return localized category name if {@link #mCategoryType} is in {@link #CATEGORIES},
+     * {@code null} otherwise.
+     */
+    public static String getCategoryName(@NonNull Context context,
+            @NonNull @CategoryType String categoryType) {
+        switch (categoryType) {
+            case CATEGORY_FAVORITES:
+                return context.getString(R.string.picker_category_favorites);
+            case CATEGORY_VIDEOS:
+                return context.getString(R.string.picker_category_videos);
+            case CATEGORY_CAMERA:
+                return context.getString(R.string.picker_category_camera);
+            case CATEGORY_SCREENSHOTS:
+                return context.getString(R.string.picker_category_screenshots);
+            case CATEGORY_DOWNLOADS:
+                return context.getString(R.string.picker_category_downloads);
+            default:
+                return null;
+        }
+    }
+
+    @CategoryType
+    public String getCategoryType() {
+        return mCategoryType;
+    }
+
+    public Uri getCoverUri() {
+        return mCoverUri;
+    }
+
+    public int getItemCount() {
+        return mItemCount;
+    }
+
+    /**
+     * Get the category instance with the {@link #mCategoryType} is {@link #CATEGORY_DEFAULT}
+     *
+     * @return the default category
+     */
+    public static Category getDefaultCategory() {
+        final Category category = new Category();
+        category.mCategoryType = CATEGORY_DEFAULT;
+        return category;
+    }
+
+    /**
+     * @return {@link Category} from the given {@code cursor}
+     */
+    public static Category fromCursor(@NonNull Cursor cursor) {
+        final Category category = new Category(cursor);
+        return category;
+    }
+
+    /**
+     * Update the category based on the {@code cursor}
+     *
+     * @param cursor the cursor to update the data
+     */
+    public void updateFromCursor(@NonNull Cursor cursor) {
+        mCategoryName = getCursorString(cursor, CategoryColumns.NAME);
+        mCoverUri = Uri.parse(getCursorString(cursor, CategoryColumns.COVER_URI));
+        mItemCount = getCursorInt(cursor, CategoryColumns.NUMBER_OF_ITEMS);
+        mCategoryType = getCursorString(cursor, CategoryColumns.CATEGORY_TYPE);
+    }
 }
diff --git a/src/com/android/providers/media/photopicker/data/model/Item.java b/src/com/android/providers/media/photopicker/data/model/Item.java
index da282af..4ae9643 100644
--- a/src/com/android/providers/media/photopicker/data/model/Item.java
+++ b/src/com/android/providers/media/photopicker/data/model/Item.java
@@ -16,6 +16,9 @@
 
 package com.android.providers.media.photopicker.data.model;
 
+import static com.android.providers.media.photopicker.util.CursorUtils.getCursorLong;
+import static com.android.providers.media.photopicker.util.CursorUtils.getCursorString;
+
 import android.database.Cursor;
 import android.net.Uri;
 import android.provider.MediaStore;
@@ -200,38 +203,4 @@
             mIsVideo = true;
         }
     }
-
-    @Nullable
-    private static String getCursorString(Cursor cursor, String columnName) {
-        if (cursor == null) {
-            return null;
-        }
-        final int index = cursor.getColumnIndex(columnName);
-        return (index != -1) ? cursor.getString(index) : null;
-    }
-
-    /**
-     * Missing or null values are returned as -1.
-     */
-    private static long getCursorLong(Cursor cursor, String columnName) {
-        if (cursor == null) {
-            return -1;
-        }
-
-        final int index = cursor.getColumnIndex(columnName);
-        if (index == -1) {
-            return -1;
-        }
-
-        final String value = cursor.getString(index);
-        if (value == null) {
-            return -1;
-        }
-
-        try {
-            return Long.parseLong(value);
-        } catch (NumberFormatException e) {
-            return -1;
-        }
-    }
 }
diff --git a/src/com/android/providers/media/photopicker/ui/PhotosTabFragment.java b/src/com/android/providers/media/photopicker/ui/PhotosTabFragment.java
index 1a8f2ee..7876fae 100644
--- a/src/com/android/providers/media/photopicker/ui/PhotosTabFragment.java
+++ b/src/com/android/providers/media/photopicker/ui/PhotosTabFragment.java
@@ -86,4 +86,4 @@
             PreviewFragment.show(getActivity().getSupportFragmentManager());
         }
     }
-}
\ No newline at end of file
+}
diff --git a/src/com/android/providers/media/photopicker/util/CursorUtils.java b/src/com/android/providers/media/photopicker/util/CursorUtils.java
new file mode 100644
index 0000000..a992f4e
--- /dev/null
+++ b/src/com/android/providers/media/photopicker/util/CursorUtils.java
@@ -0,0 +1,81 @@
+/*
+ * Copyright (C) 2021 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.providers.media.photopicker.util;
+
+import android.database.Cursor;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+
+/**
+ * Provide the utility methods to handle cursor.
+ */
+public class CursorUtils {
+
+    /**
+     * Get the string from the {@code cursor} with the {@code columnName}.
+     *
+     * @param cursor the cursor to be parsed
+     * @param columnName the column name of the value
+     * @return the string value from the {@code cursor}, or {@code null} when {@code cursor} doesn't
+     *         contain {@code columnName}
+     */
+    @Nullable
+    public static String getCursorString(@NonNull Cursor cursor, @NonNull String columnName) {
+        final int index = cursor.getColumnIndex(columnName);
+        return (index != -1) ? cursor.getString(index) : null;
+    }
+
+    /**
+     * Get the long value from the {@code cursor} with the {@code columnName}.
+     *
+     * @param cursor the cursor to be parsed
+     * @param columnName the column name of the value
+     * @return the long value from the {@code cursor}, or -1 when {@code cursor} doesn't contain
+     *         {@code columnName}
+     */
+    public static long getCursorLong(@NonNull Cursor cursor, @NonNull String columnName) {
+        final int index = cursor.getColumnIndex(columnName);
+        if (index == -1) {
+            return -1;
+        }
+
+        final String value = cursor.getString(index);
+        if (value == null) {
+            return -1;
+        }
+
+        try {
+            return Long.parseLong(value);
+        } catch (NumberFormatException e) {
+            return -1;
+        }
+    }
+
+    /**
+     * Get the int value from the {@code cursor} with the {@code columnName}.
+     *
+     * @param cursor the cursor to be parsed
+     * @param columnName the column name of the value
+     * @return the int value from the {@code cursor}, or 0 when {@code cursor} doesn't contain
+     *         {@code columnName}
+     */
+    public static int getCursorInt(@NonNull Cursor cursor, @NonNull String columnName) {
+        final int index = cursor.getColumnIndex(columnName);
+        return (index != -1) ? cursor.getInt(index) : 0;
+    }
+}
diff --git a/src/com/android/providers/media/photopicker/viewmodel/PickerViewModel.java b/src/com/android/providers/media/photopicker/viewmodel/PickerViewModel.java
index 16aa8f9..80d7c6e 100644
--- a/src/com/android/providers/media/photopicker/viewmodel/PickerViewModel.java
+++ b/src/com/android/providers/media/photopicker/viewmodel/PickerViewModel.java
@@ -28,17 +28,20 @@
 import android.net.Uri;
 import android.os.Bundle;
 import android.provider.MediaStore;
+import android.text.TextUtils;
 import android.util.Log;
 
 import androidx.lifecycle.AndroidViewModel;
 import androidx.lifecycle.LiveData;
 import androidx.lifecycle.MutableLiveData;
 
-import com.android.providers.media.photopicker.util.DateTimeUtils;
 import com.android.providers.media.photopicker.data.ItemsProvider;
 import com.android.providers.media.photopicker.data.UserIdManager;
+import com.android.providers.media.photopicker.data.model.Category;
+import com.android.providers.media.photopicker.data.model.Category.CategoryType;
 import com.android.providers.media.photopicker.data.model.Item;
 import com.android.providers.media.photopicker.data.model.UserId;
+import com.android.providers.media.photopicker.util.DateTimeUtils;
 import com.android.providers.media.util.ForegroundThread;
 
 import java.util.ArrayList;
@@ -55,8 +58,14 @@
     private static final int RECENT_MINIMUM_COUNT = 12;
     public static final int DEFAULT_MAX_SELECTION_LIMIT = 100;
 
+    // TODO(b/193857982): we keep these four data sets now, we may find a way to reduce the data
+    // set to reduce memories.
+    // the list of Items with all photos and videos
     private MutableLiveData<List<Item>> mItemList;
+    // the list of Items with all photos and videos in category
+    private MutableLiveData<List<Item>> mCategoryItemList;
     private MutableLiveData<Map<Uri, Item>> mSelectedItemList = new MutableLiveData<>();
+    private MutableLiveData<List<Category>> mCategoryList;
     private final ItemsProvider mItemsProvider;
     private final UserIdManager mUserIdManager;
     private boolean mSelectMultiple = false;
@@ -67,6 +76,8 @@
     // Show max label text view if and only if caller sets acceptable value for
     // {@link MediaStore#EXTRA_PICK_IMAGES_MAX}
     private boolean mShowMaxLabel = false;
+    @CategoryType
+    private String mCurrentCategoryType;
 
     public PickerViewModel(@NonNull Application application) {
         super(application);
@@ -76,7 +87,7 @@
     }
 
     /**
-     * @return the Map of selected Item.
+     * @return the {@link LiveData} of selected items {@link #mSelectedItemList}.
      */
     public LiveData<Map<Uri, Item>> getSelectedItems() {
         if (mSelectedItemList.getValue() == null) {
@@ -87,7 +98,7 @@
     }
 
     /**
-     * Add the selected Item.
+     * Add the selected {@code item} into {@link #mSelectedItemList}.
      */
     public void addSelectedItem(Item item) {
         if (mSelectedItemList.getValue() == null) {
@@ -101,7 +112,7 @@
     }
 
     /**
-     * Clear the selected Item list.
+     * Clear the selected Item list {@link #mSelectedItemList}.
      */
     public void clearSelectedItems() {
         if (mSelectedItemList.getValue() == null) {
@@ -112,7 +123,9 @@
     }
 
     /**
-     * Delete the selected Item.
+     * Delete the selected {@code item} from the selected item list {@link #mSelectedItemList}.
+     *
+     * @param item the item to be deleted from the selected item list
      */
     public void deleteSelectedItem(Item item) {
         if (mSelectedItemList.getValue() == null) {
@@ -149,7 +162,7 @@
     }
 
     /**
-     * @return the list of Items with all photos and videos on the device.
+     * @return the list of Items with all photos and videos {@link #mItemList} on the device.
      */
     public LiveData<List<Item>> getItems() {
         if (mItemList == null) {
@@ -158,31 +171,39 @@
         return mItemList;
     }
 
-    private List<Item> loadItems() {
+    private List<Item> loadItems(@Nullable @CategoryType String category) {
         final List<Item> items = new ArrayList<>();
         final UserId userId = mUserIdManager.getCurrentUserProfileId();
 
-        try (Cursor cursor = mItemsProvider.getItems(/* category */ null, /* offset */ 0,
+        try (Cursor cursor = mItemsProvider.getItems(category, /* offset */ 0,
                 /* limit */ -1, mMimeTypeFilter, userId)) {
             if (cursor == null) {
                 return items;
             }
 
+            // We only add the RECENT header on the PhotosTabFragment with CATEGORY_DEFAULT. In this
+            // case, we call this method {loadItems} with null category. When the category is not
+            // empty, we don't show the RECENT header.
+            final boolean showRecent = TextUtils.isEmpty(category);
+
             int recentSize = 0;
             long currentDateTaken = 0;
             // add max label message header item
             if (mShowMaxLabel) {
                 items.add(Item.createMessageItem());
             }
-            // add Recent date header
-            items.add(Item.createDateItem(0));
+
+            if (showRecent) {
+                // add Recent date header
+                items.add(Item.createDateItem(0));
+            }
             while (cursor.moveToNext()) {
                 // TODO(b/188394433): Return userId in the cursor so that we do not need to pass it
-                //  here again.
+                // here again.
                 final Item item = Item.fromCursor(cursor, userId);
                 final long dateTaken = item.getDateTaken();
                 // the minimum count of items in recent is not reached
-                if (recentSize < RECENT_MINIMUM_COUNT) {
+                if (showRecent && recentSize < RECENT_MINIMUM_COUNT) {
                     recentSize++;
                     currentDateTaken = dateTaken;
                 }
@@ -197,18 +218,23 @@
             }
         }
 
-        Log.d(TAG, "Loaded " + items.size() + " items for user " + userId.toString());
+        if (TextUtils.isEmpty(category)) {
+            Log.d(TAG, "Loaded " + items.size() + " items for user " + userId.toString());
+        } else {
+            Log.d(TAG, "Loaded " + items.size() + " items in " + category + " for user "
+                    + userId.toString());
+        }
         return items;
     }
 
     private void loadItemsAsync() {
         ForegroundThread.getExecutor().execute(() -> {
-            mItemList.postValue(loadItems());
+            mItemList.postValue(loadItems(/* category= */ null));
         });
     }
 
     /**
-     * Update the item List
+     * Update the item List {@link #mItemList}
      */
     public void updateItems() {
         if (mItemList == null) {
@@ -218,14 +244,95 @@
     }
 
     /**
-     * Return whether supports multiple select or not
+     * Get the list of all photos and videos with the specific {@code category} on the device.
+     *
+     * @param category the category we want to be queried
+     * @return the list of all photos and videos with the specific {@code category}
+     *         {@link #mCategoryItemList}
+     */
+    public LiveData<List<Item>> getCategoryItems(@NonNull @CategoryType String category) {
+        if (mCategoryItemList == null || !TextUtils.equals(category, mCurrentCategoryType)) {
+            mCurrentCategoryType = category;
+            updateCategoryItems(category);
+        }
+        return mCategoryItemList;
+    }
+
+    private void loadCategoryItemsAsync(@NonNull @CategoryType String category) {
+        ForegroundThread.getExecutor().execute(() -> {
+            mCategoryItemList.postValue(loadItems(category));
+        });
+    }
+
+    /**
+     * Update the item List with the {@code category} {@link #mCategoryItemList}
+     */
+    public void updateCategoryItems(@NonNull @CategoryType String category) {
+        if (mCategoryItemList == null) {
+            mCategoryItemList = new MutableLiveData<>();
+        }
+        loadCategoryItemsAsync(category);
+    }
+
+    /**
+     * @return the list of Categories {@link #mCategoryList}
+     */
+    public LiveData<List<Category>> getCategories() {
+        if (mCategoryList == null) {
+            updateCategories();
+        }
+        return mCategoryList;
+    }
+
+    private List<Category> loadCategories() {
+        final List<Category> categoryList = new ArrayList<>();
+        final UserId userId = mUserIdManager.getCurrentUserProfileId();
+        final Cursor cursor = mItemsProvider.getCategories(mMimeTypeFilter, userId);
+        if (cursor == null) {
+            return categoryList;
+        }
+
+        while (cursor.moveToNext()) {
+            final Category category = Category.fromCursor(cursor);
+            categoryList.add(category);
+        }
+
+        Log.d(TAG, "Loaded " + categoryList.size() + " categories for user " + userId.toString());
+        return categoryList;
+    }
+
+    private void loadCategoriesAsync() {
+        ForegroundThread.getExecutor().execute(() -> {
+            mCategoryList.postValue(loadCategories());
+        });
+    }
+
+    /**
+     * Update the category List {@link #mCategoryList}
+     */
+    public void updateCategories() {
+        if (mCategoryList == null) {
+            mCategoryList = new MutableLiveData<>();
+        }
+        loadCategoriesAsync();
+    }
+
+    /**
+     * Return whether supports multiple select {@link #mSelectMultiple} or not
      */
     public boolean canSelectMultiple() {
         return mSelectMultiple;
     }
 
     /**
-     * Parse values from Intent and set corresponding fields
+     * Return whether the {@link #mMimeTypeFilter} is {@code null} or not
+     */
+    public boolean hasMimeTypeFilter() {
+        return !TextUtils.isEmpty(mMimeTypeFilter);
+    }
+
+    /**
+     * Parse values from {@code intent} and set corresponding fields
      */
     public void parseValuesFromIntent(Intent intent) throws IllegalArgumentException {
         mSelectMultiple = intent.getBooleanExtra(Intent.EXTRA_ALLOW_MULTIPLE, false);
@@ -255,7 +362,7 @@
         }
     }
 
-    public static boolean isMimeTypeMedia(@Nullable String mimeType) {
+    private static boolean isMimeTypeMedia(@Nullable String mimeType) {
         return isImageMimeType(mimeType) || isVideoMimeType(mimeType);
     }
 
diff --git a/tests/src/com/android/providers/media/photopicker/data/model/CategoryTest.java b/tests/src/com/android/providers/media/photopicker/data/model/CategoryTest.java
new file mode 100644
index 0000000..6427ef4
--- /dev/null
+++ b/tests/src/com/android/providers/media/photopicker/data/model/CategoryTest.java
@@ -0,0 +1,126 @@
+/*
+ * Copyright (C) 2021 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.providers.media.photopicker.data.model;
+
+import static com.android.providers.media.photopicker.data.model.Category.CATEGORY_CAMERA;
+import static com.android.providers.media.photopicker.data.model.Category.CATEGORY_DEFAULT;
+import static com.android.providers.media.photopicker.data.model.Category.CATEGORY_DOWNLOADS;
+import static com.android.providers.media.photopicker.data.model.Category.CATEGORY_FAVORITES;
+import static com.android.providers.media.photopicker.data.model.Category.CATEGORY_SCREENSHOTS;
+import static com.android.providers.media.photopicker.data.model.Category.CATEGORY_VIDEOS;
+import static com.android.providers.media.photopicker.data.model.Category.CategoryColumns;
+import static com.android.providers.media.photopicker.data.model.Category.CategoryType;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import android.content.Context;
+import android.database.Cursor;
+import android.database.MatrixCursor;
+import android.net.Uri;
+
+import androidx.test.InstrumentationRegistry;
+import androidx.test.runner.AndroidJUnit4;
+
+import org.junit.Test;
+import org.junit.runner.RunWith;
+
+@RunWith(AndroidJUnit4.class)
+public class CategoryTest {
+
+    @Test
+    public void testConstructor() {
+        final int itemCount = 10;
+        final String categoryName = "Album";
+        final Uri coverUri = Uri.parse("fakeCoverUri");
+        final String categoryType = CATEGORY_SCREENSHOTS;
+        final Cursor cursor = generateCursorForCategory(categoryName, coverUri, itemCount,
+                categoryType);
+        cursor.moveToFirst();
+
+        final Category category = new Category(cursor);
+
+        assertThat(category.getCategoryName(/* context= */ null)).isEqualTo(categoryName);
+        assertThat(category.getItemCount()).isEqualTo(itemCount);
+        assertThat(category.getCoverUri()).isEqualTo(coverUri);
+        assertThat(category.getCategoryType()).isEqualTo(categoryType);
+    }
+
+    /**
+     * If the {@code category} is not in {@link Category#CATEGORIES}, return {@code null}.
+     */
+    @Test
+    public void testGetCategoryName_notInList_returnNull() {
+        final Context context = InstrumentationRegistry.getTargetContext();
+        final String categoryName = Category.getCategoryName(context, CATEGORY_DEFAULT);
+
+        assertThat(categoryName).isNull();
+    }
+
+    @Test
+    public void testGetCategoryName_camera() {
+        final Context context = InstrumentationRegistry.getTargetContext();
+        final String categoryName = Category.getCategoryName(context, CATEGORY_CAMERA);
+
+        assertThat(categoryName).isEqualTo("Camera");
+    }
+
+    @Test
+    public void testGetCategoryName_downloads() {
+        final Context context = InstrumentationRegistry.getTargetContext();
+        final String categoryName = Category.getCategoryName(context, CATEGORY_DOWNLOADS);
+
+        assertThat(categoryName).isEqualTo("Downloads");
+    }
+
+    @Test
+    public void testGetCategoryName_favorites() {
+        final Context context = InstrumentationRegistry.getTargetContext();
+        final String categoryName = Category.getCategoryName(context, CATEGORY_FAVORITES);
+
+        assertThat(categoryName).isEqualTo("Favorites");
+    }
+
+    @Test
+    public void testGetCategoryName_screenshots() {
+        final Context context = InstrumentationRegistry.getTargetContext();
+        final String categoryName = Category.getCategoryName(context, CATEGORY_SCREENSHOTS);
+
+        assertThat(categoryName).isEqualTo("Screenshots");
+    }
+
+    @Test
+    public void testGetCategoryName_videos() {
+        final Context context = InstrumentationRegistry.getTargetContext();
+        final String categoryName = Category.getCategoryName(context, CATEGORY_VIDEOS);
+
+        assertThat(categoryName).isEqualTo("Videos");
+    }
+
+    @Test
+    public void testGetDefaultCategory() {
+        final Category category = Category.getDefaultCategory();
+
+        assertThat(category.getCategoryType()).isEqualTo(CATEGORY_DEFAULT);
+    }
+
+    private static Cursor generateCursorForCategory(String categoryName, Uri coverUri,
+            int itemCount, @CategoryType String categoryType) {
+        final MatrixCursor cursor = new MatrixCursor(CategoryColumns.getAllColumns());
+        cursor.addRow(new Object[] {categoryName, coverUri, itemCount, categoryType});
+        return cursor;
+    }
+}