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