1 /* 2 * Copyright (C) 2022 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 17 package com.android.providers.media.photopicker.ui; 18 19 import static com.android.providers.media.photopicker.ui.ItemsAction.ACTION_CLEAR_AND_UPDATE_LIST; 20 import static com.android.providers.media.photopicker.ui.ItemsAction.ACTION_CLEAR_GRID; 21 import static com.android.providers.media.photopicker.ui.ItemsAction.ACTION_LOAD_NEXT_PAGE; 22 import static com.android.providers.media.photopicker.ui.ItemsAction.ACTION_REFRESH_ITEMS; 23 import static com.android.providers.media.photopicker.ui.ItemsAction.ACTION_VIEW_CREATED; 24 import static com.android.providers.media.photopicker.viewmodel.PickerViewModel.TAG; 25 26 import android.content.Context; 27 import android.util.Log; 28 import android.view.LayoutInflater; 29 import android.view.View; 30 import android.view.ViewGroup; 31 import android.widget.Button; 32 import android.widget.TextView; 33 34 import androidx.annotation.NonNull; 35 import androidx.annotation.Nullable; 36 import androidx.annotation.StringRes; 37 import androidx.annotation.VisibleForTesting; 38 import androidx.lifecycle.LifecycleOwner; 39 import androidx.lifecycle.LiveData; 40 import androidx.recyclerview.widget.RecyclerView; 41 42 import com.android.providers.media.R; 43 import com.android.providers.media.photopicker.viewmodel.PickerViewModel; 44 45 import java.util.ArrayList; 46 import java.util.List; 47 48 /** 49 * Adapts from model to something RecyclerView understands. 50 */ 51 public abstract class TabAdapter extends RecyclerView.Adapter<RecyclerView.ViewHolder> { 52 53 @VisibleForTesting 54 public static final int ITEM_TYPE_BANNER = 0; 55 // Date header sections for "Photos" tab 56 public static final int ITEM_TYPE_SECTION = 1; 57 // Media items (a.k.a. Items) for "Photos" tab, Albums (a.k.a. Categories) for "Albums" tab 58 public static final int ITEM_TYPE_MEDIA_ITEM = 2; 59 protected PickerViewModel mPickerViewModel; 60 61 @NonNull final ImageLoader mImageLoader; 62 @NonNull private final LiveData<String> mCloudMediaProviderAppTitle; 63 @NonNull private final LiveData<String> mCloudMediaAccountName; 64 65 @Nullable private Banner mBanner; 66 @Nullable private OnBannerEventListener mOnBannerEventListener; 67 /** 68 * Combined list of Sections and Media Items, ordered based on their position in the view. 69 * 70 * (List of {@link com.android.providers.media.photopicker.ui.PhotosTabAdapter.DateHeader} and 71 * {@link com.android.providers.media.photopicker.data.model.Item} for the "Photos" tab) 72 * 73 * (List of {@link com.android.providers.media.photopicker.data.model.Category} for the "Albums" 74 * tab) 75 */ 76 @NonNull 77 private final List<Object> mAllItems = new ArrayList<>(); 78 TabAdapter(@onNull ImageLoader imageLoader, @NonNull LifecycleOwner lifecycleOwner, @NonNull LiveData<String> cloudMediaProviderAppTitle, @NonNull LiveData<String> cloudMediaAccountName, @NonNull LiveData<Boolean> shouldShowChooseAppBanner, @NonNull LiveData<Boolean> shouldShowCloudMediaAvailableBanner, @NonNull LiveData<Boolean> shouldShowAccountUpdatedBanner, @NonNull LiveData<Boolean> shouldShowChooseAccountBanner, @NonNull OnBannerEventListener onChooseAppBannerEventListener, @NonNull OnBannerEventListener onCloudMediaAvailableBannerEventListener, @NonNull OnBannerEventListener onAccountUpdatedBannerEventListener, @NonNull OnBannerEventListener onChooseAccountBannerEventListener, @NonNull PickerViewModel pickerViewModel)79 TabAdapter(@NonNull ImageLoader imageLoader, @NonNull LifecycleOwner lifecycleOwner, 80 @NonNull LiveData<String> cloudMediaProviderAppTitle, 81 @NonNull LiveData<String> cloudMediaAccountName, 82 @NonNull LiveData<Boolean> shouldShowChooseAppBanner, 83 @NonNull LiveData<Boolean> shouldShowCloudMediaAvailableBanner, 84 @NonNull LiveData<Boolean> shouldShowAccountUpdatedBanner, 85 @NonNull LiveData<Boolean> shouldShowChooseAccountBanner, 86 @NonNull OnBannerEventListener onChooseAppBannerEventListener, 87 @NonNull OnBannerEventListener onCloudMediaAvailableBannerEventListener, 88 @NonNull OnBannerEventListener onAccountUpdatedBannerEventListener, 89 @NonNull OnBannerEventListener onChooseAccountBannerEventListener, 90 @NonNull PickerViewModel pickerViewModel) { 91 mImageLoader = imageLoader; 92 mCloudMediaProviderAppTitle = cloudMediaProviderAppTitle; 93 mCloudMediaAccountName = cloudMediaAccountName; 94 mPickerViewModel = pickerViewModel; 95 96 shouldShowChooseAppBanner.observe(lifecycleOwner, isVisible -> 97 setBannerVisibility(isVisible, Banner.CHOOSE_APP, onChooseAppBannerEventListener)); 98 shouldShowCloudMediaAvailableBanner.observe(lifecycleOwner, isVisible -> 99 setBannerVisibility(isVisible, Banner.CLOUD_MEDIA_AVAILABLE, 100 onCloudMediaAvailableBannerEventListener)); 101 shouldShowAccountUpdatedBanner.observe(lifecycleOwner, isVisible -> 102 setBannerVisibility(isVisible, Banner.ACCOUNT_UPDATED, 103 onAccountUpdatedBannerEventListener)); 104 shouldShowChooseAccountBanner.observe(lifecycleOwner, isVisible -> 105 setBannerVisibility(isVisible, Banner.CHOOSE_ACCOUNT, 106 onChooseAccountBannerEventListener)); 107 } 108 109 @NonNull 110 @Override onCreateViewHolder(@onNull ViewGroup viewGroup, int viewType)111 public final RecyclerView.ViewHolder onCreateViewHolder(@NonNull ViewGroup viewGroup, 112 int viewType) { 113 switch (viewType) { 114 case ITEM_TYPE_BANNER: 115 return createBannerViewHolder(viewGroup); 116 case ITEM_TYPE_SECTION: 117 return createSectionViewHolder(viewGroup); 118 case ITEM_TYPE_MEDIA_ITEM: 119 return createMediaItemViewHolder(viewGroup); 120 default: 121 throw new IllegalArgumentException("Unknown item view type " + viewType); 122 } 123 } 124 125 @Override onBindViewHolder(@onNull RecyclerView.ViewHolder itemHolder, int position)126 public final void onBindViewHolder(@NonNull RecyclerView.ViewHolder itemHolder, int position) { 127 final int itemViewType = getItemViewType(position); 128 switch (itemViewType) { 129 case ITEM_TYPE_BANNER: 130 onBindBannerViewHolder(itemHolder); 131 break; 132 case ITEM_TYPE_SECTION: 133 onBindSectionViewHolder(itemHolder, position); 134 break; 135 case ITEM_TYPE_MEDIA_ITEM: 136 onBindMediaItemViewHolder(itemHolder, position); 137 break; 138 default: 139 throw new IllegalArgumentException("Unknown item view type " + itemViewType); 140 } 141 } 142 143 @Override getItemCount()144 public final int getItemCount() { 145 return getBannerCount() + getAllItemsCount(); 146 } 147 148 @Override getItemViewType(int position)149 public final int getItemViewType(int position) { 150 if (position < 0) { 151 throw new IllegalStateException("Get item view type for negative position " + position); 152 } 153 if (isItemTypeBanner(position)) { 154 return ITEM_TYPE_BANNER; 155 } else if (isItemTypeSection(position)) { 156 return ITEM_TYPE_SECTION; 157 } else if (isItemTypeMediaItem(position)) { 158 return ITEM_TYPE_MEDIA_ITEM; 159 } else { 160 throw new IllegalStateException("Item at position " + position 161 + " is of neither of the defined types"); 162 } 163 } 164 165 @NonNull createBannerViewHolder(@onNull ViewGroup viewGroup)166 private RecyclerView.ViewHolder createBannerViewHolder(@NonNull ViewGroup viewGroup) { 167 final View view = getView(viewGroup, R.layout.item_banner); 168 return new BannerHolder(view); 169 } 170 171 @NonNull createSectionViewHolder(@onNull ViewGroup viewGroup)172 RecyclerView.ViewHolder createSectionViewHolder(@NonNull ViewGroup viewGroup) { 173 // A descendant must override this method if and only if {@link isItemTypeSection} is 174 // implemented and may return {@code true} for them. 175 throw new IllegalStateException("Attempt to create an unimplemented section view holder"); 176 } 177 178 @NonNull createMediaItemViewHolder(@onNull ViewGroup viewGroup)179 abstract RecyclerView.ViewHolder createMediaItemViewHolder(@NonNull ViewGroup viewGroup); 180 onBindBannerViewHolder(@onNull RecyclerView.ViewHolder itemHolder)181 private void onBindBannerViewHolder(@NonNull RecyclerView.ViewHolder itemHolder) { 182 final BannerHolder bannerVH = (BannerHolder) itemHolder; 183 bannerVH.bind(mBanner, mCloudMediaProviderAppTitle.getValue(), 184 mCloudMediaAccountName.getValue(), mOnBannerEventListener); 185 } 186 onBindSectionViewHolder(@onNull RecyclerView.ViewHolder itemHolder, int position)187 void onBindSectionViewHolder(@NonNull RecyclerView.ViewHolder itemHolder, int position) { 188 // no-op: descendants may implement 189 } 190 onBindMediaItemViewHolder(@onNull RecyclerView.ViewHolder itemHolder, int position)191 abstract void onBindMediaItemViewHolder(@NonNull RecyclerView.ViewHolder itemHolder, 192 int position); 193 getBannerCount()194 private int getBannerCount() { 195 return mBanner != null ? 1 : 0; 196 } 197 getAllItemsCount()198 private int getAllItemsCount() { 199 return mAllItems.size(); 200 } 201 isItemTypeBanner(int position)202 private boolean isItemTypeBanner(int position) { 203 return position > -1 && position < getBannerCount(); 204 } 205 isItemTypeSection(int position)206 boolean isItemTypeSection(int position) { 207 // no-op: descendants may implement 208 return false; 209 } 210 isItemTypeMediaItem(int position)211 abstract boolean isItemTypeMediaItem(int position); 212 213 /** 214 * Update the banner visibility in tab adapter 215 */ setBannerVisibility(boolean isVisible, @NonNull Banner banner, @NonNull OnBannerEventListener onBannerEventListener)216 private void setBannerVisibility(boolean isVisible, @NonNull Banner banner, 217 @NonNull OnBannerEventListener onBannerEventListener) { 218 if (isVisible) { 219 if (mBanner == null) { 220 mBanner = banner; 221 mOnBannerEventListener = onBannerEventListener; 222 notifyItemInserted(/* position */ 0); 223 mOnBannerEventListener.onBannerAdded(banner.name()); 224 } else { 225 mBanner = banner; 226 mOnBannerEventListener = onBannerEventListener; 227 notifyItemChanged(/* position */ 0); 228 } 229 } else if (mBanner == banner) { 230 mBanner = null; 231 mOnBannerEventListener = null; 232 notifyItemRemoved(/* position */ 0); 233 } 234 } 235 236 /** 237 * Update the List of all items (excluding the banner) in tab adapter {@link #mAllItems} 238 */ setAllItems(@onNull List<?> items, @ItemsAction.Type int action)239 protected final void setAllItems(@NonNull List<?> items, 240 @ItemsAction.Type int action) { 241 int previousItemCount = getItemCount(); 242 mAllItems.clear(); 243 mAllItems.addAll(items); 244 notifyOnListChanged(previousItemCount, items.size(), action); 245 } 246 notifyOnListChanged(int previousItemCount, int sizeOfUpdatedList, @ItemsAction.Type int action)247 private void notifyOnListChanged(int previousItemCount, int sizeOfUpdatedList, 248 @ItemsAction.Type int action) { 249 Log.d(TAG, "Updating adapter for action: " + action); 250 switch (action) { 251 case ACTION_VIEW_CREATED: 252 case ACTION_CLEAR_AND_UPDATE_LIST: { 253 notifyDataSetChanged(); 254 break; 255 } 256 case ACTION_CLEAR_GRID: { 257 notifyItemRangeRemoved(0, previousItemCount); 258 break; 259 } 260 case ACTION_LOAD_NEXT_PAGE: { 261 notifyItemRangeInserted(previousItemCount, 262 sizeOfUpdatedList - previousItemCount); 263 break; 264 } 265 case ACTION_REFRESH_ITEMS: { 266 notifyItemRangeChanged(0, sizeOfUpdatedList); 267 if (sizeOfUpdatedList < previousItemCount) { 268 notifyItemRangeRemoved(sizeOfUpdatedList, 269 previousItemCount - sizeOfUpdatedList); 270 } 271 break; 272 } 273 default: 274 Log.w(TAG, "Invalid action passed. No update to adapter"); 275 } 276 } 277 278 @NonNull getAdapterItem(int position)279 public final Object getAdapterItem(int position) { 280 if (position < 0) { 281 throw new IllegalStateException("Get adapter item for negative position " + position); 282 } 283 if (isItemTypeBanner(position)) { 284 return mBanner; 285 } 286 287 final int effectiveItemIndex = position - getBannerCount(); 288 return mAllItems.get(effectiveItemIndex); 289 } 290 291 @NonNull getView(@onNull ViewGroup viewGroup, int layout)292 final View getView(@NonNull ViewGroup viewGroup, int layout) { 293 final LayoutInflater inflater = LayoutInflater.from(viewGroup.getContext()); 294 return inflater.inflate(layout, viewGroup, /* attachToRoot */ false); 295 } 296 297 private static class BannerHolder extends RecyclerView.ViewHolder { 298 final TextView mPrimaryText; 299 final TextView mSecondaryText; 300 final Button mDismissButton; 301 final Button mActionButton; 302 BannerHolder(@onNull View itemView)303 BannerHolder(@NonNull View itemView) { 304 super(itemView); 305 mPrimaryText = itemView.findViewById(R.id.banner_primary_text); 306 mSecondaryText = itemView.findViewById(R.id.banner_secondary_text); 307 mDismissButton = itemView.findViewById(R.id.dismiss_button); 308 mActionButton = itemView.findViewById(R.id.action_button); 309 } 310 bind(@onNull Banner banner, String cloudAppName, String cloudUserAccount, @NonNull OnBannerEventListener onBannerEventListener)311 void bind(@NonNull Banner banner, String cloudAppName, String cloudUserAccount, 312 @NonNull OnBannerEventListener onBannerEventListener) { 313 final Context context = itemView.getContext(); 314 315 itemView.setOnClickListener(v -> onBannerEventListener.onBannerClick()); 316 317 mPrimaryText.setText(banner.getPrimaryText(context, cloudAppName)); 318 mSecondaryText.setText(banner.getSecondaryText(context, cloudAppName, 319 cloudUserAccount)); 320 321 mDismissButton.setOnClickListener(v -> onBannerEventListener.onDismissButtonClick()); 322 323 if (banner.mActionButtonText != -1 && onBannerEventListener.shouldShowActionButton()) { 324 mActionButton.setText(banner.mActionButtonText); 325 mActionButton.setVisibility(View.VISIBLE); 326 mActionButton.setOnClickListener(v -> onBannerEventListener.onActionButtonClick()); 327 } else { 328 mActionButton.setVisibility(View.GONE); 329 } 330 } 331 } 332 333 private enum Banner { 334 CLOUD_MEDIA_AVAILABLE(R.string.picker_banner_cloud_first_time_available_title, 335 R.string.picker_banner_cloud_first_time_available_desc, 336 R.string.picker_banner_cloud_change_account_button), 337 ACCOUNT_UPDATED(R.string.picker_banner_cloud_account_changed_title, 338 R.string.picker_banner_cloud_account_changed_desc, /* no action button */ -1), 339 CHOOSE_ACCOUNT(R.string.picker_banner_cloud_choose_account_title, 340 R.string.picker_banner_cloud_choose_account_desc, 341 R.string.picker_banner_cloud_choose_account_button), 342 CHOOSE_APP(R.string.picker_banner_cloud_choose_app_title, 343 R.string.picker_banner_cloud_choose_app_desc, 344 R.string.picker_banner_cloud_choose_app_button); 345 346 @StringRes final int mPrimaryText; 347 @StringRes final int mSecondaryText; 348 @StringRes final int mActionButtonText; 349 Banner(int primaryText, int secondaryText, int actionButtonText)350 Banner(int primaryText, int secondaryText, int actionButtonText) { 351 mPrimaryText = primaryText; 352 mSecondaryText = secondaryText; 353 mActionButtonText = actionButtonText; 354 } 355 getPrimaryText(@onNull Context context, String appName)356 String getPrimaryText(@NonNull Context context, String appName) { 357 switch (this) { 358 case CLOUD_MEDIA_AVAILABLE: 359 // fall-through 360 case CHOOSE_APP: 361 return context.getString(mPrimaryText); 362 case ACCOUNT_UPDATED: 363 // fall-through 364 case CHOOSE_ACCOUNT: 365 return context.getString(mPrimaryText, appName); 366 default: 367 throw new IllegalStateException("Unknown banner type " + name()); 368 } 369 } 370 getSecondaryText(@onNull Context context, String appName, String userAccount)371 String getSecondaryText(@NonNull Context context, String appName, String userAccount) { 372 switch (this) { 373 case CLOUD_MEDIA_AVAILABLE: 374 return context.getString(mSecondaryText, appName, userAccount); 375 case ACCOUNT_UPDATED: 376 return context.getString(mSecondaryText, userAccount); 377 case CHOOSE_ACCOUNT: 378 return context.getString(mSecondaryText, appName); 379 case CHOOSE_APP: 380 return context.getString(mSecondaryText); 381 default: 382 throw new IllegalStateException("Unknown banner type " + name()); 383 } 384 } 385 } 386 387 interface OnBannerEventListener { onActionButtonClick()388 void onActionButtonClick(); 389 onDismissButtonClick()390 void onDismissButtonClick(); 391 onBannerClick()392 void onBannerClick(); 393 onBannerAdded(@onNull String name)394 void onBannerAdded(@NonNull String name); 395 shouldShowActionButton()396 default boolean shouldShowActionButton() { 397 return true; 398 } 399 } 400 } 401