Crash fix for accessing DocumentInfo.derivedUri when in Recents.

Bug: 33371320
Change-Id: Ib04dfce6073dc03e4a3711f767b52de05174748a
diff --git a/src/com/android/documentsui/AbstractActionHandler.java b/src/com/android/documentsui/AbstractActionHandler.java
index 86093f9..af1860f 100644
--- a/src/com/android/documentsui/AbstractActionHandler.java
+++ b/src/com/android/documentsui/AbstractActionHandler.java
@@ -50,6 +50,7 @@
 import java.util.List;
 import java.util.Objects;
 import java.util.concurrent.Executor;
+import java.util.function.Consumer;
 
 /**
  * Provides support for specializing the actions (viewDocument etc.) to the host activity.
@@ -58,6 +59,7 @@
         implements ActionHandler {
 
     private static final String TAG = "AbstractActionHandler";
+    private static final int REFRESH_SPINNER_TIMEOUT = 500;
 
     protected final T mActivity;
     protected final State mState;
@@ -107,6 +109,14 @@
     }
 
     @Override
+    public void refreshDocument(DocumentInfo doc, BooleanConsumer callback) {
+        RefreshTask task = new RefreshTask(mState, doc, REFRESH_SPINNER_TIMEOUT,
+                mActivity.getApplicationContext(), mActivity::isDestroyed,
+                callback);
+        task.executeOnExecutor(mExecutors.lookup(doc == null ? null : doc.authority));
+    }
+
+    @Override
     public void openSelectedInNewWindow() {
         throw new UnsupportedOperationException("Can't open in new window.");
     }
diff --git a/src/com/android/documentsui/ActionHandler.java b/src/com/android/documentsui/ActionHandler.java
index 5f0449c..98f9896 100644
--- a/src/com/android/documentsui/ActionHandler.java
+++ b/src/com/android/documentsui/ActionHandler.java
@@ -42,6 +42,12 @@
      */
     void ejectRoot(RootInfo root, BooleanConsumer listener);
 
+    /**
+     * Attempts to refresh the given DocumentInfo, which should be at the top of the state stack.
+     * Returns a boolean answer to the callback, given by {@link ContentProvider#refresh}.
+     */
+    void refreshDocument(DocumentInfo doc, BooleanConsumer callback);
+
     void showAppDetails(ResolveInfo info);
 
     void openRoot(RootInfo root);
diff --git a/src/com/android/documentsui/RefreshTask.java b/src/com/android/documentsui/RefreshTask.java
index 4692082..d208346 100644
--- a/src/com/android/documentsui/RefreshTask.java
+++ b/src/com/android/documentsui/RefreshTask.java
@@ -29,12 +29,12 @@
 import android.util.Log;
 
 import com.android.documentsui.base.ApplicationScope;
+import com.android.documentsui.base.BooleanConsumer;
 import com.android.documentsui.base.CheckedTask;
+import com.android.documentsui.base.DocumentInfo;
 import com.android.documentsui.base.Shared;
 import com.android.documentsui.base.State;
 
-import java.util.function.Consumer;
-
 /**
  * A {@link CheckedTask} that calls
  * {@link ContentResolver#refresh(Uri, android.os.Bundle, android.os.CancellationSignal)} on the
@@ -46,14 +46,14 @@
 
     private final @ApplicationScope Context mContext;
     private final State mState;
-    private final Uri mUri;
-    private final Consumer<Boolean> mCallback;
+    private final DocumentInfo mDoc;
+    private final BooleanConsumer mCallback;
     private final CancellationSignal mSignal;
 
-    public RefreshTask(State state, Uri uri, long timeout, @ApplicationScope Context context, Check check,
-            Consumer<Boolean> callback) {
+    public RefreshTask(State state, DocumentInfo doc, long timeout,
+            @ApplicationScope Context context, Check check, BooleanConsumer callback) {
         super(check);
-        mUri = uri;
+        mDoc = doc;
         mContext = context;
         mState = state;
         mCallback = callback;
@@ -63,13 +63,18 @@
 
     @Override
     public @Nullable Boolean run(Void... params) {
-        if (mUri == null) {
-            Log.w(TAG, "Attempted to refresh on a null uri. Aborting.");
+        if (mDoc == null) {
+            Log.w(TAG, "Ignoring attempt to refresh due to null DocumentInfo.");
             return false;
         }
 
-        if (mUri != mState.stack.peek().derivedUri) {
-            Log.w(TAG, "Attempted to refresh on a non-top-level uri. Aborting.");
+        if (mState.stack.isEmpty()) {
+            Log.w(TAG, "Ignoring attempt to refresh due to empty stack.");
+            return false;
+        }
+
+        if (!mDoc.derivedUri.equals(mState.stack.peek().derivedUri)) {
+            Log.w(TAG, "Ignoring attempt to refresh on a non-top-level uri.");
             return false;
         }
 
@@ -78,17 +83,17 @@
         // and we will update accordingly. Else, we just tell the callback that Refresh is not
         // supported.
         if (!Shared.ENABLE_OMC_API_FEATURES) {
-            Log.w(TAG, "Attempted to call Refresh on an older Android platform. Aborting.");
+            Log.w(TAG, "Ignoring attempt to call Refresh on an older Android platform.");
             return false;
         }
 
         final ContentResolver resolver = mContext.getContentResolver();
-        final String authority = mUri.getAuthority();
+        final String authority = mDoc.authority;
         boolean refreshSupported = false;
         ContentProviderClient client = null;
         try {
             client = DocumentsApplication.acquireUnstableProviderOrThrow(resolver, authority);
-            refreshSupported = client.refresh(mUri, null, mSignal);
+            refreshSupported = client.refresh(mDoc.derivedUri, null, mSignal);
         } catch (Exception e) {
             Log.w(TAG, "Failed to refresh", e);
         } finally {
diff --git a/src/com/android/documentsui/TimeoutTask.java b/src/com/android/documentsui/TimeoutTask.java
index 5fb20cf..57b119e 100644
--- a/src/com/android/documentsui/TimeoutTask.java
+++ b/src/com/android/documentsui/TimeoutTask.java
@@ -19,6 +19,7 @@
 import android.annotation.CallSuper;
 import android.os.AsyncTask;
 import android.os.Handler;
+import android.os.Looper;
 
 import com.android.documentsui.base.CheckedTask;
 import com.android.documentsui.base.DocumentInfo;
@@ -48,7 +49,9 @@
             return;
         }
 
-        Handler handler = new Handler();
+        // Need to initialize handler to main Looper so it can initialize correctly in test cases
+        // Instrumentation threads don't have looper initialized
+        Handler handler = new Handler(Looper.getMainLooper());
         handler.postDelayed(() -> {
             if (getStatus() == AsyncTask.Status.RUNNING) {
                 onTimeout();
diff --git a/src/com/android/documentsui/dirlist/DirectoryFragment.java b/src/com/android/documentsui/dirlist/DirectoryFragment.java
index 26520e8..c641275 100644
--- a/src/com/android/documentsui/dirlist/DirectoryFragment.java
+++ b/src/com/android/documentsui/dirlist/DirectoryFragment.java
@@ -1145,18 +1145,15 @@
             cache.removeUri(mModel.getItemUri(ids[i]));
         }
 
-        final Uri uri = mState.stack.peek().derivedUri;
-        RefreshTask task = new RefreshTask(mState, uri, REFRESH_SPINNER_TIMEOUT,
-                getContext().getApplicationContext(), this::isDetached,
-                (Boolean refreshSupported) -> {
-                    if (refreshSupported) {
-                        mRefreshLayout.setRefreshing(false);
-                    } else {
-                        // If Refresh API isn't available, we will explicitly reload the loader
-                        getLoaderManager().restartLoader(LOADER_ID, null, mLoaderCallbacks);
-                    }
-                });
-        task.executeOnExecutor(mActivity.getExecutorForCurrentDirectory());
+        final DocumentInfo doc = mState.stack.peek();
+        mActions.refreshDocument(doc, (boolean refreshSupported) -> {
+            if (refreshSupported) {
+                mRefreshLayout.setRefreshing(false);
+            } else {
+                // If Refresh API isn't available, we will explicitly reload the loader
+                getLoaderManager().restartLoader(LOADER_ID, null, mLoaderCallbacks);
+            }
+        });
     }
 
     private final class ModelUpdateListener implements EventListener<Model.Update> {
diff --git a/tests/common/com/android/documentsui/TestActivity.java b/tests/common/com/android/documentsui/TestActivity.java
index 7b4795c..15a724e 100644
--- a/tests/common/com/android/documentsui/TestActivity.java
+++ b/tests/common/com/android/documentsui/TestActivity.java
@@ -21,10 +21,14 @@
 import android.app.Activity;
 import android.content.ComponentName;
 import android.content.ContentResolver;
+import android.content.Context;
 import android.content.Intent;
 import android.content.pm.PackageManager;
 import android.content.res.Resources;
 import android.net.Uri;
+import android.os.Bundle;
+import android.test.mock.MockContentProvider;
+import android.test.mock.MockContentResolver;
 
 import com.android.documentsui.AbstractActionHandler.CommonAddons;
 import com.android.documentsui.base.DocumentInfo;
@@ -32,6 +36,7 @@
 import com.android.documentsui.testing.TestEventListener;
 import com.android.documentsui.testing.TestPackageManager;
 import com.android.documentsui.testing.TestResources;
+import com.android.documentsui.testing.TestRootsAccess;
 
 import org.mockito.Mockito;
 
@@ -45,6 +50,8 @@
     public TestPackageManager packageMgr;
     public Intent intent;
     public RootInfo currentRoot;
+    public MockContentResolver contentResolver;
+    public MockContentProvider contentProvider;
 
     public TestEventListener<Intent> startActivity;
     public TestEventListener<Intent> startService;
@@ -70,6 +77,10 @@
        refreshCurrentRootAndDirectory =  new TestEventListener<>();
        setRootsDrawerOpen = new TestEventListener<>();
        notifyDirectoryNavigated = new TestEventListener<>();
+       contentResolver = new MockContentResolver();
+       contentProvider = new DocsMockContentProvider();
+       contentResolver.addProvider(TestRootsAccess.HOME.authority, contentProvider);
+
    }
 
     @Override
@@ -143,7 +154,17 @@
 
     @Override
     public final ContentResolver getContentResolver() {
-        return null;
+        return contentResolver;
+    }
+
+    @Override
+    public final Context getApplicationContext() {
+        return this;
+    }
+
+    @Override
+    public boolean isDestroyed() {
+        return false;
     }
 
     @Override
@@ -153,3 +174,10 @@
 // Trick Mockito into finding our Addons methods correctly. W/o this
 // hack, Mockito thinks Addons methods are not implemented.
 abstract class AbstractBase extends Activity implements CommonAddons {}
+
+class DocsMockContentProvider extends MockContentProvider {
+    @Override
+    public boolean refresh(Uri url, Bundle args) {
+        return true;
+    }
+}
diff --git a/tests/unit/com/android/documentsui/files/ActionHandlerTest.java b/tests/unit/com/android/documentsui/files/ActionHandlerTest.java
index c332bfd..013a391 100644
--- a/tests/unit/com/android/documentsui/files/ActionHandlerTest.java
+++ b/tests/unit/com/android/documentsui/files/ActionHandlerTest.java
@@ -22,7 +22,9 @@
 import static com.android.documentsui.testing.IntentAsserts.assertHasExtraUri;
 
 import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertFalse;
 import static org.junit.Assert.assertNotNull;
+import static org.junit.Assert.assertTrue;
 
 import android.content.Intent;
 import android.net.Uri;
@@ -33,6 +35,7 @@
 
 import com.android.documentsui.R;
 import com.android.documentsui.TestActionModeAddons;
+import com.android.documentsui.base.DocumentInfo;
 import com.android.documentsui.base.DocumentStack;
 import com.android.documentsui.base.RootInfo;
 import com.android.documentsui.base.Shared;
@@ -57,6 +60,7 @@
     private TestDialogController mDialogs;
     private TestConfirmationCallback mCallback;
     private ActionHandler<TestActivity> mHandler;
+    private boolean refreshAnswer = false;
 
     @Before
     public void setUp() {
@@ -289,6 +293,41 @@
         assertRootPicked(TestRootsAccess.PICKLES.getUri());
     }
 
+    @Test
+    public void testRefresh_nullUri() throws Exception {
+        refreshAnswer = true;
+        mHandler.refreshDocument(null, (boolean answer) -> {
+            refreshAnswer = answer;
+        });
+
+        mEnv.beforeAsserts();
+        assertFalse(refreshAnswer);
+    }
+
+    @Test
+    public void testRefresh_emptyStack() throws Exception {
+        refreshAnswer = true;
+        assertTrue(mEnv.state.stack.isEmpty());
+        mHandler.refreshDocument(new DocumentInfo(), (boolean answer) -> {
+            refreshAnswer = answer;
+        });
+
+        mEnv.beforeAsserts();
+        assertFalse(refreshAnswer);
+    }
+
+    @Test
+    public void testRefresh() throws Exception {
+        refreshAnswer = false;
+        mEnv.populateStack();
+        mHandler.refreshDocument(mEnv.model.getDocument("1"), (boolean answer) -> {
+            refreshAnswer = answer;
+        });
+
+        mEnv.beforeAsserts();
+        assertTrue(refreshAnswer);
+    }
+
     private void assertRootPicked(Uri expectedUri) throws Exception {
         mEnv.beforeAsserts();