Fragment removal fixes

* Prevents a crash when item collection changes while
  mFragmentManager.isStateSaved() == true. E.g. while the app is
  minimised.
* Removes zombie Fragments that are no longer bound to any
  items (issue after rotation).
* Removes saved state of removed items.
* Provides a grace period of 10s for previously bound Fragments
  (pre-config change) to get rebound. This is to avoid Fragment churn.

To faciliate those, we need a Lifecycle, which is now a part of the
contructor. Two additional utility constructors were added to simplify
using the adapter in the context of a Fragment / FragmentActivity being
the ViewPager2 hosts.

Bug: 122669030
Test: ./gradlew viewpager2:connectedCheck

Change-Id: I3bdeca06780e4907f6765e86e47041219e4ab8d3
diff --git a/viewpager2/api/1.0.0-alpha03.txt b/viewpager2/api/1.0.0-alpha03.txt
index 4b1a6aa..1b64963 100644
--- a/viewpager2/api/1.0.0-alpha03.txt
+++ b/viewpager2/api/1.0.0-alpha03.txt
@@ -2,7 +2,9 @@
 package androidx.viewpager2.adapter {
 
   public abstract class FragmentStateAdapter extends androidx.recyclerview.widget.RecyclerView.Adapter<androidx.viewpager2.adapter.FragmentViewHolder> implements androidx.viewpager2.adapter.StatefulAdapter {
-    ctor public FragmentStateAdapter(androidx.fragment.app.FragmentManager);
+    ctor public FragmentStateAdapter(androidx.fragment.app.FragmentActivity);
+    ctor public FragmentStateAdapter(androidx.fragment.app.Fragment);
+    ctor public FragmentStateAdapter(androidx.fragment.app.FragmentManager, androidx.lifecycle.Lifecycle);
     method public boolean containsItem(long);
     method public abstract androidx.fragment.app.Fragment getItem(int);
     method public final void onBindViewHolder(androidx.viewpager2.adapter.FragmentViewHolder, int);
diff --git a/viewpager2/api/current.txt b/viewpager2/api/current.txt
index 4b1a6aa..1b64963 100644
--- a/viewpager2/api/current.txt
+++ b/viewpager2/api/current.txt
@@ -2,7 +2,9 @@
 package androidx.viewpager2.adapter {
 
   public abstract class FragmentStateAdapter extends androidx.recyclerview.widget.RecyclerView.Adapter<androidx.viewpager2.adapter.FragmentViewHolder> implements androidx.viewpager2.adapter.StatefulAdapter {
-    ctor public FragmentStateAdapter(androidx.fragment.app.FragmentManager);
+    ctor public FragmentStateAdapter(androidx.fragment.app.FragmentActivity);
+    ctor public FragmentStateAdapter(androidx.fragment.app.Fragment);
+    ctor public FragmentStateAdapter(androidx.fragment.app.FragmentManager, androidx.lifecycle.Lifecycle);
     method public boolean containsItem(long);
     method public abstract androidx.fragment.app.Fragment getItem(int);
     method public final void onBindViewHolder(androidx.viewpager2.adapter.FragmentViewHolder, int);
diff --git a/viewpager2/integration-tests/testapp/src/main/java/com/example/androidx/viewpager2/CardFragmentActivity.kt b/viewpager2/integration-tests/testapp/src/main/java/com/example/androidx/viewpager2/CardFragmentActivity.kt
index 794104d..55cf5b2 100644
--- a/viewpager2/integration-tests/testapp/src/main/java/com/example/androidx/viewpager2/CardFragmentActivity.kt
+++ b/viewpager2/integration-tests/testapp/src/main/java/com/example/androidx/viewpager2/CardFragmentActivity.kt
@@ -38,7 +38,7 @@
     override fun onCreate(savedInstanceState: Bundle?) {
         super.onCreate(savedInstanceState)
 
-        viewPager.adapter = object : FragmentStateAdapter(supportFragmentManager) {
+        viewPager.adapter = object : FragmentStateAdapter(this) {
             override fun getItem(position: Int): Fragment {
                 return CardFragment.create(Card.DECK[position])
             }
diff --git a/viewpager2/integration-tests/testapp/src/main/java/com/example/androidx/viewpager2/MutableCollectionFragmentActivity.kt b/viewpager2/integration-tests/testapp/src/main/java/com/example/androidx/viewpager2/MutableCollectionFragmentActivity.kt
index 746c1ad..21c49dd 100644
--- a/viewpager2/integration-tests/testapp/src/main/java/com/example/androidx/viewpager2/MutableCollectionFragmentActivity.kt
+++ b/viewpager2/integration-tests/testapp/src/main/java/com/example/androidx/viewpager2/MutableCollectionFragmentActivity.kt
@@ -37,7 +37,7 @@
 class MutableCollectionFragmentActivity : MutableCollectionBaseActivity() {
     override fun createViewPagerAdapter(): RecyclerView.Adapter<*> {
         val items = items // avoids resolving the ViewModel multiple times
-        return object : FragmentStateAdapter(supportFragmentManager) {
+        return object : FragmentStateAdapter(this) {
             override fun getItem(position: Int): PageFragment {
                 val itemId = items.itemId(position)
                 val itemText = items.getItemById(itemId)
diff --git a/viewpager2/src/androidTest/java/androidx/viewpager2/widget/BaseTest.kt b/viewpager2/src/androidTest/java/androidx/viewpager2/widget/BaseTest.kt
index 54f92c2..2c42ef2 100644
--- a/viewpager2/src/androidTest/java/androidx/viewpager2/widget/BaseTest.kt
+++ b/viewpager2/src/androidTest/java/androidx/viewpager2/widget/BaseTest.kt
@@ -428,7 +428,7 @@
 typealias AdapterProviderForItems = (items: List<String>) -> AdapterProvider
 
 val fragmentAdapterProvider: AdapterProviderForItems = { items ->
-    { activity: TestActivity -> FragmentAdapter(activity.supportFragmentManager, items) }
+    { activity: TestActivity -> FragmentAdapter(activity, items) }
 }
 
 /**
diff --git a/viewpager2/src/androidTest/java/androidx/viewpager2/widget/swipe/FragmentAdapter.kt b/viewpager2/src/androidTest/java/androidx/viewpager2/widget/swipe/FragmentAdapter.kt
index aafcea4..8eedc0f 100644
--- a/viewpager2/src/androidTest/java/androidx/viewpager2/widget/swipe/FragmentAdapter.kt
+++ b/viewpager2/src/androidTest/java/androidx/viewpager2/widget/swipe/FragmentAdapter.kt
@@ -22,7 +22,7 @@
 import android.view.View
 import android.view.ViewGroup
 import androidx.fragment.app.Fragment
-import androidx.fragment.app.FragmentManager
+import androidx.fragment.app.FragmentActivity
 import androidx.viewpager2.adapter.FragmentStateAdapter
 import org.hamcrest.Matchers.allOf
 import org.hamcrest.Matchers.greaterThanOrEqualTo
@@ -33,9 +33,9 @@
 private const val ARG_KEY = "key"
 
 class FragmentAdapter(
-    fragmentManager: FragmentManager,
+    fragmentActivity: FragmentActivity,
     private val items: List<String>
-) : FragmentStateAdapter(fragmentManager), SelfChecking {
+) : FragmentStateAdapter(fragmentActivity), SelfChecking {
     private val attachCount = AtomicInteger(0)
     private val destroyCount = AtomicInteger(0)
 
diff --git a/viewpager2/src/main/java/androidx/viewpager2/adapter/FragmentStateAdapter.java b/viewpager2/src/main/java/androidx/viewpager2/adapter/FragmentStateAdapter.java
index 101a933..9ab45dd 100644
--- a/viewpager2/src/main/java/androidx/viewpager2/adapter/FragmentStateAdapter.java
+++ b/viewpager2/src/main/java/androidx/viewpager2/adapter/FragmentStateAdapter.java
@@ -17,6 +17,8 @@
 package androidx.viewpager2.adapter;
 
 import android.os.Bundle;
+import android.os.Handler;
+import android.os.Looper;
 import android.os.Parcelable;
 import android.view.View;
 import android.view.ViewGroup;
@@ -25,12 +27,20 @@
 
 import androidx.annotation.NonNull;
 import androidx.annotation.Nullable;
+import androidx.collection.ArraySet;
 import androidx.collection.LongSparseArray;
 import androidx.core.view.ViewCompat;
 import androidx.fragment.app.Fragment;
+import androidx.fragment.app.FragmentActivity;
 import androidx.fragment.app.FragmentManager;
 import androidx.fragment.app.FragmentStatePagerAdapter;
+import androidx.lifecycle.Lifecycle;
+import androidx.lifecycle.LifecycleEventObserver;
+import androidx.lifecycle.LifecycleOwner;
 import androidx.recyclerview.widget.RecyclerView;
+import androidx.viewpager2.widget.ViewPager2;
+
+import java.util.Set;
 
 /**
  * Similar in behavior to {@link FragmentStatePagerAdapter}
@@ -50,19 +60,58 @@
  */
 public abstract class FragmentStateAdapter extends
         RecyclerView.Adapter<FragmentViewHolder> implements StatefulAdapter {
+    // State saving config
     private static final String KEY_PREFIX_FRAGMENT = "f#";
     private static final String KEY_PREFIX_STATE = "s#";
 
-    private final LongSparseArray<Fragment> mFragments = new LongSparseArray<>();
-    private final LongSparseArray<Fragment.SavedState> mSavedStates = new LongSparseArray<>();
-
-    // Keeps track what ViewHolder was last bound to an item.
-    private final LongSparseArray<Integer> mItemViewHolderMap =  new LongSparseArray<>();
+    // Fragment GC config
+    private static final long GRACE_WINDOW_TIME_MS = 10_000; // 10 seconds
 
     private final FragmentManager mFragmentManager;
+    private final Lifecycle mLifecycle;
 
-    public FragmentStateAdapter(@NonNull FragmentManager fragmentManager) {
+    // Fragment bookkeeping
+    private final LongSparseArray<Fragment> mFragments = new LongSparseArray<>();
+    private final LongSparseArray<Fragment.SavedState> mSavedStates = new LongSparseArray<>();
+    private final LongSparseArray<Integer> mItemIdToViewHolder = new LongSparseArray<>();
+
+    // Fragment GC
+    @SuppressWarnings("WeakerAccess") // to avoid creation of a synthetic accessor
+    boolean mIsInGracePeriod = false;
+    private boolean mHasStaleFragments = false;
+
+    /**
+     * @param fragmentActivity if the {@link ViewPager2} lives directly in a
+     * {@link FragmentActivity} subclass.
+     *
+     * @see FragmentStateAdapter#FragmentStateAdapter(Fragment)
+     * @see FragmentStateAdapter#FragmentStateAdapter(FragmentManager, Lifecycle)
+     */
+    public FragmentStateAdapter(@NonNull FragmentActivity fragmentActivity) {
+        this(fragmentActivity.getSupportFragmentManager(), fragmentActivity.getLifecycle());
+    }
+
+    /**
+     * @param fragment if the {@link ViewPager2} lives directly in a {@link Fragment} subclass.
+     *
+     * @see FragmentStateAdapter#FragmentStateAdapter(FragmentActivity)
+     * @see FragmentStateAdapter#FragmentStateAdapter(FragmentManager, Lifecycle)
+     */
+    public FragmentStateAdapter(@NonNull Fragment fragment) {
+        this(fragment.getChildFragmentManager(), fragment.getLifecycle());
+    }
+
+    /**
+     * @param fragmentManager of {@link ViewPager2}'s host
+     * @param lifecycle of {@link ViewPager2}'s host
+     *
+     * @see FragmentStateAdapter#FragmentStateAdapter(FragmentActivity)
+     * @see FragmentStateAdapter#FragmentStateAdapter(Fragment)
+     */
+    public FragmentStateAdapter(@NonNull FragmentManager fragmentManager,
+            @NonNull Lifecycle lifecycle) {
         mFragmentManager = fragmentManager;
+        mLifecycle = lifecycle;
         super.setHasStableIds(true);
     }
 
@@ -84,10 +133,10 @@
         final Long boundItemId = itemForViewHolder(viewHolderId); // item currently bound to the VH
         if (boundItemId != null && boundItemId != itemId) {
             removeFragment(boundItemId);
-            mItemViewHolderMap.remove(boundItemId);
+            mItemIdToViewHolder.remove(boundItemId);
         }
 
-        mItemViewHolderMap.put(itemId, viewHolderId); // this might overwrite an existing entry
+        mItemIdToViewHolder.put(itemId, viewHolderId); // this might overwrite an existing entry
         ensureFragment(position);
 
         /** Special case when {@link RecyclerView} decides to keep the {@link container}
@@ -108,17 +157,52 @@
                 }
             });
         }
+
+        gcFragments();
+    }
+
+    @SuppressWarnings("WeakerAccess") // to avoid creation of a synthetic accessor
+    void gcFragments() {
+        if (!mHasStaleFragments || shouldDelayFragmentTransactions()) {
+            return;
+        }
+
+        // Remove Fragments for items that are no longer part of the data-set
+        Set<Long> toRemove = new ArraySet<>();
+        for (int ix = 0; ix < mFragments.size(); ix++) {
+            long itemId = mFragments.keyAt(ix);
+            if (!containsItem(itemId)) {
+                toRemove.add(itemId);
+                mItemIdToViewHolder.remove(itemId); // in case they're still bound
+            }
+        }
+
+        // Remove Fragments that are not bound anywhere -- pending a grace period
+        if (!mIsInGracePeriod) {
+            mHasStaleFragments = false; // we've executed all GC checks
+
+            for (int ix = 0; ix < mFragments.size(); ix++) {
+                long itemId = mFragments.keyAt(ix);
+                if (!mItemIdToViewHolder.containsKey(itemId)) {
+                    toRemove.add(itemId);
+                }
+            }
+        }
+
+        for (Long itemId : toRemove) {
+            removeFragment(itemId);
+        }
     }
 
     private Long itemForViewHolder(int viewHolderId) {
         Long boundItemId = null;
-        for (int ix = 0; ix < mItemViewHolderMap.size(); ix++) {
-            if (mItemViewHolderMap.valueAt(ix) == viewHolderId) {
+        for (int ix = 0; ix < mItemIdToViewHolder.size(); ix++) {
+            if (mItemIdToViewHolder.valueAt(ix) == viewHolderId) {
                 if (boundItemId != null) {
                     throw new IllegalStateException("Design assumption violated: "
                             + "a ViewHolder can only be bound to one item at a time.");
                 }
-                boundItemId = mItemViewHolderMap.keyAt(ix);
+                boundItemId = mItemIdToViewHolder.keyAt(ix);
             }
         }
         return boundItemId;
@@ -189,6 +273,8 @@
         scheduleViewAttach(fragment, container);
         // TODO(b/122669030): this call might fail, so address with recovery steps
         mFragmentManager.beginTransaction().add(fragment, "f" + holder.getItemId()).commitNow();
+
+        gcFragments();
     }
 
     private void scheduleViewAttach(final Fragment fragment, final FrameLayout container) {
@@ -237,7 +323,7 @@
         final Long boundItemId = itemForViewHolder(viewHolderId); // item currently bound to the VH
         if (boundItemId != null) {
             removeFragment(boundItemId);
-            mItemViewHolderMap.remove(boundItemId);
+            mItemIdToViewHolder.remove(boundItemId);
         }
     }
 
@@ -265,12 +351,29 @@
             }
         }
 
+        if (!containsItem(itemId)) {
+            mSavedStates.remove(itemId);
+        }
+
+        if (!fragment.isAdded()) {
+            mFragments.remove(itemId);
+            return;
+        }
+
+        if (shouldDelayFragmentTransactions()) {
+            mHasStaleFragments = true;
+            return;
+        }
+
         if (fragment.isAdded() && containsItem(itemId)) {
             mSavedStates.put(itemId, mFragmentManager.saveFragmentInstanceState(fragment));
         }
-
-        mFragments.remove(itemId);
         mFragmentManager.beginTransaction().remove(fragment).commitNow();
+        mFragments.remove(itemId);
+    }
+
+    private boolean shouldDelayFragmentTransactions() {
+        return mFragmentManager.isStateSaved();
     }
 
     /**
@@ -353,12 +456,45 @@
             if (isValidKey(key, KEY_PREFIX_STATE)) {
                 long itemId = parseIdFromKey(key, KEY_PREFIX_STATE);
                 Fragment.SavedState state = bundle.getParcelable(key);
-                mSavedStates.put(itemId, state);
+                if (containsItem(itemId)) {
+                    mSavedStates.put(itemId, state);
+                }
                 continue;
             }
 
             throw new IllegalArgumentException("Unexpected key in savedState: " + key);
         }
+
+        if (!mFragments.isEmpty()) {
+            mHasStaleFragments = true;
+            mIsInGracePeriod = true;
+            gcFragments();
+            scheduleGracePeriodEnd();
+        }
+    }
+
+    private void scheduleGracePeriodEnd() {
+        final Handler handler = new Handler(Looper.getMainLooper());
+        final Runnable runnable = new Runnable() {
+            @Override
+            public void run() {
+                mIsInGracePeriod = false;
+                gcFragments(); // good opportunity to GC
+            }
+        };
+
+        mLifecycle.addObserver(new LifecycleEventObserver() {
+            @Override
+            public void onStateChanged(@NonNull LifecycleOwner source,
+                    @NonNull Lifecycle.Event event) {
+                if (event == Lifecycle.Event.ON_DESTROY) {
+                    handler.removeCallbacks(runnable);
+                    source.getLifecycle().removeObserver(this);
+                }
+            }
+        });
+
+        handler.postDelayed(runnable, GRACE_WINDOW_TIME_MS);
     }
 
     // Helper function for dealing with save / restore state