Adding UI tests for various bind widget flows

Change-Id: I634302051886baee6b6424f69bc95db860b4823e
diff --git a/src/com/android/launcher3/AppWidgetResizeFrame.java b/src/com/android/launcher3/AppWidgetResizeFrame.java
index 0ba9499..daeca3b 100644
--- a/src/com/android/launcher3/AppWidgetResizeFrame.java
+++ b/src/com/android/launcher3/AppWidgetResizeFrame.java
@@ -363,7 +363,7 @@
                 sTmpRect.right, sTmpRect.bottom);
     }
 
-    public static Rect getWidgetSizeRanges(Launcher launcher, int spanX, int spanY, Rect rect) {
+    public static Rect getWidgetSizeRanges(Context context, int spanX, int spanY, Rect rect) {
         if (sCellSize == null) {
             InvariantDeviceProfile inv = LauncherAppState.getInstance().getInvariantDeviceProfile();
 
@@ -376,7 +376,7 @@
         if (rect == null) {
             rect = new Rect();
         }
-        final float density = launcher.getResources().getDisplayMetrics().density;
+        final float density = context.getResources().getDisplayMetrics().density;
 
         // Compute landscape size
         int landWidth = (int) ((spanX * sCellSize[0].x) / density);
diff --git a/src/com/android/launcher3/Launcher.java b/src/com/android/launcher3/Launcher.java
index e411527..245b399 100644
--- a/src/com/android/launcher3/Launcher.java
+++ b/src/com/android/launcher3/Launcher.java
@@ -4013,7 +4013,7 @@
                 // Note: This assumes that the id remap broadcast is received before this step.
                 // If that is not the case, the id remap will be ignored and user may see the
                 // click to setup view.
-                PendingAddWidgetInfo pendingInfo = new PendingAddWidgetInfo(this, appWidgetInfo, null);
+                PendingAddWidgetInfo pendingInfo = new PendingAddWidgetInfo(this, appWidgetInfo);
                 pendingInfo.spanX = item.spanX;
                 pendingInfo.spanY = item.spanY;
                 pendingInfo.minSpanX = item.minSpanX;
diff --git a/src/com/android/launcher3/LauncherAppWidgetHostView.java b/src/com/android/launcher3/LauncherAppWidgetHostView.java
index c789082..570607e 100644
--- a/src/com/android/launcher3/LauncherAppWidgetHostView.java
+++ b/src/com/android/launcher3/LauncherAppWidgetHostView.java
@@ -27,6 +27,7 @@
 import android.view.ViewConfiguration;
 import android.view.ViewDebug;
 import android.view.ViewGroup;
+import android.view.accessibility.AccessibilityNodeInfo;
 import android.widget.RemoteViews;
 
 import com.android.launcher3.dragndrop.DragLayer;
@@ -291,4 +292,10 @@
             });
         }
     }
+
+    @Override
+    public void onInitializeAccessibilityNodeInfo(AccessibilityNodeInfo info) {
+        super.onInitializeAccessibilityNodeInfo(info);
+        info.setClassName(getClass().getName());
+    }
 }
diff --git a/src/com/android/launcher3/widget/PendingAddWidgetInfo.java b/src/com/android/launcher3/widget/PendingAddWidgetInfo.java
index fcb714f..de06ab6 100644
--- a/src/com/android/launcher3/widget/PendingAddWidgetInfo.java
+++ b/src/com/android/launcher3/widget/PendingAddWidgetInfo.java
@@ -16,6 +16,7 @@
 package com.android.launcher3.widget;
 
 import android.appwidget.AppWidgetHostView;
+import android.content.Context;
 import android.os.Bundle;
 import android.os.Parcelable;
 
@@ -37,14 +38,14 @@
     public AppWidgetHostView boundWidget;
     public Bundle bindOptions = null;
 
-    public PendingAddWidgetInfo(Launcher launcher, LauncherAppWidgetProviderInfo i, Parcelable data) {
+    public PendingAddWidgetInfo(Context context, LauncherAppWidgetProviderInfo i) {
         if (i.isCustomWidget) {
             itemType = LauncherSettings.Favorites.ITEM_TYPE_CUSTOM_APPWIDGET;
         } else {
             itemType = LauncherSettings.Favorites.ITEM_TYPE_APPWIDGET;
         }
         this.info = i;
-        user = AppWidgetManagerCompat.getInstance(launcher).getUser(i);
+        user = AppWidgetManagerCompat.getInstance(context).getUser(i);
         componentName = i.provider;
         previewImage = i.previewImage;
         icon = i.icon;
diff --git a/src/com/android/launcher3/widget/WidgetHostViewLoader.java b/src/com/android/launcher3/widget/WidgetHostViewLoader.java
index b47ba84..297505b 100644
--- a/src/com/android/launcher3/widget/WidgetHostViewLoader.java
+++ b/src/com/android/launcher3/widget/WidgetHostViewLoader.java
@@ -151,15 +151,15 @@
         return true;
     }
 
-    public static Bundle getDefaultOptionsForWidget(Launcher launcher, PendingAddWidgetInfo info) {
+    public static Bundle getDefaultOptionsForWidget(Context context, PendingAddWidgetInfo info) {
         Bundle options = null;
-        Rect rect = new Rect();
         if (Utilities.ATLEAST_JB_MR1) {
-            AppWidgetResizeFrame.getWidgetSizeRanges(launcher, info.spanX, info.spanY, rect);
-            Rect padding = AppWidgetHostView.getDefaultPaddingForWidget(launcher,
+            Rect rect = new Rect();
+            AppWidgetResizeFrame.getWidgetSizeRanges(context, info.spanX, info.spanY, rect);
+            Rect padding = AppWidgetHostView.getDefaultPaddingForWidget(context,
                     info.componentName, null);
 
-            float density = launcher.getResources().getDisplayMetrics().density;
+            float density = context.getResources().getDisplayMetrics().density;
             int xPaddingDips = (int) ((padding.left + padding.right) / density);
             int yPaddingDips = (int) ((padding.top + padding.bottom) / density);
 
diff --git a/src/com/android/launcher3/widget/WidgetsListAdapter.java b/src/com/android/launcher3/widget/WidgetsListAdapter.java
index ac9d62e..5d8adf5 100644
--- a/src/com/android/launcher3/widget/WidgetsListAdapter.java
+++ b/src/com/android/launcher3/widget/WidgetsListAdapter.java
@@ -138,7 +138,7 @@
             WidgetCell widget = (WidgetCell) row.getChildAt(i);
             if (infoList.get(i) instanceof LauncherAppWidgetProviderInfo) {
                 LauncherAppWidgetProviderInfo info = (LauncherAppWidgetProviderInfo) infoList.get(i);
-                PendingAddWidgetInfo pawi = new PendingAddWidgetInfo(mLauncher, info, null);
+                PendingAddWidgetInfo pawi = new PendingAddWidgetInfo(mLauncher, info);
                 widget.setTag(pawi);
                 widget.applyFromAppWidgetProviderInfo(info, mWidgetPreviewLoader);
             } else if (infoList.get(i) instanceof ResolveInfo) {
diff --git a/tests/src/com/android/launcher3/BindWidgetTest.java b/tests/src/com/android/launcher3/BindWidgetTest.java
new file mode 100644
index 0000000..06e1936
--- /dev/null
+++ b/tests/src/com/android/launcher3/BindWidgetTest.java
@@ -0,0 +1,428 @@
+package com.android.launcher3;
+
+import android.annotation.TargetApi;
+import android.app.SearchManager;
+import android.appwidget.AppWidgetHost;
+import android.appwidget.AppWidgetProviderInfo;
+import android.content.ComponentName;
+import android.content.ContentResolver;
+import android.content.ContentValues;
+import android.content.Context;
+import android.content.Intent;
+import android.content.pm.PackageInstaller;
+import android.content.pm.PackageInstaller.SessionParams;
+import android.content.pm.PackageManager;
+import android.database.Cursor;
+import android.os.Build;
+import android.os.Bundle;
+import android.os.ParcelFileDescriptor;
+import android.support.test.uiautomator.UiDevice;
+import android.support.test.uiautomator.UiSelector;
+import android.test.InstrumentationTestCase;
+
+import com.android.launcher3.compat.AppWidgetManagerCompat;
+import com.android.launcher3.compat.PackageInstallerCompat;
+import com.android.launcher3.util.ManagedProfileHeuristic;
+import com.android.launcher3.widget.PendingAddWidgetInfo;
+import com.android.launcher3.widget.WidgetHostViewLoader;
+
+import java.io.FileInputStream;
+import java.util.Set;
+import java.util.concurrent.Callable;
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.atomic.AtomicReference;
+
+/**
+ * Tests for bind widget flow.
+ *
+ * Note running these tests will clear the workspace on the device.
+ */
+@TargetApi(Build.VERSION_CODES.LOLLIPOP)
+public class BindWidgetTest extends InstrumentationTestCase {
+
+    private static final long DEFAULT_TIMEOUT = 6000;
+
+    private UiDevice mDevice;
+    private Context mTargetContext;
+    private ContentResolver mResolver;
+    private AppWidgetManagerCompat mWidgetManager;
+
+    // Objects created during test, which should be cleaned up in the end.
+    private Cursor mCursor;
+    // App install session id.
+    private int mSessionId = -1;
+
+    @Override
+    protected void setUp() throws Exception {
+        super.setUp();
+
+        mDevice = UiDevice.getInstance(getInstrumentation());
+        mTargetContext = getInstrumentation().getTargetContext();
+        mResolver = mTargetContext.getContentResolver();
+        mWidgetManager = AppWidgetManagerCompat.getInstance(mTargetContext);
+
+        // Check bind widget permission
+        String pkg = mTargetContext.getPackageName();
+        if (mTargetContext.getPackageManager().checkPermission(
+                pkg, android.Manifest.permission.BIND_APPWIDGET)
+                != PackageManager.PERMISSION_GRANTED) {
+            ParcelFileDescriptor pfd = getInstrumentation().getUiAutomation().executeShellCommand(
+                    "appwidget grantbind --package " + pkg);
+            // Read the input stream fully.
+            FileInputStream fis = new ParcelFileDescriptor.AutoCloseInputStream(pfd);
+            while (fis.read() != -1);
+            fis.close();
+        }
+
+        // Clear all existing data
+        LauncherSettings.Settings.call(mResolver, LauncherSettings.Settings.METHOD_CREATE_EMPTY_DB);
+        LauncherSettings.Settings.call(mResolver, LauncherSettings.Settings.METHOD_CLEAR_EMPTY_DB_FLAG);
+    }
+
+    @Override
+    protected void tearDown() throws Exception {
+        super.tearDown();
+        if (mCursor != null) {
+            mCursor.close();
+        }
+
+        if (mSessionId > -1) {
+            mTargetContext.getPackageManager().getPackageInstaller().abandonSession(mSessionId);
+        }
+    }
+
+    public void testBindNormalWidget_withConfig() {
+        LauncherAppWidgetProviderInfo info = findWidgetProvider(true);
+        LauncherAppWidgetInfo item = createWidgetInfo(info, true);
+
+        setupAndVerifyContents(item, LauncherAppWidgetHostView.class, info.label);
+    }
+
+    public void testBindNormalWidget_withoutConfig() {
+        LauncherAppWidgetProviderInfo info = findWidgetProvider(false);
+        LauncherAppWidgetInfo item = createWidgetInfo(info, true);
+
+        setupAndVerifyContents(item, LauncherAppWidgetHostView.class, info.label);
+    }
+
+    public void testUnboundWidget_removed() throws Exception {
+        LauncherAppWidgetProviderInfo info = findWidgetProvider(false);
+        LauncherAppWidgetInfo item = createWidgetInfo(info, false);
+        item.appWidgetId = 33;
+
+        // Since there is no widget to verify, just wait until the workspace is ready.
+        setupAndVerifyContents(item, Workspace.class, null);
+
+        waitUntilLoaderIdle();
+        // Item deleted from db
+        mCursor = mResolver.query(LauncherSettings.Favorites.getContentUri(item.id),
+                null, null, null, null, null);
+        assertEquals(0, mCursor.getCount());
+
+        // The view does not exist
+        assertFalse(mDevice.findObject(new UiSelector().description(info.label)).exists());
+    }
+
+    public void testPendingWidget_autoRestored() {
+        // A non-restored widget with no config screen gets restored automatically.
+        LauncherAppWidgetProviderInfo info = findWidgetProvider(false);
+
+        // Do not bind the widget
+        LauncherAppWidgetInfo item = createWidgetInfo(info, false);
+        item.restoreStatus = LauncherAppWidgetInfo.FLAG_ID_NOT_VALID;
+
+        setupAndVerifyContents(item, LauncherAppWidgetHostView.class, info.label);
+    }
+
+    public void testPendingWidget_withConfigScreen() throws Exception {
+        // A non-restored widget with config screen get bound and shows a 'Click to setup' UI.
+        LauncherAppWidgetProviderInfo info = findWidgetProvider(true);
+
+        // Do not bind the widget
+        LauncherAppWidgetInfo item = createWidgetInfo(info, false);
+        item.restoreStatus = LauncherAppWidgetInfo.FLAG_ID_NOT_VALID;
+
+        setupAndVerifyContents(item, PendingAppWidgetHostView.class, null);
+        waitUntilLoaderIdle();
+        // Item deleted from db
+        mCursor = mResolver.query(LauncherSettings.Favorites.getContentUri(item.id),
+                null, null, null, null, null);
+        mCursor.moveToNext();
+
+        // Widget has a valid Id now.
+        assertEquals(0, mCursor.getInt(mCursor.getColumnIndex(LauncherSettings.Favorites.RESTORED))
+                & LauncherAppWidgetInfo.FLAG_ID_NOT_VALID);
+        assertNotNull(mWidgetManager.getAppWidgetInfo(mCursor.getInt(mCursor.getColumnIndex(
+                LauncherSettings.Favorites.APPWIDGET_ID))));
+    }
+
+    public void testPendingWidget_notRestored_removed() throws Exception {
+        LauncherAppWidgetInfo item = getInvalidWidgetInfo();
+        item.restoreStatus = LauncherAppWidgetInfo.FLAG_ID_NOT_VALID
+                | LauncherAppWidgetInfo.FLAG_PROVIDER_NOT_READY;
+
+        setupAndVerifyContents(item, Workspace.class, null);
+        // The view does not exist
+        assertFalse(mDevice.findObject(
+                new UiSelector().className(PendingAppWidgetHostView.class)).exists());
+        waitUntilLoaderIdle();
+        // Item deleted from db
+        mCursor = mResolver.query(LauncherSettings.Favorites.getContentUri(item.id),
+                null, null, null, null, null);
+        assertEquals(0, mCursor.getCount());
+    }
+
+    public void testPendingWidget_notRestored_brokenInstall() throws Exception {
+        // A widget which is was being installed once, even if its not being
+        // installed at the moment is not removed.
+        LauncherAppWidgetInfo item = getInvalidWidgetInfo();
+        item.restoreStatus = LauncherAppWidgetInfo.FLAG_ID_NOT_VALID
+                | LauncherAppWidgetInfo.FLAG_RESTORE_STARTED
+                | LauncherAppWidgetInfo.FLAG_PROVIDER_NOT_READY;
+
+        setupAndVerifyContents(item, PendingAppWidgetHostView.class, null);
+        // Verify item still exists in db
+        waitUntilLoaderIdle();
+        mCursor = mResolver.query(LauncherSettings.Favorites.getContentUri(item.id),
+                null, null, null, null, null);
+        assertEquals(1, mCursor.getCount());
+
+        // Widget still has an invalid id.
+        mCursor.moveToNext();
+        assertEquals(LauncherAppWidgetInfo.FLAG_ID_NOT_VALID,
+                mCursor.getInt(mCursor.getColumnIndex(LauncherSettings.Favorites.RESTORED))
+                        & LauncherAppWidgetInfo.FLAG_ID_NOT_VALID);
+    }
+
+    public void testPendingWidget_notRestored_activeInstall() throws Exception {
+        // A widget which is being installed is not removed
+        LauncherAppWidgetInfo item = getInvalidWidgetInfo();
+        item.restoreStatus = LauncherAppWidgetInfo.FLAG_ID_NOT_VALID
+                | LauncherAppWidgetInfo.FLAG_PROVIDER_NOT_READY;
+
+        // Create an active installer session
+        SessionParams params = new SessionParams(SessionParams.MODE_FULL_INSTALL);
+        params.setAppPackageName(item.providerName.getPackageName());
+        PackageInstaller installer = mTargetContext.getPackageManager().getPackageInstaller();
+        mSessionId = installer.createSession(params);
+
+        setupAndVerifyContents(item, PendingAppWidgetHostView.class, null);
+        // Verify item still exists in db
+        waitUntilLoaderIdle();
+        mCursor = mResolver.query(LauncherSettings.Favorites.getContentUri(item.id),
+                null, null, null, null, null);
+        assertEquals(1, mCursor.getCount());
+
+        // Widget still has an invalid id.
+        mCursor.moveToNext();
+        assertEquals(LauncherAppWidgetInfo.FLAG_ID_NOT_VALID,
+                mCursor.getInt(mCursor.getColumnIndex(LauncherSettings.Favorites.RESTORED))
+                        & LauncherAppWidgetInfo.FLAG_ID_NOT_VALID);
+    }
+
+    /**
+     * Adds {@param item} on the homescreen on the 0th screen at 0,0, and verifies that the
+     * widget class is displayed on the homescreen.
+     * @param widgetClass the View class which is displayed on the homescreen
+     * @param desc the content description of the view or null.
+     */
+    private void setupAndVerifyContents(
+            LauncherAppWidgetInfo item, Class<?> widgetClass, String desc) {
+        // Add new screen
+        long screenId = LauncherSettings.Settings.call(
+                mResolver, LauncherSettings.Settings.METHOD_NEW_SCREEN_ID)
+                .getLong(LauncherSettings.Settings.EXTRA_VALUE);
+        ContentValues v = new ContentValues();
+        v.put(LauncherSettings.WorkspaceScreens._ID, screenId);
+        v.put(LauncherSettings.WorkspaceScreens.SCREEN_RANK, 0);
+        mResolver.insert(LauncherSettings.WorkspaceScreens.CONTENT_URI, v);
+
+        // Insert the item
+        v = new ContentValues();
+        item.id = LauncherSettings.Settings.call(
+                mResolver, LauncherSettings.Settings.METHOD_NEW_ITEM_ID)
+                .getLong(LauncherSettings.Settings.EXTRA_VALUE);
+        item.screenId = screenId;
+        item.onAddToDatabase(mTargetContext, v);
+        v.put(LauncherSettings.Favorites._ID, item.id);
+        mResolver.insert(LauncherSettings.Favorites.CONTENT_URI, v);
+
+        // Reset loader
+        try {
+            runTestOnUiThread(new Runnable() {
+                @Override
+                public void run() {
+                    ManagedProfileHeuristic.markExistingUsersForNoFolderCreation(mTargetContext);
+                    LauncherAppState.getInstance().getModel().resetLoadedState(true, true);
+                }
+            });
+        } catch (Throwable t) {
+            throw new IllegalArgumentException(t);
+        }
+        // Launch the home activity
+        getInstrumentation().getContext().startActivity(new Intent(Intent.ACTION_MAIN)
+                .addCategory(Intent.CATEGORY_HOME)
+                .setPackage(mTargetContext.getPackageName())
+                .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK));
+
+        // Verify UI
+        UiSelector selector = new UiSelector().packageName(mTargetContext.getPackageName())
+                .className(widgetClass);
+        if (desc != null) {
+            selector = selector.description(desc);
+        }
+        assertTrue(mDevice.findObject(selector).waitForExists(DEFAULT_TIMEOUT));
+    }
+
+    /**
+     * Finds a widget provider which can fit on the home screen.
+     * @param hasConfigureScreen if true, a provider with a config screen is returned.
+     */
+    private LauncherAppWidgetProviderInfo findWidgetProvider(final boolean hasConfigureScreen) {
+        LauncherAppWidgetProviderInfo info = getOnUiThread(new Callable<LauncherAppWidgetProviderInfo>() {
+            @Override
+            public LauncherAppWidgetProviderInfo call() throws Exception {
+                InvariantDeviceProfile idv =
+                        LauncherAppState.getInstance().getInvariantDeviceProfile();
+
+                ComponentName searchComponent = ((SearchManager) mTargetContext
+                        .getSystemService(Context.SEARCH_SERVICE)).getGlobalSearchActivity();
+                String searchPackage = searchComponent == null
+                        ? null : searchComponent.getPackageName();
+
+                for (AppWidgetProviderInfo info :
+                        AppWidgetManagerCompat.getInstance(mTargetContext).getAllProviders()) {
+                    if ((info.configure != null) ^ hasConfigureScreen) {
+                        continue;
+                    }
+                    // Exclude the widgets in search package, as Launcher already binds them in
+                    // QSB, so they can cause conflicts.
+                    if (info.provider.getPackageName().equals(searchPackage)) {
+                        continue;
+                    }
+                    LauncherAppWidgetProviderInfo widgetInfo = LauncherAppWidgetProviderInfo
+                            .fromProviderInfo(mTargetContext, info);
+                    if (widgetInfo.minSpanX >= idv.numColumns
+                            || widgetInfo.minSpanY >= idv.numRows) {
+                        continue;
+                    }
+                    return widgetInfo;
+                }
+                return null;
+            }
+        });
+        if (info == null) {
+            throw new IllegalArgumentException("No valid widget provider");
+        }
+        return info;
+    }
+
+    /**
+     * Creates a LauncherAppWidgetInfo corresponding to {@param info}
+     * @param bindWidget if true the info is bound and a valid widgetId is assigned to
+     *                   the LauncherAppWidgetInfo
+     */
+    private LauncherAppWidgetInfo createWidgetInfo(
+            LauncherAppWidgetProviderInfo info, boolean bindWidget) {
+        LauncherAppWidgetInfo item = new LauncherAppWidgetInfo(
+                LauncherAppWidgetInfo.NO_ID, info.provider);
+        item.spanX = info.minSpanX;
+        item.spanY = info.minSpanY;
+        item.minSpanX = info.minSpanX;
+        item.minSpanY = info.minSpanY;
+        item.user = mWidgetManager.getUser(info);
+        item.cellX = 0;
+        item.cellY = 0;
+        item.container = LauncherSettings.Favorites.CONTAINER_DESKTOP;
+
+        if (bindWidget) {
+            PendingAddWidgetInfo pendingInfo = new PendingAddWidgetInfo(mTargetContext, info);
+            pendingInfo.spanX = item.spanX;
+            pendingInfo.spanY = item.spanY;
+            pendingInfo.minSpanX = item.minSpanX;
+            pendingInfo.minSpanY = item.minSpanY;
+            Bundle options = WidgetHostViewLoader.getDefaultOptionsForWidget(mTargetContext, pendingInfo);
+
+            AppWidgetHost host = new AppWidgetHost(mTargetContext, Launcher.APPWIDGET_HOST_ID);
+            int widgetId = host.allocateAppWidgetId();
+            if (!mWidgetManager.bindAppWidgetIdIfAllowed(widgetId, info, options)) {
+                host.deleteAppWidgetId(widgetId);
+                throw new IllegalArgumentException("Unable to bind widget id");
+            }
+            item.appWidgetId = widgetId;
+        }
+        return item;
+    }
+
+    /**
+     * Returns a LauncherAppWidgetInfo with package name which is not present on the device
+     */
+    private LauncherAppWidgetInfo getInvalidWidgetInfo() {
+        String invalidPackage = "com.invalidpackage";
+        int count = 0;
+        String pkg = invalidPackage;
+
+        Set<String> activePackage = getOnUiThread(new Callable<Set<String>>() {
+            @Override
+            public Set<String> call() throws Exception {
+                return PackageInstallerCompat.getInstance(mTargetContext)
+                        .updateAndGetActiveSessionCache().keySet();
+            }
+        });
+        while(true) {
+            try {
+                mTargetContext.getPackageManager().getPackageInfo(
+                        pkg, PackageManager.GET_UNINSTALLED_PACKAGES);
+            } catch (Exception e) {
+                if (!activePackage.contains(pkg)) {
+                    break;
+                }
+            }
+            pkg = invalidPackage + count;
+            count ++;
+        }
+        LauncherAppWidgetInfo item = new LauncherAppWidgetInfo(10,
+                new ComponentName(pkg, "com.test.widgetprovider"));
+        item.spanX = 2;
+        item.spanY = 2;
+        item.minSpanX = 2;
+        item.minSpanY = 2;
+        item.cellX = 0;
+        item.cellY = 0;
+        item.container = LauncherSettings.Favorites.CONTAINER_DESKTOP;
+        return item;
+    }
+
+    /**
+     * Runs the callback on the UI thread and returns the result.
+     */
+    private <T> T getOnUiThread(final Callable<T> callback) {
+        final AtomicReference<T> result = new AtomicReference<>(null);
+        try {
+            runTestOnUiThread(new Runnable() {
+                @Override
+                public void run() {
+                    try {
+                        result.set(callback.call());
+                    } catch (Exception e) { }
+                }
+            });
+        } catch (Throwable t) { }
+        return result.get();
+    }
+
+    /**
+     * Blocks the current thread until all the jobs in the main worker thread are complete.
+     */
+    private void waitUntilLoaderIdle() throws InterruptedException {
+        final CountDownLatch latch = new CountDownLatch(1);
+        LauncherModel.sWorker.post(new Runnable() {
+            @Override
+            public void run() {
+                latch.countDown();
+            }
+        });
+        assertTrue(latch.await(5, TimeUnit.SECONDS));
+    }
+}