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();
+ }
+}