Add error dialog to prevent the user adding cloud only photos when
offline

Preventing the user selecting non cached  unavailable
cloud only media.If user is offline and  tries adding any media which is not available or cached ,
an error dialog box will be shown.

Bug: b/291925524
Test: Try adding cloud-only unavailable photo while offline, an error
dialog will be shown.
(cherry picked from commit 9a77de82969f9ae5944524416210ec31f4e944ad)

Change-Id: I5930cb40c55b97f328c5fa84b6733a8f2399de68
Merged-In: I5930cb40c55b97f328c5fa84b6733a8f2399de68
diff --git a/res/drawable/error_icon.xml b/res/drawable/error_icon.xml
new file mode 100644
index 0000000..e9433b7
--- /dev/null
+++ b/res/drawable/error_icon.xml
@@ -0,0 +1,17 @@
+<vector xmlns:android="http://schemas.android.com/apk/res/android"
+        android:width="24dp"
+        android:height="24dp"
+        android:viewportWidth="24"
+        android:viewportHeight="24">
+    <group>
+        <clip-path
+            android:pathData="M0,0h24v24h-24z"/>
+        <path
+            android:pathData="M1,21L12,2L23,21H1ZM4.45,19H19.55L12,6L4.45,19ZM12,18C12.283,18
+            12.517,17.908 12.7,17.725C12.9,17.525 13,17.283 13,17C13,16.717 12.9,16.483
+            12.7,16.3C12.517,16.1 12.283,16 12,16C11.717,16 11.475,16.1 11.275,16.3C11.092,16.483
+            11,16.717 11,17C11,17.283 11.092,17.525 11.275,17.725C11.475,17.908 11.717,18 12,
+            18ZM11,15H13V10H11V15Z"
+            android:fillColor="#775A0B"/>
+    </group>
+</vector>
diff --git a/res/layout/error_dialog.xml b/res/layout/error_dialog.xml
new file mode 100644
index 0000000..5fa23d1
--- /dev/null
+++ b/res/layout/error_dialog.xml
@@ -0,0 +1,47 @@
+<?xml version="1.0" encoding="utf-8"?>
+<LinearLayout
+    xmlns:android="http://schemas.android.com/apk/res/android"
+    android:layout_width="wrap_content"
+    android:layout_height="wrap_content"
+    android:orientation="vertical"
+    android:padding="20dp"
+    android:gravity="center">
+    <ImageView
+        android:layout_width="34dp"
+        android:layout_height="34dp"
+        android:src="@drawable/error_icon"
+        android:layout_gravity="center_horizontal"
+        android:layout_marginBottom="16dp"
+        android:importantForAccessibility="no"/>
+
+    <TextView
+        android:id="@+id/title"
+        android:layout_width="wrap_content"
+        android:layout_height="wrap_content"
+        android:text="@string/dialog_error_title"
+        android:textSize="24sp"
+        android:layout_gravity="center_horizontal"
+        android:gravity="center"
+        android:textColor="?android:attr/textColorPrimary"/>
+
+    <TextView
+        android:id="@+id/message"
+        android:layout_width="wrap_content"
+        android:layout_height="wrap_content"
+        android:text="@string/dialog_error_message"
+        android:layout_marginTop="16dp"
+        android:layout_gravity="center_horizontal"
+        android:gravity="center"
+        android:textSize="16sp"
+        android:textColor="?android:attr/textColorSecondary"/>
+
+    <Button
+        android:id="@+id/okButton"
+        android:layout_width="wrap_content"
+        android:layout_height="wrap_content"
+        android:text="@string/dialog_button_text"
+        android:layout_marginTop="16dp"
+        android:layout_gravity="end"
+        android:textColor="?attr/pickerHighlightTextColor"
+        android:backgroundTint="?attr/pickerHighlightColor"/>
+</LinearLayout>
\ No newline at end of file
diff --git a/res/values/strings.xml b/res/values/strings.xml
index 5a6f29b..d813e2b 100644
--- a/res/values/strings.xml
+++ b/res/values/strings.xml
@@ -508,4 +508,13 @@
 
     <!-- Transcode progress channel name. -->
     <string name="transcode_progress_channel">Native Transcode Progress</string>
+
+    <!-- Dialog error message-->
+    <string name="dialog_error_message">Sorry, the photos you selected are unavailable. Please try again.</string>
+
+    <!-- Dialog error title-->
+    <string name="dialog_error_title">Can\'t load some Photos</string>
+
+    <!-- Error dialog OK button text-->
+    <string name="dialog_button_text">Got it</string>
 </resources>
diff --git a/src/com/android/providers/media/photopicker/DialogUtils.java b/src/com/android/providers/media/photopicker/DialogUtils.java
new file mode 100644
index 0000000..9f94aef
--- /dev/null
+++ b/src/com/android/providers/media/photopicker/DialogUtils.java
@@ -0,0 +1,57 @@
+/*
+ * 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;
+
+import android.app.AlertDialog;
+import android.content.Context;
+import android.view.LayoutInflater;
+import android.view.View;
+import android.widget.Button;
+import android.widget.TextView;
+
+import androidx.appcompat.app.AppCompatActivity;
+
+import com.android.providers.media.R;
+
+/**
+ * Dialog box to display custom alert or error messages
+ */
+public class DialogUtils extends AppCompatActivity {
+    /**
+     * Custom dialog box with single button to display title and single error message
+     */
+    public static void showDialog(Context context, String title, String message) {
+        View customView =
+                LayoutInflater.from(context).inflate(R.layout.error_dialog, null);
+
+        TextView dialogTitle = customView.findViewById(R.id.title);
+        TextView dialogMessage = customView.findViewById(R.id.message);
+        Button gotItButton = customView.findViewById(R.id.okButton);
+        dialogTitle.setText(title);
+        dialogMessage.setText(message);
+
+        AlertDialog.Builder builder = new AlertDialog.Builder(context);
+        builder.setView(customView);
+        builder.setCancelable(false); // Prevent dismiss when clicking outside
+        final AlertDialog dialog = builder.create();
+
+        gotItButton.setOnClickListener(v -> {
+            dialog.dismiss(); // Close the dialog
+        });
+        dialog.show();
+    }
+}
diff --git a/src/com/android/providers/media/photopicker/PhotoPickerActivity.java b/src/com/android/providers/media/photopicker/PhotoPickerActivity.java
index 4d3d501..1cdc56c 100644
--- a/src/com/android/providers/media/photopicker/PhotoPickerActivity.java
+++ b/src/com/android/providers/media/photopicker/PhotoPickerActivity.java
@@ -65,6 +65,8 @@
 import androidx.appcompat.app.AppCompatActivity;
 import androidx.appcompat.widget.Toolbar;
 import androidx.fragment.app.FragmentManager;
+import androidx.lifecycle.LiveData;
+import androidx.lifecycle.MutableLiveData;
 import androidx.lifecycle.ViewModel;
 import androidx.lifecycle.ViewModelProvider;
 
@@ -73,6 +75,7 @@
 import com.android.providers.media.photopicker.data.PickerResult;
 import com.android.providers.media.photopicker.data.Selection;
 import com.android.providers.media.photopicker.data.UserIdManager;
+import com.android.providers.media.photopicker.data.model.Item;
 import com.android.providers.media.photopicker.data.model.UserId;
 import com.android.providers.media.photopicker.ui.TabContainerFragment;
 import com.android.providers.media.photopicker.util.LayoutModeUtils;
@@ -115,6 +118,10 @@
     private Toolbar mToolbar;
     private CrossProfileListeners mCrossProfileListeners;
 
+    @NonNull
+    private final MutableLiveData<Boolean> mIsItemPhotoGridViewChanged =
+            new MutableLiveData<>(false);
+
     @ColorInt
     private int mDefaultBackgroundColor;
 
@@ -527,13 +534,17 @@
         return getResources().getConfiguration().orientation == Configuration.ORIENTATION_LANDSCAPE;
     }
 
+    public LiveData<Boolean> isItemPhotoGridViewChanged() {
+        return mIsItemPhotoGridViewChanged;
+    }
+
     public void setResultAndFinishSelf() {
         logPickerSelectionConfirmed(mSelection.getSelectedItems().size());
-
         if (shouldPreloadSelectedItems()) {
             final var uris = PickerResult.getPickerUrisForItems(mSelection.getSelectedItems());
             mPreloaderInstanceHolder.preloader =
                     SelectedMediaPreloader.preload(/* activity */ this, uris);
+            deSelectUnavailableMedia(mPreloaderInstanceHolder.preloader);
             subscribeToSelectedMediaPreloader(mPreloaderInstanceHolder.preloader);
         } else {
             setResultAndFinishSelfInternal();
@@ -611,6 +622,30 @@
                 });
     }
 
+    // This method is responsible for deselecting all  unavailable items from selection list
+    // when user tries selecting unavailable could only media (not cached) while offline
+    private void deSelectUnavailableMedia(@NonNull SelectedMediaPreloader preloader) {
+        preloader.getUnavailableMediaIndexes().observe(
+                /* lifecycleOwner */ PhotoPickerActivity.this,
+                unavailableMediaIndexes -> {
+                    if (unavailableMediaIndexes.size() > 0) {
+                        // To notify the fragment to uncheck the unavailable items at UI those are
+                        // no longer available in the selection list.
+                        mIsItemPhotoGridViewChanged.postValue(true);
+                        // Displaying  error dialog with an error message when the user tries
+                        // to add unavailable cloud only media (not cached) while offline.
+                        DialogUtils.showDialog(this,
+                                getResources().getString(R.string.dialog_error_title),
+                                getResources().getString(R.string.dialog_error_message));
+
+                        List<Item> selectedItems = mSelection.getSelectedItems();
+                        for (var mediaIndex : unavailableMediaIndexes) {
+                            mSelection.removeSelectedItem(selectedItems.get(mediaIndex));
+                        }
+                    }
+                });
+    }
+
     /**
      * NOTE: this may wrongly return {@code false} if called before {@link PickerViewModel} had a
      * chance to fetch the authority and the account of the current
diff --git a/src/com/android/providers/media/photopicker/SelectedMediaPreloader.java b/src/com/android/providers/media/photopicker/SelectedMediaPreloader.java
index 81f95a8..c5a18eb 100644
--- a/src/com/android/providers/media/photopicker/SelectedMediaPreloader.java
+++ b/src/com/android/providers/media/photopicker/SelectedMediaPreloader.java
@@ -73,6 +73,9 @@
     @NonNull
     private final MutableLiveData<Boolean> mIsFinishedLiveData = new MutableLiveData<>(false);
     @NonNull
+    private final MutableLiveData<List<Integer>> mUnavailableMediaIndexes =
+            new MutableLiveData<>(new ArrayList<>());
+    @NonNull
     private final ContentResolver mContentResolver;
 
     /**
@@ -114,17 +117,17 @@
             public void onChanged(Boolean isFinished) {
                 if (isFinished) {
                     preloader.mIsFinishedLiveData.removeObserver(this);
-                    dialog.dismiss();
-
                     Trace.endAsyncSection(TRACE_SECTION_NAME, /* cookie */ preloader.hashCode());
                 }
             }
         });
+
         preloader.mFinishedCountLiveData.observeForever(new Observer<>() {
             @Override
             public void onChanged(Integer finishedCount) {
                 if (finishedCount == count) {
                     preloader.mFinishedCountLiveData.removeObserver(this);
+                    dialog.dismiss();
                 }
                 // "X of Y ready"
                 final String message = context.getString(
@@ -155,18 +158,28 @@
         return mIsFinishedLiveData;
     }
 
+    @NonNull
+    LiveData<List<Integer>> getUnavailableMediaIndexes() {
+        return mUnavailableMediaIndexes;
+    }
+
     /**
      * This method is intentionally {@code private}: clients should use static
      * {@link #preload(Context, List)} method.
      */
     @UiThread
     private void start(@NonNull Executor executor) {
-        for (var item : mItems) {
+        List<Integer> unavailableMediaIndexes = new ArrayList<>();
+        for (int index = 0; index < mItems.size(); index++) {
+            int currIndex = index;
             // Off-loading to an Executor (presumable backed up by a thread pool)
             executor.execute(new Runnable() {
                 @Override
                 public void run() {
-                    openFileDescriptor(item);
+                    boolean isOpenedSuccessfully = openFileDescriptor(mItems.get(currIndex));
+                    if (!isOpenedSuccessfully) {
+                        unavailableMediaIndexes.add(currIndex);
+                    }
 
                     final int preloadedCount = mFinishedCount.incrementAndGet();
                     if (DEBUG) {
@@ -175,7 +188,12 @@
                     if (preloadedCount == mCount) {
                         // Don't need to "synchronize" here: mCount is our final value for
                         // preloadedCount, it won't be changing anymore.
-                        mIsFinishedLiveData.postValue(true);
+                        if (unavailableMediaIndexes.size() == 0) {
+                            mIsFinishedLiveData.postValue(true);
+                        } else {
+                            mUnavailableMediaIndexes.postValue(unavailableMediaIndexes);
+                            mIsFinishedLiveData.postValue(false);
+                        }
                     }
 
                     // In order to prevent race conditions where we may "post" a lower value after
@@ -190,7 +208,8 @@
     }
 
     @Nullable
-    private void openFileDescriptor(@NonNull Uri uri) {
+    private Boolean openFileDescriptor(@NonNull Uri uri) {
+        Boolean isOpenedSuccessfully = true;
         long start = 0;
         if (DEBUG) {
             Log.d(TAG, "openFileDescriptor() START, " + Thread.currentThread() + ", " + uri);
@@ -201,6 +220,7 @@
         try {
             mContentResolver.openAssetFileDescriptor(uri, "r");
         } catch (FileNotFoundException e) {
+            isOpenedSuccessfully = false;
             Log.w(TAG, "Could not open FileDescriptor for " + uri, e);
         } finally {
             Trace.endSection();
@@ -211,6 +231,7 @@
                         + ", " + uri);
             }
         }
+        return isOpenedSuccessfully;
     }
 
     @NonNull
diff --git a/src/com/android/providers/media/photopicker/data/Selection.java b/src/com/android/providers/media/photopicker/data/Selection.java
index 4894977..7176aad 100644
--- a/src/com/android/providers/media/photopicker/data/Selection.java
+++ b/src/com/android/providers/media/photopicker/data/Selection.java
@@ -27,6 +27,7 @@
 import com.android.providers.media.photopicker.data.model.Item;
 
 import java.util.ArrayList;
+import java.util.Collection;
 import java.util.Collections;
 import java.util.HashMap;
 import java.util.List;
@@ -36,6 +37,16 @@
  * A class that tracks Selection
  */
 public class Selection {
+    /**
+     * Contains positions of checked Item at UI. {@link #mCheckedItemIndexes} may have more number
+     * of indexes , from the number of items present in {@link #mSelectedItems}. The index in
+     * {@link #mCheckedItemIndexes} is a potential index that needs to be rechecked in
+     * notifyItemChanged() at the time of deselecting the unavailable item at UI when user is
+     * offline and tries adding unavailable non cached items. the item corresponding to the index in
+     * {@link #mCheckedItemIndexes} may no longer be selected.
+     */
+    private final Map<Item, Integer> mCheckedItemIndexes = new HashMap<>();
+
     // The list of selected items.
     private Map<Uri, Item> mSelectedItems = new HashMap<>();
     private MutableLiveData<Integer> mSelectedItemSize = new MutableLiveData<>();
@@ -55,6 +66,13 @@
     }
 
     /**
+     * @return Indexes - A {@link List} of checked {@link Item} positions.
+     */
+    public Collection<Integer> getCheckedItemsIndexes() {
+        return mCheckedItemIndexes.values();
+    }
+
+    /**
      * @return {@link LiveData} of count of selected items in {@link #mSelectedItems}
      */
     public LiveData<Integer> getSelectedItemCount() {
@@ -74,6 +92,13 @@
     }
 
     /**
+     * Add the checked {@code item} index into {@link #mCheckedItemIndexes}.
+     */
+    public void addCheckedItemIndex(Item item, Integer index) {
+        mCheckedItemIndexes.put(item, index);
+    }
+
+    /**
      * Clears {@link #mSelectedItems} and sets the selected item as given {@code item}
      */
     public void setSelectedItem(Item item) {
@@ -84,7 +109,7 @@
     }
 
     /**
-     * Remove the {@code item} from the selected item list {@link #mSelectedItems}.
+     * Remove the {@code item} from the selected item list {@link #mSelectedItems}
      *
      * @param item the item to be removed from the selected item list
      */
@@ -95,15 +120,32 @@
     }
 
     /**
-     * Clear all selected items
+     * Remove the {@code item} index from the checked item  index list {@link #mCheckedItemIndexes}.
+     *
+     * @param item the item to be removed from the selected item list
+     */
+    public void removeCheckedItemIndex(Item item) {
+        mCheckedItemIndexes.remove(item);
+    }
+
+    /**
+     * Clear all selected items and checked positions
      */
     public void clearSelectedItems() {
         mSelectedItems.clear();
+        mCheckedItemIndexes.clear();
         mSelectedItemSize.postValue(mSelectedItems.size());
         updateSelectionAllowed();
     }
 
     /**
+     * Clear all checked items
+     */
+    public void clearCheckedItemList() {
+        mCheckedItemIndexes.clear();
+    }
+
+    /**
      * @return {@code true} if give {@code item} is present in selected items
      *         {@link #mSelectedItems}, {@code false} otherwise
      */
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 e618386..11dbc64 100644
--- a/src/com/android/providers/media/photopicker/data/model/Item.java
+++ b/src/com/android/providers/media/photopicker/data/model/Item.java
@@ -41,6 +41,8 @@
 import com.android.providers.media.photopicker.util.DateTimeUtils;
 import com.android.providers.media.util.MimeUtils;
 
+import java.util.Objects;
+
 /**
  * Base class for representing a single media item (a picture, a video, etc.) in the PhotoPicker.
  */
@@ -225,4 +227,17 @@
     public boolean isLocal() {
         return LOCAL_PICKER_PROVIDER_AUTHORITY.equals(mUri.getAuthority());
     }
+
+    @Override
+    public boolean equals(Object obj) {
+        if (this == obj) return true;
+        if (obj == null || !(obj instanceof Item)) return false;
+
+        Item other = (Item) obj;
+        return mUri.equals(other.mUri);
+    }
+
+    @Override public int hashCode() {
+        return Objects.hash(mUri);
+    }
 }
\ No newline at end of file
diff --git a/src/com/android/providers/media/photopicker/ui/PhotosTabAdapter.java b/src/com/android/providers/media/photopicker/ui/PhotosTabAdapter.java
index 3588fc3..5f51864 100644
--- a/src/com/android/providers/media/photopicker/ui/PhotosTabAdapter.java
+++ b/src/com/android/providers/media/photopicker/ui/PhotosTabAdapter.java
@@ -103,6 +103,10 @@
 
         final boolean isSelected = mSelection.canSelectMultiple()
                 && mSelection.isItemSelected(item);
+        if (isSelected) {
+            mSelection.addCheckedItemIndex(item, position);
+        }
+
         mediaItemVH.bind(item, isSelected);
 
         // We also need to set Item as a tag so that OnClick/OnLongClickListeners can then
@@ -191,4 +195,4 @@
 
         boolean onItemLongClick(@NonNull View view, int position);
     }
-}
+}
\ No newline at end of file
diff --git a/src/com/android/providers/media/photopicker/ui/PhotosTabFragment.java b/src/com/android/providers/media/photopicker/ui/PhotosTabFragment.java
index 4a1843d..9b5ff0f 100644
--- a/src/com/android/providers/media/photopicker/ui/PhotosTabFragment.java
+++ b/src/com/android/providers/media/photopicker/ui/PhotosTabFragment.java
@@ -85,7 +85,6 @@
 
     private final Object mHideProgressBarToken = new Object();
 
-
     @Override
     public void onCreate(Bundle savedInstanceState) {
         super.onCreate(savedInstanceState);
@@ -131,6 +130,7 @@
         if (savedInstanceState == null) {
             initProgressBar(view);
         }
+        mSelection.clearCheckedItemList();
 
         final PhotosTabAdapter adapter = new PhotosTabAdapter(showRecentSection, mSelection,
                 mImageLoader, mOnMediaItemClickListener, /* lifecycleOwner */ this,
@@ -188,8 +188,21 @@
         if (mIsCloudMediaInPhotoPickerEnabled) {
             setOnScrollListenerForRecyclerView();
         }
-    }
 
+        // uncheck the unavailable items at UI those are no longer available in the selection list
+        getPickerActivity().isItemPhotoGridViewChanged()
+                .observe(this, isItemViewChanged -> {
+                    if (isItemViewChanged) {
+                        // To re-bind the view just to uncheck the unavailable media items at UI
+                        // Size of mCheckItems is going to be constant ( Iterating over mCheckItems
+                        // is not a heavy operation)
+                        for (Integer index : mSelection.getCheckedItemsIndexes()) {
+                            adapter.notifyItemChanged(index);
+                        }
+                    }
+                }
+        );
+    }
 
     private void initProgressBar(@NonNull View view) {
         // Check feature flag for cloud media and if it is not true then hide progress bar and
@@ -279,7 +292,6 @@
     @Override
     public void onResume() {
         super.onResume();
-
         final String title;
         final LayoutModeUtils.Mode layoutMode;
         final boolean shouldHideProfileButton;
@@ -340,12 +352,15 @@
             new PhotosTabAdapter.OnMediaItemClickListener() {
                 @Override
                 public void onItemClick(@NonNull View view, int position) {
+
                     if (mSelection.canSelectMultiple()) {
                         final boolean isSelectedBefore = view.isSelected();
 
                         if (isSelectedBefore) {
                             mSelection.removeSelectedItem((Item) view.getTag());
+                            mSelection.removeCheckedItemIndex((Item) view.getTag());
                         } else {
+                            mSelection.addCheckedItemIndex((Item) view.getTag(), position);
                             if (!mSelection.isSelectionAllowed()) {
                                 final int maxCount = mSelection.getMaxSelectionLimit();
                                 final CharSequence quantityText =
@@ -364,6 +379,7 @@
                             }
                         }
                         view.setSelected(!isSelectedBefore);
+
                         // There is an issue b/223695510 about not selected in Accessibility mode.
                         // It only says selected state, but it doesn't say not selected state.
                         // Add the not selected only to avoid that it says selected twice.
@@ -491,4 +507,4 @@
         super.onDestroy();
         mMainThreadHandler.removeCallbacksAndMessages(mHideProgressBarToken);
     }
-}
+}
\ No newline at end of file