Add UiBlocker for DashboardFragment

If DashboardFragment detects blocker controllers, it won't display
until:
1. All blocking controllers finish bg works.
2. Timeout

This CL adds UiBlockerController to control this behavior and
BlockingSlicePreferenceController as an example.

Bug: 120803703
Test: atest SettingsUnitTests
Change-Id: I66fc194776d46ee49b3ec7685f3167834e673ba2
diff --git a/res/xml/bluetooth_device_details_fragment.xml b/res/xml/bluetooth_device_details_fragment.xml
index 90895f2..cf9fbf9 100644
--- a/res/xml/bluetooth_device_details_fragment.xml
+++ b/res/xml/bluetooth_device_details_fragment.xml
@@ -31,7 +31,7 @@
 
     <com.android.settings.slices.SlicePreference
         android:key="bt_device_slice"
-        settings:controller="com.android.settings.slices.SlicePreferenceController"
+        settings:controller="com.android.settings.slices.BlockingSlicePrefController"
         settings:allowDividerBelow="true"
         settings:allowDividerAbove="true"/>
 
diff --git a/src/com/android/settings/bluetooth/BluetoothDeviceDetailsFragment.java b/src/com/android/settings/bluetooth/BluetoothDeviceDetailsFragment.java
index 6ec419b..43de5a4 100644
--- a/src/com/android/settings/bluetooth/BluetoothDeviceDetailsFragment.java
+++ b/src/com/android/settings/bluetooth/BluetoothDeviceDetailsFragment.java
@@ -30,7 +30,7 @@
 import com.android.settings.core.FeatureFlags;
 import com.android.settings.dashboard.RestrictedDashboardFragment;
 import com.android.settings.overlay.FeatureFactory;
-import com.android.settings.slices.SlicePreferenceController;
+import com.android.settings.slices.BlockingSlicePrefController;
 import com.android.settingslib.bluetooth.CachedBluetoothDevice;
 import com.android.settingslib.bluetooth.LocalBluetoothManager;
 import com.android.settingslib.core.AbstractPreferenceController;
@@ -106,7 +106,7 @@
         if (FeatureFlagUtils.isEnabled(context, FeatureFlags.SLICE_INJECTION)) {
             final BluetoothFeatureProvider featureProvider = FeatureFactory.getFactory(context)
                     .getBluetoothFeatureProvider(context);
-            use(SlicePreferenceController.class).setSliceUri(
+            use(BlockingSlicePrefController.class).setSliceUri(
                     featureProvider.getBluetoothDeviceSettingsUri(mDeviceAddress));
         }
     }
diff --git a/src/com/android/settings/core/BasePreferenceController.java b/src/com/android/settings/core/BasePreferenceController.java
index facec4a..1c85009 100644
--- a/src/com/android/settings/core/BasePreferenceController.java
+++ b/src/com/android/settings/core/BasePreferenceController.java
@@ -106,6 +106,7 @@
 
 
     protected final String mPreferenceKey;
+    protected UiBlockListener mUiBlockListener;
 
     /**
      * Instantiate a controller as specified controller type and user-defined key.
@@ -289,4 +290,36 @@
      */
     public void updateRawDataToIndex(List<SearchIndexableRaw> rawData) {
     }
+
+    /**
+     * Set {@link UiBlockListener}
+     * @param uiBlockListener listener to set
+     */
+    public void setUiBlockListener(UiBlockListener uiBlockListener) {
+        mUiBlockListener = uiBlockListener;
+    }
+
+    /**
+     * Listener to invoke when background job is finished
+     */
+    public interface UiBlockListener {
+        /**
+         * To notify client that UI related background work is finished.
+         * (i.e. Slice is fully loaded.)
+         * @param controller Controller that contains background work
+         */
+        void onBlockerWorkFinished(BasePreferenceController controller);
+    }
+
+    /**
+     * Used for {@link BasePreferenceController} to decide whether it is ui blocker.
+     * If it is, entire UI will be invisible for a certain period until controller
+     * invokes {@link UiBlockListener}
+     *
+     * This won't block UI thread however has similar side effect. Please use it if you
+     * want to avoid janky animation(i.e. new preference is added in the middle of page).
+     *
+     * This music be used in {@link BasePreferenceController}
+     */
+    public interface UiBlocker {}
 }
\ No newline at end of file
diff --git a/src/com/android/settings/dashboard/DashboardFragment.java b/src/com/android/settings/dashboard/DashboardFragment.java
index 1916110..11858a7 100644
--- a/src/com/android/settings/dashboard/DashboardFragment.java
+++ b/src/com/android/settings/dashboard/DashboardFragment.java
@@ -56,7 +56,8 @@
  */
 public abstract class DashboardFragment extends SettingsPreferenceFragment
         implements SettingsBaseActivity.CategoryListener, Indexable,
-        SummaryLoader.SummaryConsumer, PreferenceGroup.OnExpandButtonClickListener {
+        SummaryLoader.SummaryConsumer, PreferenceGroup.OnExpandButtonClickListener,
+        BasePreferenceController.UiBlockListener {
     private static final String TAG = "DashboardFragment";
 
     private final Map<Class, List<AbstractPreferenceController>> mPreferenceControllers =
@@ -67,6 +68,7 @@
     private DashboardTilePlaceholderPreferenceController mPlaceholderPreferenceController;
     private boolean mListeningToCategoryChange;
     private SummaryLoader mSummaryLoader;
+    private UiBlockerController mBlockerController;
 
     @Override
     public void onAttach(Context context) {
@@ -105,6 +107,22 @@
         for (AbstractPreferenceController controller : controllers) {
             addPreferenceController(controller);
         }
+
+        checkUiBlocker(controllers);
+    }
+
+    private void checkUiBlocker(List<AbstractPreferenceController> controllers) {
+        final List<String> keys = new ArrayList<>();
+        controllers
+                .stream()
+                .filter(controller -> controller instanceof BasePreferenceController.UiBlocker)
+                .forEach(controller -> {
+                    ((BasePreferenceController) controller).setUiBlockListener(this);
+                    keys.add(controller.getPreferenceKey());
+                });
+
+        mBlockerController = new UiBlockerController(keys);
+        mBlockerController.start(()->updatePreferenceVisibility());
     }
 
     @Override
@@ -319,10 +337,11 @@
      * DashboardCategory.
      */
     private void refreshAllPreferences(final String TAG) {
+        final PreferenceScreen screen = getPreferenceScreen();
         // First remove old preferences.
-        if (getPreferenceScreen() != null) {
+        if (screen != null) {
             // Intentionally do not cache PreferenceScreen because it will be recreated later.
-            getPreferenceScreen().removeAll();
+            screen.removeAll();
         }
 
         // Add resource based tiles.
@@ -335,6 +354,27 @@
             Log.d(TAG, "All preferences added, reporting fully drawn");
             activity.reportFullyDrawn();
         }
+
+        updatePreferenceVisibility();
+    }
+
+    private void updatePreferenceVisibility() {
+        final PreferenceScreen screen = getPreferenceScreen();
+        if (screen == null) {
+            return;
+        }
+
+        final boolean visible = mBlockerController.isBlockerFinished();
+        for (List<AbstractPreferenceController> controllerList :
+                mPreferenceControllers.values()) {
+            for (AbstractPreferenceController controller : controllerList) {
+                final String key = controller.getPreferenceKey();
+                final Preference preference = screen.findPreference(key);
+                if (preference != null) {
+                    preference.setVisible(visible && controller.isAvailable());
+                }
+            }
+        }
     }
 
     /**
@@ -413,4 +453,9 @@
         }
         mSummaryLoader.setListening(true);
     }
+
+    @Override
+    public void onBlockerWorkFinished(BasePreferenceController controller) {
+        mBlockerController.countDown(controller.getPreferenceKey());
+    }
 }
diff --git a/src/com/android/settings/dashboard/UiBlockerController.java b/src/com/android/settings/dashboard/UiBlockerController.java
new file mode 100644
index 0000000..eeb56e6
--- /dev/null
+++ b/src/com/android/settings/dashboard/UiBlockerController.java
@@ -0,0 +1,101 @@
+/*
+ * Copyright (C) 2019 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.settings.dashboard;
+
+import android.util.Log;
+
+import androidx.annotation.NonNull;
+
+import com.android.settings.core.BasePreferenceController;
+import com.android.settingslib.utils.ThreadUtils;
+
+import java.util.HashSet;
+import java.util.List;
+import java.util.Set;
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.TimeUnit;
+
+/**
+ * Control ui blocker data and check whether it is finished
+ *
+ * @see BasePreferenceController.UiBlocker
+ * @see BasePreferenceController.OnUiBlockListener
+ */
+public class UiBlockerController {
+    private static final String TAG = "UiBlockerController";
+    private static final int TIMEOUT_MILLIS = 500;
+
+    private CountDownLatch mCountDownLatch;
+    private boolean mBlockerFinished;
+    private Set<String> mKeys;
+    private long mTimeoutMillis;
+
+    public UiBlockerController(@NonNull List<String> keys) {
+        this(keys, TIMEOUT_MILLIS);
+    }
+
+    public UiBlockerController(@NonNull List<String> keys, long timeout) {
+        mCountDownLatch = new CountDownLatch(keys.size());
+        mBlockerFinished = keys.isEmpty();
+        mKeys = new HashSet<>(keys);
+        mTimeoutMillis = timeout;
+    }
+
+    /**
+     * Start background thread, it will invoke {@code finishRunnable} if any condition is met
+     *
+     * 1. Waiting time exceeds {@link #mTimeoutMillis}
+     * 2. All background work that associated with {@link #mCountDownLatch} is finished
+     */
+    public boolean start(Runnable finishRunnable) {
+        if (mKeys.isEmpty()) {
+            // Don't need to run finishRunnable because it doesn't start
+            return false;
+        }
+        ThreadUtils.postOnBackgroundThread(() -> {
+            try {
+                mCountDownLatch.await(mTimeoutMillis, TimeUnit.MILLISECONDS);
+            } catch (InterruptedException e) {
+                Log.w(TAG, "interrupted");
+            }
+            mBlockerFinished = true;
+            ThreadUtils.postOnMainThread(finishRunnable);
+        });
+
+        return true;
+    }
+
+    /**
+     * Return {@code true} if all work finished
+     */
+    public boolean isBlockerFinished() {
+        return mBlockerFinished;
+    }
+
+    /**
+     * Count down latch by {@code key}. It only count down 1 time if same key count down multiple
+     * times.
+     */
+    public boolean countDown(String key) {
+        if (mKeys.remove(key)) {
+            mCountDownLatch.countDown();
+            return true;
+        }
+
+        return false;
+    }
+}
diff --git a/src/com/android/settings/slices/BlockingSlicePrefController.java b/src/com/android/settings/slices/BlockingSlicePrefController.java
new file mode 100644
index 0000000..94810c5
--- /dev/null
+++ b/src/com/android/settings/slices/BlockingSlicePrefController.java
@@ -0,0 +1,43 @@
+/*
+ * Copyright (C) 2018 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.settings.slices;
+
+import android.content.Context;
+
+import androidx.slice.Slice;
+
+import com.android.settings.core.BasePreferenceController;
+
+/**
+ * The blocking slice preference controller. It will make whole page invisible for a certain time
+ * until {@link Slice} is fully loaded.
+ */
+public class BlockingSlicePrefController extends SlicePreferenceController implements
+        BasePreferenceController.UiBlocker {
+
+    public BlockingSlicePrefController(Context context, String preferenceKey) {
+        super(context, preferenceKey);
+    }
+
+    @Override
+    public void onChanged(Slice slice) {
+        super.onChanged(slice);
+        if (mUiBlockListener != null) {
+            mUiBlockListener.onBlockerWorkFinished(this);
+        }
+    }
+}
diff --git a/src/com/android/settings/slices/SlicePreferenceController.java b/src/com/android/settings/slices/SlicePreferenceController.java
index 8c751c8..93ba652 100644
--- a/src/com/android/settings/slices/SlicePreferenceController.java
+++ b/src/com/android/settings/slices/SlicePreferenceController.java
@@ -51,8 +51,7 @@
     public void displayPreference(PreferenceScreen screen) {
         super.displayPreference(screen);
 
-        mSlicePreference = (SlicePreference) screen.findPreference(
-                getPreferenceKey());
+        mSlicePreference = screen.findPreference(getPreferenceKey());
     }
 
     @Override
diff --git a/tests/robotests/src/com/android/settings/bluetooth/BluetoothDeviceDetailsFragmentTest.java b/tests/robotests/src/com/android/settings/bluetooth/BluetoothDeviceDetailsFragmentTest.java
index 21d62bc..be77283 100644
--- a/tests/robotests/src/com/android/settings/bluetooth/BluetoothDeviceDetailsFragmentTest.java
+++ b/tests/robotests/src/com/android/settings/bluetooth/BluetoothDeviceDetailsFragmentTest.java
@@ -26,6 +26,8 @@
 import android.content.Context;
 import android.os.Bundle;
 
+import androidx.preference.PreferenceScreen;
+
 import com.android.settings.testutils.FakeFeatureFactory;
 import com.android.settingslib.bluetooth.CachedBluetoothDevice;
 import com.android.settingslib.bluetooth.LocalBluetoothManager;
@@ -49,9 +51,10 @@
 
     @Mock(answer = Answers.RETURNS_DEEP_STUBS)
     private CachedBluetoothDevice mCachedDevice;
-
     @Mock(answer = Answers.RETURNS_DEEP_STUBS)
     private LocalBluetoothManager mLocalManager;
+    @Mock
+    private PreferenceScreen mPreferenceScreen;
 
     @Before
     public void setUp() {
@@ -62,6 +65,7 @@
         mFragment = spy(BluetoothDeviceDetailsFragment.newInstance(TEST_ADDRESS));
         doReturn(mLocalManager).when(mFragment).getLocalBluetoothManager(any());
         doReturn(mCachedDevice).when(mFragment).getCachedDevice(any());
+        doReturn(mPreferenceScreen).when(mFragment).getPreferenceScreen();
 
         when(mCachedDevice.getAddress()).thenReturn(TEST_ADDRESS);
         Bundle args = new Bundle();
diff --git a/tests/unit/src/com/android/settings/dashboard/UiBlockerControllerTest.java b/tests/unit/src/com/android/settings/dashboard/UiBlockerControllerTest.java
new file mode 100644
index 0000000..c3a7a4e
--- /dev/null
+++ b/tests/unit/src/com/android/settings/dashboard/UiBlockerControllerTest.java
@@ -0,0 +1,69 @@
+/*
+ * Copyright (C) 2018 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.settings.dashboard;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import android.app.Instrumentation;
+
+import androidx.test.InstrumentationRegistry;
+import androidx.test.filters.MediumTest;
+import androidx.test.runner.AndroidJUnit4;
+
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+
+import java.util.Arrays;
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.TimeUnit;
+
+@RunWith(AndroidJUnit4.class)
+@MediumTest
+public class UiBlockerControllerTest {
+    private static final long TIMEOUT = 600;
+    private static final String KEY_1 = "key1";
+    private static final String KEY_2 = "key2";
+
+    private Instrumentation mInstrumentation;
+    private UiBlockerController mSyncableController;
+
+    @Before
+    public void setUp() throws Exception {
+        mInstrumentation = InstrumentationRegistry.getInstrumentation();
+
+        mSyncableController = new UiBlockerController(Arrays.asList(KEY_1, KEY_2));
+    }
+
+    @Test
+    public void start_isSyncedReturnFalseUntilAllWorkDone() throws InterruptedException {
+        final CountDownLatch latch = new CountDownLatch(1);
+        mSyncableController.start(() -> latch.countDown());
+
+        // Return false at first
+        assertThat(mSyncableController.isBlockerFinished()).isFalse();
+
+        // Return false if only one job is done
+        mSyncableController.countDown(KEY_1);
+        assertThat(mSyncableController.isBlockerFinished()).isFalse();
+
+        // Return true if all jobs done
+        mSyncableController.countDown(KEY_2);
+        assertThat(latch.await(TIMEOUT, TimeUnit.MILLISECONDS)).isTrue();
+        assertThat(mSyncableController.isBlockerFinished()).isTrue();
+    }
+}