1 /*
2  * Copyright (C) 2020 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 package com.android.wallpaper.picker;
17 
18 import android.app.Activity;
19 import android.content.Intent;
20 import android.graphics.Point;
21 import android.graphics.PorterDuff;
22 import android.graphics.Rect;
23 import android.net.Uri;
24 import android.os.Bundle;
25 import android.provider.Settings;
26 import android.util.DisplayMetrics;
27 import android.util.Log;
28 import android.view.LayoutInflater;
29 import android.view.View;
30 import android.view.ViewGroup;
31 import android.widget.ImageView;
32 import android.widget.ProgressBar;
33 import android.widget.TextView;
34 
35 import androidx.annotation.NonNull;
36 import androidx.annotation.Nullable;
37 import androidx.appcompat.app.AlertDialog;
38 import androidx.cardview.widget.CardView;
39 import androidx.fragment.app.Fragment;
40 import androidx.recyclerview.widget.GridLayoutManager;
41 import androidx.recyclerview.widget.RecyclerView;
42 
43 import com.android.wallpaper.R;
44 import com.android.wallpaper.asset.Asset;
45 import com.android.wallpaper.model.Category;
46 import com.android.wallpaper.module.InjectorProvider;
47 import com.android.wallpaper.module.UserEventLogger;
48 import com.android.wallpaper.util.DisplayMetricsRetriever;
49 import com.android.wallpaper.util.TileSizeCalculator;
50 
51 import com.bumptech.glide.Glide;
52 
53 import java.util.ArrayList;
54 import java.util.List;
55 
56 /**
57  * Displays the UI which contains the categories of the wallpaper.
58  */
59 public class CategorySelectorFragment extends Fragment {
60 
61     // The number of ViewHolders that don't pertain to category tiles.
62     // Currently 2: one for the metadata section and one for the "Select wallpaper" header.
63     private static final int NUM_NON_CATEGORY_VIEW_HOLDERS = 0;
64     private static final int SETTINGS_APP_INFO_REQUEST_CODE = 1;
65     private static final String TAG = "CategorySelectorFragment";
66 
67     /**
68      * Interface to be implemented by an Fragment hosting a {@link CategorySelectorFragment}
69      */
70     public interface CategorySelectorFragmentHost {
71 
72         /**
73          * Requests to show the Android custom photo picker for the sake of picking a photo
74          * to set as the device's wallpaper.
75          */
requestCustomPhotoPicker(MyPhotosStarter.PermissionChangedListener listener)76         void requestCustomPhotoPicker(MyPhotosStarter.PermissionChangedListener listener);
77 
78         /**
79          * Shows the wallpaper page of the specific category.
80          *
81          * @param collectionId the id of the category
82          */
show(String collectionId)83         void show(String collectionId);
84     }
85 
86     private RecyclerView mImageGrid;
87     private CategoryAdapter mAdapter;
88     private ArrayList<Category> mCategories = new ArrayList<>();
89     private Point mTileSizePx;
90     private boolean mAwaitingCategories;
91 
CategorySelectorFragment()92     public CategorySelectorFragment() {
93         mAdapter = new CategoryAdapter(mCategories);
94     }
95 
96     @Nullable
97     @Override
onCreateView(@onNull LayoutInflater inflater, @Nullable ViewGroup container, @Nullable Bundle savedInstanceState)98     public View onCreateView(@NonNull LayoutInflater inflater, @Nullable ViewGroup container,
99                              @Nullable Bundle savedInstanceState) {
100         View view = inflater.inflate(R.layout.fragment_category_selector, container,
101                 /* attachToRoot= */ false);
102 
103         mImageGrid = view.findViewById(R.id.category_grid);
104         mImageGrid.addItemDecoration(new GridPaddingDecoration(
105                 getResources().getDimensionPixelSize(R.dimen.grid_padding)));
106 
107         mTileSizePx = TileSizeCalculator.getCategoryTileSize(getActivity());
108 
109         mImageGrid.setAdapter(mAdapter);
110 
111         GridLayoutManager gridLayoutManager = new GridLayoutManager(getActivity(), getNumColumns());
112         mImageGrid.setLayoutManager(gridLayoutManager);
113 
114         return view;
115     }
116 
117     /**
118      * Inserts the given category into the categories list in priority order.
119      */
addCategory(Category category, boolean loading)120     void addCategory(Category category, boolean loading) {
121         // If not previously waiting for categories, enter the waiting state by showing the loading
122         // indicator.
123         if (loading && !mAwaitingCategories) {
124             mAdapter.notifyItemChanged(getNumColumns());
125             mAdapter.notifyItemInserted(getNumColumns());
126             mAwaitingCategories = true;
127         }
128         // Not add existing category to category list
129         if (mCategories.indexOf(category) >= 0) {
130             updateCategory(category);
131             return;
132         }
133 
134         int priority = category.getPriority();
135 
136         int index = 0;
137         while (index < mCategories.size() && priority >= mCategories.get(index).getPriority()) {
138             index++;
139         }
140 
141         mCategories.add(index, category);
142         if (mAdapter != null) {
143             // Offset the index because of the static metadata element at beginning of RecyclerView.
144             mAdapter.notifyItemInserted(index + NUM_NON_CATEGORY_VIEW_HOLDERS);
145         }
146     }
147 
removeCategory(Category category)148     void removeCategory(Category category) {
149         int index = mCategories.indexOf(category);
150         if (index >= 0) {
151             mCategories.remove(index);
152             mAdapter.notifyItemRemoved(index + NUM_NON_CATEGORY_VIEW_HOLDERS);
153         }
154     }
155 
updateCategory(Category category)156     void updateCategory(Category category) {
157         int index = mCategories.indexOf(category);
158         if (index >= 0) {
159             mCategories.remove(index);
160             mCategories.add(index, category);
161             mAdapter.notifyItemChanged(index + NUM_NON_CATEGORY_VIEW_HOLDERS);
162         }
163     }
164 
clearCategories()165     void clearCategories() {
166         mCategories.clear();
167         mAdapter.notifyDataSetChanged();
168     }
169 
170     /**
171      * Notifies the CategoryFragment that no further categories are expected so it may hide
172      * the loading indicator.
173      */
doneFetchingCategories()174     void doneFetchingCategories() {
175         if (mAwaitingCategories) {
176             mAdapter.notifyItemRemoved(mAdapter.getItemCount() - 1);
177             mAwaitingCategories = false;
178         }
179     }
180 
notifyDataSetChanged()181     void notifyDataSetChanged() {
182         mAdapter.notifyDataSetChanged();
183     }
184 
getNumColumns()185     private int getNumColumns() {
186         Activity activity = getActivity();
187         return activity == null ? 0 : TileSizeCalculator.getNumCategoryColumns(activity);
188     }
189 
190 
getCategorySelectorFragmentHost()191     private CategorySelectorFragmentHost getCategorySelectorFragmentHost() {
192         return (CategorySelectorFragmentHost) getParentFragment();
193     }
194 
195     /**
196      * ViewHolder subclass for a category tile in the RecyclerView.
197      */
198     private class CategoryHolder extends RecyclerView.ViewHolder implements View.OnClickListener {
199         private Category mCategory;
200         private ImageView mImageView;
201         private ImageView mOverlayIconView;
202         private TextView mTitleView;
203 
CategoryHolder(View itemView)204         CategoryHolder(View itemView) {
205             super(itemView);
206             itemView.setOnClickListener(this);
207 
208             mImageView = itemView.findViewById(R.id.image);
209             mOverlayIconView = itemView.findViewById(R.id.overlay_icon);
210             mTitleView = itemView.findViewById(R.id.category_title);
211 
212             CardView categoryView = itemView.findViewById(R.id.category);
213             categoryView.getLayoutParams().height = mTileSizePx.y;
214         }
215 
216         @Override
onClick(View view)217         public void onClick(View view) {
218             final UserEventLogger eventLogger =
219                     InjectorProvider.getInjector().getUserEventLogger(getActivity());
220             eventLogger.logCategorySelected(mCategory.getCollectionId());
221 
222             if (mCategory.supportsCustomPhotos()) {
223                 getCategorySelectorFragmentHost().requestCustomPhotoPicker(
224                         new MyPhotosStarter.PermissionChangedListener() {
225                             @Override
226                             public void onPermissionsGranted() {
227                                 drawThumbnailAndOverlayIcon();
228                             }
229 
230                             @Override
231                             public void onPermissionsDenied(boolean dontAskAgain) {
232                                 // No-op
233                             }
234                         });
235                 return;
236             }
237 
238             getCategorySelectorFragmentHost().show(mCategory.getCollectionId());
239         }
240 
241         /**
242          * Binds the given category to this CategoryHolder.
243          */
bindCategory(Category category)244         private void bindCategory(Category category) {
245             mCategory = category;
246             mTitleView.setText(category.getTitle());
247             drawThumbnailAndOverlayIcon();
248         }
249 
250         /**
251          * Draws the CategoryHolder's thumbnail and overlay icon.
252          */
drawThumbnailAndOverlayIcon()253         private void drawThumbnailAndOverlayIcon() {
254             mOverlayIconView.setImageDrawable(mCategory.getOverlayIcon(
255                     getActivity().getApplicationContext()));
256 
257             // Size the overlay icon according to the category.
258             int overlayIconDimenDp = mCategory.getOverlayIconSizeDp();
259             DisplayMetrics metrics = DisplayMetricsRetriever.getInstance().getDisplayMetrics(
260                     getResources(), getActivity().getWindowManager().getDefaultDisplay());
261             int overlayIconDimenPx = (int) (overlayIconDimenDp * metrics.density);
262             mOverlayIconView.getLayoutParams().width = overlayIconDimenPx;
263             mOverlayIconView.getLayoutParams().height = overlayIconDimenPx;
264 
265             Asset thumbnail = mCategory.getThumbnail(getActivity().getApplicationContext());
266             if (thumbnail != null) {
267                 thumbnail.loadDrawable(getActivity(), mImageView,
268                         getResources().getColor(R.color.secondary_color));
269             } else {
270                 // TODO(orenb): Replace this workaround for b/62584914 with a proper way of
271                 //  unloading the ImageView such that no incorrect image is improperly loaded upon
272                 //  rapid scroll.
273                 Object nullObj = null;
274                 Glide.with(getActivity())
275                         .asDrawable()
276                         .load(nullObj)
277                         .into(mImageView);
278 
279             }
280         }
281     }
282 
283     /**
284      * ViewHolder subclass for the loading indicator ("spinner") shown when categories are being
285      * fetched.
286      */
287     private class LoadingIndicatorHolder extends RecyclerView.ViewHolder {
LoadingIndicatorHolder(View view)288         private LoadingIndicatorHolder(View view) {
289             super(view);
290             ProgressBar progressBar = view.findViewById(R.id.loading_indicator);
291             progressBar.getIndeterminateDrawable().setColorFilter(
292                     getResources().getColor(R.color.accent_color), PorterDuff.Mode.SRC_IN);
293         }
294     }
295 
296     /**
297      * RecyclerView Adapter subclass for the category tiles in the RecyclerView.
298      */
299     private class CategoryAdapter extends RecyclerView.Adapter<RecyclerView.ViewHolder>
300             implements MyPhotosStarter.PermissionChangedListener {
301         private static final int ITEM_VIEW_TYPE_CATEGORY = 3;
302         private static final int ITEM_VIEW_TYPE_LOADING_INDICATOR = 4;
303         private List<Category> mCategories;
304 
CategoryAdapter(List<Category> categories)305         private CategoryAdapter(List<Category> categories) {
306             mCategories = categories;
307         }
308 
309         @Override
getItemViewType(int position)310         public int getItemViewType(int position) {
311             if (mAwaitingCategories && position == getItemCount() - 1) {
312                 return ITEM_VIEW_TYPE_LOADING_INDICATOR;
313             }
314 
315             return ITEM_VIEW_TYPE_CATEGORY;
316         }
317 
318         @Override
onCreateViewHolder(ViewGroup parent, int viewType)319         public RecyclerView.ViewHolder onCreateViewHolder(ViewGroup parent, int viewType) {
320             LayoutInflater layoutInflater = LayoutInflater.from(getActivity());
321             View view;
322 
323             switch (viewType) {
324                 case ITEM_VIEW_TYPE_LOADING_INDICATOR:
325                     view = layoutInflater.inflate(R.layout.grid_item_loading_indicator,
326                             parent, /* attachToRoot= */ false);
327                     return new LoadingIndicatorHolder(view);
328                 case ITEM_VIEW_TYPE_CATEGORY:
329                     view = layoutInflater.inflate(R.layout.grid_item_category,
330                             parent, /* attachToRoot= */ false);
331                     return new CategoryHolder(view);
332                 default:
333                     Log.e(TAG, "Unsupported viewType " + viewType + " in CategoryAdapter");
334                     return null;
335             }
336         }
337 
338         @Override
onBindViewHolder(RecyclerView.ViewHolder holder, int position)339         public void onBindViewHolder(RecyclerView.ViewHolder holder, int position) {
340             int viewType = getItemViewType(position);
341 
342             switch (viewType) {
343                 case ITEM_VIEW_TYPE_CATEGORY:
344                     // Offset position to get category index to account for the non-category view
345                     // holders.
346                     Category category = mCategories.get(position - NUM_NON_CATEGORY_VIEW_HOLDERS);
347                     ((CategoryHolder) holder).bindCategory(category);
348                     break;
349                 case ITEM_VIEW_TYPE_LOADING_INDICATOR:
350                     // No op.
351                     break;
352                 default:
353                     Log.e(TAG, "Unsupported viewType " + viewType + " in CategoryAdapter");
354             }
355         }
356 
357         @Override
getItemCount()358         public int getItemCount() {
359             // Add to size of categories to account for the metadata related views.
360             // Add 1 more for the loading indicator if not yet done loading.
361             int size = mCategories.size() + NUM_NON_CATEGORY_VIEW_HOLDERS;
362             if (mAwaitingCategories) {
363                 size += 1;
364             }
365 
366             return size;
367         }
368 
369         @Override
onPermissionsGranted()370         public void onPermissionsGranted() {
371             notifyDataSetChanged();
372         }
373 
374         @Override
onPermissionsDenied(boolean dontAskAgain)375         public void onPermissionsDenied(boolean dontAskAgain) {
376             if (!dontAskAgain) {
377                 return;
378             }
379 
380             String permissionNeededMessage =
381                     getString(R.string.permission_needed_explanation_go_to_settings);
382             AlertDialog dialog = new AlertDialog.Builder(getActivity(), R.style.LightDialogTheme)
383                     .setMessage(permissionNeededMessage)
384                     .setPositiveButton(android.R.string.ok, null /* onClickListener */)
385                     .setNegativeButton(
386                             R.string.settings_button_label,
387                             (dialogInterface, i) -> {
388                                 Intent appInfoIntent =
389                                         new Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS);
390                                 Uri uri = Uri.fromParts("package",
391                                         getActivity().getPackageName(), /* fragment= */ null);
392                                 appInfoIntent.setData(uri);
393                                 startActivityForResult(
394                                         appInfoIntent, SETTINGS_APP_INFO_REQUEST_CODE);
395                             })
396                     .create();
397             dialog.show();
398         }
399     }
400 
401     private class GridPaddingDecoration extends RecyclerView.ItemDecoration {
402 
403         private int mPadding;
404 
GridPaddingDecoration(int padding)405         GridPaddingDecoration(int padding) {
406             mPadding = padding;
407         }
408 
409         @Override
getItemOffsets(Rect outRect, View view, RecyclerView parent, RecyclerView.State state)410         public void getItemOffsets(Rect outRect, View view, RecyclerView parent,
411                                    RecyclerView.State state) {
412             int position = parent.getChildAdapterPosition(view) - NUM_NON_CATEGORY_VIEW_HOLDERS;
413             if (position >= 0) {
414                 outRect.left = mPadding;
415                 outRect.right = mPadding;
416             }
417         }
418     }
419 
420     /**
421      * SpanSizeLookup subclass which provides that the item in the first position spans the number
422      * of columns in the RecyclerView and all other items only take up a single span.
423      */
424     private class CategorySpanSizeLookup extends GridLayoutManager.SpanSizeLookup {
425         CategoryAdapter mAdapter;
426 
CategorySpanSizeLookup(CategoryAdapter adapter)427         private CategorySpanSizeLookup(CategoryAdapter adapter) {
428             mAdapter = adapter;
429         }
430 
431         @Override
getSpanSize(int position)432         public int getSpanSize(int position) {
433             if (position < NUM_NON_CATEGORY_VIEW_HOLDERS
434                     || mAdapter.getItemViewType(position)
435                     == CategoryAdapter.ITEM_VIEW_TYPE_LOADING_INDICATOR) {
436                 return getNumColumns();
437             }
438 
439             return 1;
440         }
441     }
442 }
443