Added support to animate transitions for menu radii
When dragged by the user, FAB has rounded edges on all sides.
When attached to the side of the screen, that side of the menu flattens to lie uniform.
This aims to smooth out the transition.
Adds a RadiiAnimator to use a single ValueAnimator to drive 8 radii.
Bug: 281140482
Test: atest RadiiAnimatorTest
Flag: ACONFIG com.android.systemui.Flags.floating_menu_radii_animation ENABLED
Change-Id: Ia77bc87905f1d863be713f7e51fc91988819bea8
diff --git a/packages/SystemUI/aconfig/accessibility.aconfig b/packages/SystemUI/aconfig/accessibility.aconfig
index 25ac486..9e06872 100644
--- a/packages/SystemUI/aconfig/accessibility.aconfig
+++ b/packages/SystemUI/aconfig/accessibility.aconfig
@@ -21,4 +21,11 @@
namespace: "accessibility"
description: "Adjusts bounds to allow the floating menu to render on top of navigation bars."
bug: "283768342"
+}
+
+flag {
+ name: "floating_menu_radii_animation"
+ namespace: "accessibility"
+ description: "Animates the floating menu's transition between curved and jagged edges."
+ bug: "281140482"
}
\ No newline at end of file
diff --git a/packages/SystemUI/src/com/android/systemui/accessibility/floatingmenu/IRadiiAnimationListener.java b/packages/SystemUI/src/com/android/systemui/accessibility/floatingmenu/IRadiiAnimationListener.java
new file mode 100644
index 0000000..72935f7
--- /dev/null
+++ b/packages/SystemUI/src/com/android/systemui/accessibility/floatingmenu/IRadiiAnimationListener.java
@@ -0,0 +1,21 @@
+/*
+ * Copyright (C) 2023 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.systemui.accessibility.floatingmenu;
+
+interface IRadiiAnimationListener {
+ void onRadiiAnimationUpdate(float[] radii);
+}
diff --git a/packages/SystemUI/src/com/android/systemui/accessibility/floatingmenu/MenuAnimationController.java b/packages/SystemUI/src/com/android/systemui/accessibility/floatingmenu/MenuAnimationController.java
index ceddee8..761551c 100644
--- a/packages/SystemUI/src/com/android/systemui/accessibility/floatingmenu/MenuAnimationController.java
+++ b/packages/SystemUI/src/com/android/systemui/accessibility/floatingmenu/MenuAnimationController.java
@@ -69,6 +69,7 @@
private static final int FADE_EFFECT_DURATION_MS = 3000;
private final MenuView mMenuView;
+ private final MenuViewAppearance mMenuViewAppearance;
private final ValueAnimator mFadeOutAnimator;
private final Handler mHandler;
private boolean mIsFadeEffectEnabled;
@@ -81,14 +82,19 @@
final HashMap<DynamicAnimation.ViewProperty, DynamicAnimation> mPositionAnimations =
new HashMap<>();
- MenuAnimationController(MenuView menuView) {
+ @VisibleForTesting
+ final RadiiAnimator mRadiiAnimator;
+
+ MenuAnimationController(MenuView menuView, MenuViewAppearance menuViewAppearance) {
mMenuView = menuView;
+ mMenuViewAppearance = menuViewAppearance;
mHandler = createUiHandler();
mFadeOutAnimator = new ValueAnimator();
mFadeOutAnimator.setDuration(FADE_OUT_DURATION_MS);
mFadeOutAnimator.addUpdateListener(
(animation) -> menuView.setAlpha((float) animation.getAnimatedValue()));
+ mRadiiAnimator = new RadiiAnimator(mMenuViewAppearance.getMenuRadii(), mMenuView::setRadii);
}
void moveToPosition(PointF position) {
@@ -427,6 +433,10 @@
.start();
}
+ void startRadiiAnimation(float[] endRadii) {
+ mRadiiAnimator.startAnimation(endRadii);
+ }
+
private void onSpringAnimationsEnd(PointF position, boolean writeToPosition) {
mMenuView.onBoundsInParentChanged((int) position.x, (int) position.y);
constrainPositionAndUpdate(position, writeToPosition);
diff --git a/packages/SystemUI/src/com/android/systemui/accessibility/floatingmenu/MenuView.java b/packages/SystemUI/src/com/android/systemui/accessibility/floatingmenu/MenuView.java
index 92c7259..76808cb 100644
--- a/packages/SystemUI/src/com/android/systemui/accessibility/floatingmenu/MenuView.java
+++ b/packages/SystemUI/src/com/android/systemui/accessibility/floatingmenu/MenuView.java
@@ -78,7 +78,7 @@
mMenuViewModel = menuViewModel;
mMenuViewAppearance = menuViewAppearance;
- mMenuAnimationController = new MenuAnimationController(this);
+ mMenuAnimationController = new MenuAnimationController(this, menuViewAppearance);
mAdapter = new AccessibilityTargetAdapter(mTargetFeatures);
mTargetFeaturesView = new RecyclerView(context);
mTargetFeaturesView.setAdapter(mAdapter);
@@ -184,9 +184,17 @@
insets[3]);
final GradientDrawable gradientDrawable = getContainerViewGradient();
- gradientDrawable.setCornerRadii(mMenuViewAppearance.getMenuRadii());
gradientDrawable.setStroke(mMenuViewAppearance.getMenuStrokeWidth(),
mMenuViewAppearance.getMenuStrokeColor());
+ if (Flags.floatingMenuRadiiAnimation()) {
+ mMenuAnimationController.startRadiiAnimation(mMenuViewAppearance.getMenuRadii());
+ } else {
+ gradientDrawable.setCornerRadii(mMenuViewAppearance.getMenuRadii());
+ }
+ }
+
+ void setRadii(float[] radii) {
+ getContainerViewGradient().setCornerRadii(radii);
}
private void onMoveToTucked(boolean isMoveToTucked) {
@@ -391,8 +399,13 @@
getContainerViewInsetLayer().setLayerInset(INDEX_MENU_ITEM, insets[0], insets[1], insets[2],
insets[3]);
- final GradientDrawable gradientDrawable = getContainerViewGradient();
- gradientDrawable.setCornerRadii(mMenuViewAppearance.getMenuMovingStateRadii());
+ if (Flags.floatingMenuRadiiAnimation()) {
+ mMenuAnimationController.startRadiiAnimation(
+ mMenuViewAppearance.getMenuMovingStateRadii());
+ } else {
+ final GradientDrawable gradientDrawable = getContainerViewGradient();
+ gradientDrawable.setCornerRadii(mMenuViewAppearance.getMenuMovingStateRadii());
+ }
}
void onBoundsInParentChanged(int newLeft, int newTop) {
diff --git a/packages/SystemUI/src/com/android/systemui/accessibility/floatingmenu/RadiiAnimator.java b/packages/SystemUI/src/com/android/systemui/accessibility/floatingmenu/RadiiAnimator.java
new file mode 100644
index 0000000..acad36e
--- /dev/null
+++ b/packages/SystemUI/src/com/android/systemui/accessibility/floatingmenu/RadiiAnimator.java
@@ -0,0 +1,104 @@
+/*
+ * Copyright (C) 2023 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.systemui.accessibility.floatingmenu;
+
+import android.animation.Animator;
+import android.animation.ValueAnimator;
+import android.util.MathUtils;
+
+import androidx.annotation.NonNull;
+
+import com.android.internal.annotations.VisibleForTesting;
+
+import java.util.Arrays;
+
+/**
+ * Manages the animation of the floating menu's radii.
+ * <p>
+ * There are 8 output values total. There are 4 corners,
+ * and each corner has a value for the x and y axes.
+ */
+class RadiiAnimator {
+ static final int RADII_COUNT = 8;
+
+ private float[] mStartValues;
+ private float[] mEndValues;
+ private final ValueAnimator mAnimationDriver = ValueAnimator.ofFloat(0.0f, 1.0f);
+
+ RadiiAnimator(float[] initialValues, IRadiiAnimationListener animationListener) {
+ if (initialValues.length != RADII_COUNT) {
+ initialValues = Arrays.copyOf(initialValues, RADII_COUNT);
+ }
+
+ mStartValues = initialValues;
+ mEndValues = initialValues;
+
+ mAnimationDriver.setRepeatCount(0);
+ mAnimationDriver.addUpdateListener(
+ animation -> animationListener.onRadiiAnimationUpdate(
+ evaluate(animation.getAnimatedFraction())));
+ mAnimationDriver.addListener(new Animator.AnimatorListener() {
+ @Override
+ public void onAnimationStart(@NonNull Animator animation) {
+ animationListener.onRadiiAnimationUpdate(evaluate(/* t = */ 0.0f));
+ }
+
+ @Override
+ public void onAnimationEnd(@NonNull Animator animation) {}
+
+ @Override
+ public void onAnimationCancel(@NonNull Animator animation) {
+ animationListener.onRadiiAnimationUpdate(
+ evaluate(mAnimationDriver.getAnimatedFraction()));
+ }
+
+ @Override
+ public void onAnimationRepeat(@NonNull Animator animation) {}
+ });
+ mAnimationDriver.setInterpolator(new android.view.animation.BounceInterpolator());
+ }
+
+ void startAnimation(float[] endValues) {
+ if (mAnimationDriver.isStarted()) {
+ mAnimationDriver.cancel();
+ mStartValues = evaluate(mAnimationDriver.getAnimatedFraction());
+ } else {
+ mStartValues = mEndValues;
+ }
+ mEndValues = endValues;
+
+ mAnimationDriver.start();
+ }
+
+ void skipAnimationToEnd() {
+ mAnimationDriver.end();
+ }
+
+ @VisibleForTesting
+ float[] evaluate(float time /* interpolator value between 0.0 and 1.0 */) {
+ float[] out = new float[8];
+ for (int i = 0; i < RADII_COUNT; i++) {
+ out[i] = MathUtils.lerp(mStartValues[i], mEndValues[i], time);
+ }
+ return out;
+ }
+
+ boolean isStarted() {
+ return mAnimationDriver.isStarted();
+ }
+}
+
diff --git a/packages/SystemUI/tests/src/com/android/systemui/accessibility/floatingmenu/MenuAnimationControllerTest.java b/packages/SystemUI/tests/src/com/android/systemui/accessibility/floatingmenu/MenuAnimationControllerTest.java
index 834dccb..2a1cfd1 100644
--- a/packages/SystemUI/tests/src/com/android/systemui/accessibility/floatingmenu/MenuAnimationControllerTest.java
+++ b/packages/SystemUI/tests/src/com/android/systemui/accessibility/floatingmenu/MenuAnimationControllerTest.java
@@ -93,7 +93,8 @@
mViewPropertyAnimator = spy(mMenuView.animate());
doReturn(mViewPropertyAnimator).when(mMenuView).animate();
- mMenuAnimationController = new TestMenuAnimationController(mMenuView);
+ mMenuAnimationController = new TestMenuAnimationController(
+ mMenuView, stubMenuViewAppearance);
mLastIsMoveToTucked = Prefs.getBoolean(mContext,
Prefs.Key.HAS_ACCESSIBILITY_FLOATING_MENU_TUCKED, /* defaultValue= */ false);
mEndListenerCaptor = ArgumentCaptor.forClass(DynamicAnimation.OnAnimationEndListener.class);
@@ -277,8 +278,8 @@
* Wrapper class for testing.
*/
private static class TestMenuAnimationController extends MenuAnimationController {
- TestMenuAnimationController(MenuView menuView) {
- super(menuView);
+ TestMenuAnimationController(MenuView menuView, MenuViewAppearance menuViewAppearance) {
+ super(menuView, menuViewAppearance);
}
@Override
diff --git a/packages/SystemUI/tests/src/com/android/systemui/accessibility/floatingmenu/MenuItemAccessibilityDelegateTest.java b/packages/SystemUI/tests/src/com/android/systemui/accessibility/floatingmenu/MenuItemAccessibilityDelegateTest.java
index 4ac18d0..7f12c05 100644
--- a/packages/SystemUI/tests/src/com/android/systemui/accessibility/floatingmenu/MenuItemAccessibilityDelegateTest.java
+++ b/packages/SystemUI/tests/src/com/android/systemui/accessibility/floatingmenu/MenuItemAccessibilityDelegateTest.java
@@ -85,7 +85,8 @@
doReturn(mDraggableBounds).when(mMenuView).getMenuDraggableBounds();
mStubListView = new RecyclerView(mContext);
- mMenuAnimationController = spy(new MenuAnimationController(mMenuView));
+ mMenuAnimationController = spy(new MenuAnimationController(mMenuView,
+ stubMenuViewAppearance));
mMenuItemAccessibilityDelegate =
new MenuItemAccessibilityDelegate(new RecyclerViewAccessibilityDelegate(
mStubListView), mMenuAnimationController);
diff --git a/packages/SystemUI/tests/src/com/android/systemui/accessibility/floatingmenu/MenuListViewTouchHandlerTest.java b/packages/SystemUI/tests/src/com/android/systemui/accessibility/floatingmenu/MenuListViewTouchHandlerTest.java
index f0a497d..9797f2a 100644
--- a/packages/SystemUI/tests/src/com/android/systemui/accessibility/floatingmenu/MenuListViewTouchHandlerTest.java
+++ b/packages/SystemUI/tests/src/com/android/systemui/accessibility/floatingmenu/MenuListViewTouchHandlerTest.java
@@ -88,7 +88,8 @@
mStubMenuView = new MenuView(mContext, stubMenuViewModel, stubMenuViewAppearance);
mStubMenuView.setTranslationX(0);
mStubMenuView.setTranslationY(0);
- mMenuAnimationController = spy(new MenuAnimationController(mStubMenuView));
+ mMenuAnimationController = spy(new MenuAnimationController(
+ mStubMenuView, stubMenuViewAppearance));
mDismissView = spy(new DismissView(mContext));
DismissViewUtils.setup(mDismissView);
mDismissAnimationController =
diff --git a/packages/SystemUI/tests/src/com/android/systemui/accessibility/floatingmenu/MenuViewTest.java b/packages/SystemUI/tests/src/com/android/systemui/accessibility/floatingmenu/MenuViewTest.java
index 5cd0fd0..8f0a97c 100644
--- a/packages/SystemUI/tests/src/com/android/systemui/accessibility/floatingmenu/MenuViewTest.java
+++ b/packages/SystemUI/tests/src/com/android/systemui/accessibility/floatingmenu/MenuViewTest.java
@@ -27,6 +27,9 @@
import android.app.UiModeManager;
import android.graphics.Rect;
import android.graphics.drawable.GradientDrawable;
+import android.platform.test.annotations.RequiresFlagsEnabled;
+import android.platform.test.flag.junit.CheckFlagsRule;
+import android.platform.test.flag.junit.DeviceFlagsValueProvider;
import android.testing.AndroidTestingRunner;
import android.testing.TestableLooper;
import android.view.WindowManager;
@@ -34,6 +37,7 @@
import androidx.test.filters.SmallTest;
+import com.android.systemui.Flags;
import com.android.systemui.Prefs;
import com.android.systemui.SysuiTestCase;
import com.android.systemui.util.settings.SecureSettings;
@@ -62,6 +66,10 @@
@Rule
public MockitoRule mockito = MockitoJUnit.rule();
+ @Rule
+ public final CheckFlagsRule mCheckFlagsRule =
+ DeviceFlagsValueProvider.createCheckFlagsRule();
+
@Mock
private AccessibilityManager mAccessibilityManager;
@@ -138,6 +146,22 @@
assertThat(radii[7]).isEqualTo(0.0f);
}
+ @Test
+ @RequiresFlagsEnabled(Flags.FLAG_FLOATING_MENU_RADII_ANIMATION)
+ public void onEdgeChanged_startsRadiiAnimation() {
+ final RadiiAnimator radiiAnimator = getRadiiAnimator();
+ mMenuView.onEdgeChanged();
+ assertThat(radiiAnimator.isStarted()).isTrue();
+ }
+
+ @Test
+ @RequiresFlagsEnabled(Flags.FLAG_FLOATING_MENU_RADII_ANIMATION)
+ public void onDraggingStart_startsRadiiAnimation() {
+ final RadiiAnimator radiiAnimator = getRadiiAnimator();
+ mMenuView.onDraggingStart();
+ assertThat(radiiAnimator.isStarted()).isTrue();
+ }
+
private InstantInsetLayerDrawable getMenuViewInsetLayer() {
return (InstantInsetLayerDrawable) mMenuView.getBackground();
}
@@ -146,6 +170,15 @@
return (GradientDrawable) getMenuViewInsetLayer().getDrawable(INDEX_MENU_ITEM);
}
+ private RadiiAnimator getRadiiAnimator() {
+ final RadiiAnimator radiiAnimator = mMenuView.getMenuAnimationController().mRadiiAnimator;
+ if (radiiAnimator.isStarted()) {
+ radiiAnimator.skipAnimationToEnd();
+ }
+ assertThat(radiiAnimator.isStarted()).isFalse();
+ return radiiAnimator;
+ }
+
@After
public void tearDown() throws Exception {
mUiModeManager.setNightMode(mNightMode);
diff --git a/packages/SystemUI/tests/src/com/android/systemui/accessibility/floatingmenu/RadiiAnimatorTest.java b/packages/SystemUI/tests/src/com/android/systemui/accessibility/floatingmenu/RadiiAnimatorTest.java
new file mode 100644
index 0000000..e3a2c59
--- /dev/null
+++ b/packages/SystemUI/tests/src/com/android/systemui/accessibility/floatingmenu/RadiiAnimatorTest.java
@@ -0,0 +1,102 @@
+/*
+ * Copyright (C) 2023 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.systemui.accessibility.floatingmenu;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import android.os.Handler;
+import android.os.Looper;
+import android.testing.AndroidTestingRunner;
+
+import androidx.test.filters.SmallTest;
+
+import com.android.systemui.SysuiTestCase;
+import com.android.systemui.accessibility.utils.TestUtils;
+
+import org.junit.Test;
+import org.junit.runner.RunWith;
+
+import java.util.Arrays;
+
+/** Tests for {@link RadiiAnimator}. */
+@SmallTest
+@RunWith(AndroidTestingRunner.class)
+public class RadiiAnimatorTest extends SysuiTestCase {
+ float[] mResultRadii = new float[RadiiAnimator.RADII_COUNT];
+
+ @Test
+ public void constructor() {
+ final float[] radii = generateRadii(0.0f);
+ final RadiiAnimator radiiAnimator = new RadiiAnimator(radii, newRadii -> {});
+
+ assertThat(radiiAnimator.evaluate(0.0f)).isEqualTo(radii);
+ }
+
+ @Test
+ public void skip_updates_to_end() {
+ final float[] startRadii = generateRadii(0.0f);
+ final float[] endRadii = generateRadii(1.0f);
+
+ final RadiiAnimator radiiAnimator = setupAnimator(startRadii);
+
+ new Handler(Looper.getMainLooper()).post(() -> radiiAnimator.startAnimation(endRadii));
+ TestUtils.waitForCondition(radiiAnimator::isStarted, "Animation did not start.");
+ TestUtils.waitForCondition(() -> Arrays.equals(radiiAnimator.evaluate(0.0f), startRadii)
+ && Arrays.equals(radiiAnimator.evaluate(1.0f), endRadii),
+ "Animator did not initialize to start and end values");
+
+ new Handler(Looper.getMainLooper()).post(radiiAnimator::skipAnimationToEnd);
+ TestUtils.waitForCondition(
+ () -> !radiiAnimator.isStarted(), "Animation did not end.");
+ assertThat(mResultRadii).usingTolerance(0.001).containsExactly(endRadii);
+ }
+
+ @Test
+ public void animation_can_repeat() {
+ final float[] startRadii = generateRadii(0.0f);
+ final float[] midRadii = generateRadii(1.0f);
+ final float[] endRadii = generateRadii(2.0f);
+
+ final RadiiAnimator radiiAnimator = setupAnimator(startRadii);
+
+ playAndSkipAnimation(radiiAnimator, midRadii);
+ assertThat(mResultRadii).usingTolerance(0.001).containsExactly(midRadii);
+
+ playAndSkipAnimation(radiiAnimator, endRadii);
+ assertThat(mResultRadii).usingTolerance(0.001).containsExactly(endRadii);
+ }
+
+ private float[] generateRadii(float value) {
+ float[] radii = new float[8];
+ Arrays.fill(radii, value);
+ return radii;
+ }
+
+ private RadiiAnimator setupAnimator(float[] startRadii) {
+ mResultRadii = new float[RadiiAnimator.RADII_COUNT];
+ return new RadiiAnimator(startRadii,
+ newRadii -> mResultRadii = newRadii);
+ }
+
+ private void playAndSkipAnimation(RadiiAnimator animator, float[] endRadii) {
+ new Handler(Looper.getMainLooper()).post(() -> animator.startAnimation(endRadii));
+ TestUtils.waitForCondition(animator::isStarted, "Animation did not start.");
+ new Handler(Looper.getMainLooper()).post(animator::skipAnimationToEnd);
+ TestUtils.waitForCondition(
+ () -> !animator.isStarted(), "Animation did not end.");
+ }
+}
diff --git a/packages/SystemUI/tests/src/com/android/systemui/accessibility/utils/TestUtils.java b/packages/SystemUI/tests/src/com/android/systemui/accessibility/utils/TestUtils.java
new file mode 100644
index 0000000..10c8caa
--- /dev/null
+++ b/packages/SystemUI/tests/src/com/android/systemui/accessibility/utils/TestUtils.java
@@ -0,0 +1,58 @@
+/*
+ * Copyright (C) 2023 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.systemui.accessibility.utils;
+
+import android.os.SystemClock;
+
+import java.util.function.BooleanSupplier;
+
+public class TestUtils {
+ public static long DEFAULT_CONDITION_DURATION = 5_000;
+
+ /**
+ * Waits an amount of time specified by {@link TestUtils#DEFAULT_CONDITION_DURATION}
+ * for a condition to become true.
+ * On failure, throws a {@link RuntimeException} with a custom message.
+ *
+ * @param c Condition which must return true to proceed.
+ * @param message Message to print on failure.
+ */
+ public static void waitForCondition(BooleanSupplier condition, String message) {
+ waitForCondition(condition, message, DEFAULT_CONDITION_DURATION);
+ }
+
+ /**
+ * Waits up to a specified amount of time for a condition to become true.
+ * On failure, throws a {@link RuntimeException} with a custom message.
+ *
+ * @param c Condition which must return true to proceed.
+ * @param message Message to print on failure.
+ * @param duration Amount of time permitted to wait.
+ */
+ public static void waitForCondition(BooleanSupplier condition, String message, long duration) {
+ long deadline = SystemClock.uptimeMillis() + duration;
+ long sleepMs = 50;
+ while (!condition.getAsBoolean()) {
+ if (SystemClock.uptimeMillis() > deadline) {
+ throw new RuntimeException(message);
+ }
+ // Reduce frequency of checks as more checks occur
+ sleepMs *= 2;
+ SystemClock.sleep(sleepMs);
+ }
+ }
+}