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