diff --git a/res/xml/bluetooth_device_details_fragment.xml b/res/xml/bluetooth_device_details_fragment.xml index 90895f258fe..cf9fbf936c7 100644 --- a/res/xml/bluetooth_device_details_fragment.xml +++ b/res/xml/bluetooth_device_details_fragment.xml @@ -31,7 +31,7 @@ diff --git a/src/com/android/settings/bluetooth/BluetoothDeviceDetailsFragment.java b/src/com/android/settings/bluetooth/BluetoothDeviceDetailsFragment.java index 6ec419b6a8c..43de5a41b30 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.R; 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 @@ public class BluetoothDeviceDetailsFragment extends RestrictedDashboardFragment 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 facec4ace81..1c850099554 100644 --- a/src/com/android/settings/core/BasePreferenceController.java +++ b/src/com/android/settings/core/BasePreferenceController.java @@ -106,6 +106,7 @@ public abstract class BasePreferenceController extends AbstractPreferenceControl protected final String mPreferenceKey; + protected UiBlockListener mUiBlockListener; /** * Instantiate a controller as specified controller type and user-defined key. @@ -289,4 +290,36 @@ public abstract class BasePreferenceController extends AbstractPreferenceControl */ public void updateRawDataToIndex(List 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 19161109c82..11858a79b6c 100644 --- a/src/com/android/settings/dashboard/DashboardFragment.java +++ b/src/com/android/settings/dashboard/DashboardFragment.java @@ -56,7 +56,8 @@ import java.util.Set; */ 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> mPreferenceControllers = @@ -67,6 +68,7 @@ public abstract class DashboardFragment extends SettingsPreferenceFragment private DashboardTilePlaceholderPreferenceController mPlaceholderPreferenceController; private boolean mListeningToCategoryChange; private SummaryLoader mSummaryLoader; + private UiBlockerController mBlockerController; @Override public void onAttach(Context context) { @@ -105,6 +107,22 @@ public abstract class DashboardFragment extends SettingsPreferenceFragment for (AbstractPreferenceController controller : controllers) { addPreferenceController(controller); } + + checkUiBlocker(controllers); + } + + private void checkUiBlocker(List controllers) { + final List 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 @@ public abstract class DashboardFragment extends SettingsPreferenceFragment * 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 @@ public abstract class DashboardFragment extends SettingsPreferenceFragment 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 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 @@ public abstract class DashboardFragment extends SettingsPreferenceFragment } 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 00000000000..eeb56e6daef --- /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 mKeys; + private long mTimeoutMillis; + + public UiBlockerController(@NonNull List keys) { + this(keys, TIMEOUT_MILLIS); + } + + public UiBlockerController(@NonNull List 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 00000000000..94810c565a3 --- /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 8c751c8f609..93ba6520f3e 100644 --- a/src/com/android/settings/slices/SlicePreferenceController.java +++ b/src/com/android/settings/slices/SlicePreferenceController.java @@ -51,8 +51,7 @@ public class SlicePreferenceController extends BasePreferenceController implemen 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 21d62bc47de..be772838ef0 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 static org.mockito.Mockito.when; 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 @@ public class BluetoothDeviceDetailsFragmentTest { @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 @@ public class BluetoothDeviceDetailsFragmentTest { 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 00000000000..c3a7a4e10d0 --- /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(); + } +}