Loading core/java/android/view/ListenerGroup.java→core/java/android/util/ListenerGroup.java +100 −0 Original line number Original line Diff line number Diff line Loading @@ -14,56 +14,78 @@ * limitations under the License. * limitations under the License. */ */ package android.view; package android.util; import android.annotation.NonNull; import android.annotation.NonNull; import android.os.Handler; import android.ravenwood.annotation.RavenwoodKeepWholeClass; import java.util.ArrayList; import java.util.List; import java.util.Objects; import java.util.Objects; import java.util.concurrent.Executor; import java.util.concurrent.Executor; import java.util.function.Consumer; import java.util.function.Consumer; /** /** * A utility class to manage a list of {@link ListenerWrapper}. This class is not thread safe. * A utility class to manage a list of {@link Consumer} and {@link Executor} pairs. This class * is thread safe because all the effects are dispatched through the handler. * @param <T> the type of the value to be reported. * @param <T> the type of the value to be reported. * @hide * @hide */ */ @RavenwoodKeepWholeClass public class ListenerGroup<T> { public class ListenerGroup<T> { private final List<ListenerWrapper<T>> mListeners = new ArrayList<>(); /** * The set of listeners to be managed. All modifications should be done on {@link #mHandler}. */ private final ArrayMap<Consumer<T>, Executor> mListeners = new ArrayMap<>(); @NonNull @NonNull private T mLastValue; private T mLastValue; @NonNull private final Handler mHandler; /** /** * Constructs a {@link ListenerGroup} that will replay the last reported value whenever a new * Constructs a {@link ListenerGroup} that will replay the last reported value whenever a new * listener is registered. * listener is registered. * @param value the initial value * @param value the initial value * @param handler a handler to synchronize access to shared resources. */ */ public ListenerGroup(@NonNull T value) { public ListenerGroup(@NonNull T value, @NonNull Handler handler) { mLastValue = value; mLastValue = Objects.requireNonNull(value); mHandler = Objects.requireNonNull(handler); } } /** /** * Relays the value to all the registered {@link java.util.function.Consumer} * Relays the value to all the registered {@link java.util.function.Consumer}. The relay is * initiated on the {@link Handler} provided in the constructor and then switched to the * {@link Executor} that was registered with the {@link Consumer}. */ */ public void accept(@NonNull T value) { public void accept(@NonNull T value) { mLastValue = Objects.requireNonNull(value); Objects.requireNonNull(value); mHandler.post(() -> { mLastValue = value; for (int i = 0; i < mListeners.size(); i++) { for (int i = 0; i < mListeners.size(); i++) { mListeners.get(i).accept(value); final Consumer<T> consumer = mListeners.keyAt(i); final Executor executor = mListeners.get(consumer); executor.execute(() -> consumer.accept(value)); } } }); } } /** /** * Adds a {@link Consumer} to the group and replays the last reported value. If the * Adds a {@link Consumer} to the group and replays the last reported value. The replay is * {@link Consumer} is already present then this is a no op. * initiated from the {@link Handler} provided in the constructor and run on the * {@link Executor}. If the {@link Consumer} is already present then this is a no op. */ */ public void addListener(@NonNull Executor executor, @NonNull Consumer<T> consumer) { public void addListener(@NonNull Executor executor, @NonNull Consumer<T> consumer) { if (isConsumerPresent(consumer)) { Objects.requireNonNull(executor, "Executor must not be null."); Objects.requireNonNull(consumer, "Consumer must not be null."); mHandler.post(() -> { if (mListeners.containsKey(consumer)) { return; return; } } final ListenerWrapper<T> listenerWrapper = new ListenerWrapper<>(executor, consumer); mListeners.put(consumer, executor); mListeners.add(listenerWrapper); executor.execute(() -> consumer.accept(mLastValue)); listenerWrapper.accept(mLastValue); }); } } /** /** Loading @@ -71,29 +93,8 @@ public class ListenerGroup<T> { * is a no op. * is a no op. */ */ public void removeListener(@NonNull Consumer<T> consumer) { public void removeListener(@NonNull Consumer<T> consumer) { final int index = computeIndex(consumer); mHandler.post(() -> { if (index > -1) { mListeners.remove(consumer); mListeners.remove(index); }); } } /** * Returns {@code true} if the {@link Consumer} is present in the list, {@code false} * otherwise. */ public boolean isConsumerPresent(Consumer<T> consumer) { return computeIndex(consumer) > -1; } /** * Returns the index of the matching {@link ListenerWrapper} if present, {@code -1} otherwise. */ private int computeIndex(Consumer<T> consumer) { for (int i = 0; i < mListeners.size(); i++) { if (mListeners.get(i).isConsumerSame(consumer)) { return i; } } return -1; } } } } core/java/android/view/ListenerWrapper.javadeleted 100644 → 0 +0 −56 Original line number Original line Diff line number Diff line /* * Copyright (C) 2025 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 android.view; import android.annotation.NonNull; import java.util.Objects; import java.util.concurrent.Executor; import java.util.function.Consumer; /** * A utilty class to bundle a {@link Consumer} and an {@link Executor} * @param <T> the type of value to be reported. * @hide */ public class ListenerWrapper<T> { @NonNull private final Consumer<T> mConsumer; @NonNull private final Executor mExecutor; public ListenerWrapper(@NonNull Executor executor, @NonNull Consumer<T> consumer) { mExecutor = Objects.requireNonNull(executor); mConsumer = Objects.requireNonNull(consumer); } /** * Relays the new value to the {@link Consumer} using the {@link Executor} */ public void accept(@NonNull T value) { mExecutor.execute(() -> mConsumer.accept(value)); } /** * Returns {@code true} if the consumer matches the one provided in the constructor, * {@code false} otherwise. */ public boolean isConsumerSame(@NonNull Consumer<T> consumer) { return mConsumer.equals(consumer); } } core/java/android/view/WindowManagerGlobal.java +3 −5 Original line number Original line Diff line number Diff line Loading @@ -32,6 +32,7 @@ import android.content.res.TypedArray; import android.graphics.HardwareRenderer; import android.graphics.HardwareRenderer; import android.os.Binder; import android.os.Binder; import android.os.Build; import android.os.Build; import android.os.Handler; import android.os.IBinder; import android.os.IBinder; import android.os.Looper; import android.os.Looper; import android.os.RemoteException; import android.os.RemoteException; Loading @@ -40,6 +41,7 @@ import android.os.SystemProperties; import android.util.AndroidRuntimeException; import android.util.AndroidRuntimeException; import android.util.ArrayMap; import android.util.ArrayMap; import android.util.ArraySet; import android.util.ArraySet; import android.util.ListenerGroup; import android.util.Log; import android.util.Log; import android.util.Pair; import android.util.Pair; import android.util.SparseArray; import android.util.SparseArray; Loading Loading @@ -153,9 +155,8 @@ public final class WindowManagerGlobal { * The {@link ListenerGroup} that is associated to {@link #mViews}. * The {@link ListenerGroup} that is associated to {@link #mViews}. * @hide * @hide */ */ @GuardedBy("mLock") private final ListenerGroup<List<View>> mWindowViewsListenerGroup = private final ListenerGroup<List<View>> mWindowViewsListenerGroup = new ListenerGroup<>(new ArrayList<>()); new ListenerGroup<>(new ArrayList<>(), new Handler(Looper.getMainLooper())); @UnsupportedAppUsage @UnsupportedAppUsage private final ArrayList<ViewRootImpl> mRoots = new ArrayList<ViewRootImpl>(); private final ArrayList<ViewRootImpl> mRoots = new ArrayList<ViewRootImpl>(); @UnsupportedAppUsage @UnsupportedAppUsage Loading Loading @@ -336,9 +337,6 @@ public final class WindowManagerGlobal { public void addWindowViewsListener(@NonNull Executor executor, public void addWindowViewsListener(@NonNull Executor executor, @NonNull Consumer<List<View>> consumer) { @NonNull Consumer<List<View>> consumer) { synchronized (mLock) { synchronized (mLock) { if (mWindowViewsListenerGroup.isConsumerPresent(consumer)) { return; } mWindowViewsListenerGroup.addListener(executor, consumer); mWindowViewsListenerGroup.addListener(executor, consumer); } } } } Loading core/java/android/view/inspector/WindowInspector.java +3 −1 Original line number Original line Diff line number Diff line Loading @@ -43,7 +43,9 @@ public final class WindowInspector { /** /** * Adds a listener that is notified whenever the value of {@link #getGlobalWindowViews()} * Adds a listener that is notified whenever the value of {@link #getGlobalWindowViews()} * changes. The current value is provided immediately using the provided {@link Executor}. * changes. The current value is provided immediately using the provided {@link Executor}. The * value is initially relayed using the main thread and then posted to the {@link Executor}. * Using a direct {@link Executor} and doing heavy calculations can cause performance issues. * If this {@link Consumer} is already registered, then this method is a no op. * If this {@link Consumer} is already registered, then this method is a no op. * @see #getGlobalWindowViews() * @see #getGlobalWindowViews() */ */ Loading core/tests/utiltests/src/android/util/ListenerGroupTest.java 0 → 100644 +146 −0 Original line number Original line Diff line number Diff line /* * Copyright (C) 2025 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 android.util; import static junit.framework.Assert.assertEquals; import static junit.framework.Assert.assertTrue; import android.os.Handler; import android.os.Looper; import androidx.test.filters.SmallTest; import com.android.internal.annotations.GuardedBy; import org.junit.Before; import org.junit.Test; import java.util.ArrayList; import java.util.List; import java.util.Objects; import java.util.concurrent.CountDownLatch; import java.util.concurrent.TimeUnit; import java.util.function.Consumer; @SmallTest public class ListenerGroupTest { private Object mInitialObject = new Object(); private ListenerGroup<Object> mListenerGroup; @Before public void setUp() throws InterruptedException { mListenerGroup = new ListenerGroup<>(mInitialObject, new Handler(Looper.getMainLooper())); } @Test public void test_added_listener_gets_initial_value() { final int valueCount = 1; final ValueListener valueListener = new ValueListener(valueCount); mListenerGroup.addListener(Runnable::run, valueListener); final boolean waitCompleted = valueListener.waitForValues(); List<Object> values = valueListener.getValues(); assertTrue("waitForValues did not complete.", waitCompleted); assertEquals("Value count does not match.", valueCount, values.size()); assertEquals("First value does not match initial value", mInitialObject, valueListener.getValues().getFirst()); } @Test public void test_added_listener_gets_receives_updates() { int valueCount = 2; Object nextValue = new Object(); ValueListener valueListener = new ValueListener(2); mListenerGroup.addListener(Runnable::run, valueListener); mListenerGroup.accept(nextValue); boolean waitCompleted = valueListener.waitForValues(); List<Object> values = valueListener.getValues(); assertTrue("waitForValues did not complete.", waitCompleted); assertEquals("Value count does not match.", valueCount, values.size()); assertEquals("Next value not received", nextValue, valueListener.getValues().getLast()); } @Test public void test_removed_listener_stops_receiving_updates() { final int valueCount = 1; Object nextValue = new Object(); ValueListener valueListener = new ValueListener(valueCount); ValueListener stopListener = new ValueListener(valueCount); mListenerGroup.addListener(Runnable::run, valueListener); mListenerGroup.removeListener(valueListener); mListenerGroup.accept(nextValue); mListenerGroup.addListener(Runnable::run, stopListener); boolean waitCompleted = valueListener.waitForValues() && stopListener.waitForValues(); List<Object> values = valueListener.getValues(); assertTrue("waitForValues did not complete.", waitCompleted); assertEquals("Value count does not match.", valueCount, values.size()); assertEquals("Incorrect values received.", mInitialObject, values.getFirst()); assertEquals("stopListener must receive next value", nextValue, stopListener.getValues().getFirst()); } private static final class ValueListener implements Consumer<Object> { private final Object mLock = new Object(); @GuardedBy("mLock") private final List<Object> mValues = new ArrayList<>(); private final CountDownLatch mCountDownLatch; ValueListener(int valueCount) { mCountDownLatch = new CountDownLatch(valueCount); } @Override public void accept(Object o) { synchronized (mLock) { mValues.add(Objects.requireNonNull(o)); mCountDownLatch.countDown(); } } public boolean waitForValues() { try { return mCountDownLatch.await(16, TimeUnit.MILLISECONDS); } catch (InterruptedException e) { return false; } } public List<Object> getValues() { synchronized (mLock) { return new ArrayList<>(mValues); } } } } Loading
core/java/android/view/ListenerGroup.java→core/java/android/util/ListenerGroup.java +100 −0 Original line number Original line Diff line number Diff line Loading @@ -14,56 +14,78 @@ * limitations under the License. * limitations under the License. */ */ package android.view; package android.util; import android.annotation.NonNull; import android.annotation.NonNull; import android.os.Handler; import android.ravenwood.annotation.RavenwoodKeepWholeClass; import java.util.ArrayList; import java.util.List; import java.util.Objects; import java.util.Objects; import java.util.concurrent.Executor; import java.util.concurrent.Executor; import java.util.function.Consumer; import java.util.function.Consumer; /** /** * A utility class to manage a list of {@link ListenerWrapper}. This class is not thread safe. * A utility class to manage a list of {@link Consumer} and {@link Executor} pairs. This class * is thread safe because all the effects are dispatched through the handler. * @param <T> the type of the value to be reported. * @param <T> the type of the value to be reported. * @hide * @hide */ */ @RavenwoodKeepWholeClass public class ListenerGroup<T> { public class ListenerGroup<T> { private final List<ListenerWrapper<T>> mListeners = new ArrayList<>(); /** * The set of listeners to be managed. All modifications should be done on {@link #mHandler}. */ private final ArrayMap<Consumer<T>, Executor> mListeners = new ArrayMap<>(); @NonNull @NonNull private T mLastValue; private T mLastValue; @NonNull private final Handler mHandler; /** /** * Constructs a {@link ListenerGroup} that will replay the last reported value whenever a new * Constructs a {@link ListenerGroup} that will replay the last reported value whenever a new * listener is registered. * listener is registered. * @param value the initial value * @param value the initial value * @param handler a handler to synchronize access to shared resources. */ */ public ListenerGroup(@NonNull T value) { public ListenerGroup(@NonNull T value, @NonNull Handler handler) { mLastValue = value; mLastValue = Objects.requireNonNull(value); mHandler = Objects.requireNonNull(handler); } } /** /** * Relays the value to all the registered {@link java.util.function.Consumer} * Relays the value to all the registered {@link java.util.function.Consumer}. The relay is * initiated on the {@link Handler} provided in the constructor and then switched to the * {@link Executor} that was registered with the {@link Consumer}. */ */ public void accept(@NonNull T value) { public void accept(@NonNull T value) { mLastValue = Objects.requireNonNull(value); Objects.requireNonNull(value); mHandler.post(() -> { mLastValue = value; for (int i = 0; i < mListeners.size(); i++) { for (int i = 0; i < mListeners.size(); i++) { mListeners.get(i).accept(value); final Consumer<T> consumer = mListeners.keyAt(i); final Executor executor = mListeners.get(consumer); executor.execute(() -> consumer.accept(value)); } } }); } } /** /** * Adds a {@link Consumer} to the group and replays the last reported value. If the * Adds a {@link Consumer} to the group and replays the last reported value. The replay is * {@link Consumer} is already present then this is a no op. * initiated from the {@link Handler} provided in the constructor and run on the * {@link Executor}. If the {@link Consumer} is already present then this is a no op. */ */ public void addListener(@NonNull Executor executor, @NonNull Consumer<T> consumer) { public void addListener(@NonNull Executor executor, @NonNull Consumer<T> consumer) { if (isConsumerPresent(consumer)) { Objects.requireNonNull(executor, "Executor must not be null."); Objects.requireNonNull(consumer, "Consumer must not be null."); mHandler.post(() -> { if (mListeners.containsKey(consumer)) { return; return; } } final ListenerWrapper<T> listenerWrapper = new ListenerWrapper<>(executor, consumer); mListeners.put(consumer, executor); mListeners.add(listenerWrapper); executor.execute(() -> consumer.accept(mLastValue)); listenerWrapper.accept(mLastValue); }); } } /** /** Loading @@ -71,29 +93,8 @@ public class ListenerGroup<T> { * is a no op. * is a no op. */ */ public void removeListener(@NonNull Consumer<T> consumer) { public void removeListener(@NonNull Consumer<T> consumer) { final int index = computeIndex(consumer); mHandler.post(() -> { if (index > -1) { mListeners.remove(consumer); mListeners.remove(index); }); } } /** * Returns {@code true} if the {@link Consumer} is present in the list, {@code false} * otherwise. */ public boolean isConsumerPresent(Consumer<T> consumer) { return computeIndex(consumer) > -1; } /** * Returns the index of the matching {@link ListenerWrapper} if present, {@code -1} otherwise. */ private int computeIndex(Consumer<T> consumer) { for (int i = 0; i < mListeners.size(); i++) { if (mListeners.get(i).isConsumerSame(consumer)) { return i; } } return -1; } } } }
core/java/android/view/ListenerWrapper.javadeleted 100644 → 0 +0 −56 Original line number Original line Diff line number Diff line /* * Copyright (C) 2025 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 android.view; import android.annotation.NonNull; import java.util.Objects; import java.util.concurrent.Executor; import java.util.function.Consumer; /** * A utilty class to bundle a {@link Consumer} and an {@link Executor} * @param <T> the type of value to be reported. * @hide */ public class ListenerWrapper<T> { @NonNull private final Consumer<T> mConsumer; @NonNull private final Executor mExecutor; public ListenerWrapper(@NonNull Executor executor, @NonNull Consumer<T> consumer) { mExecutor = Objects.requireNonNull(executor); mConsumer = Objects.requireNonNull(consumer); } /** * Relays the new value to the {@link Consumer} using the {@link Executor} */ public void accept(@NonNull T value) { mExecutor.execute(() -> mConsumer.accept(value)); } /** * Returns {@code true} if the consumer matches the one provided in the constructor, * {@code false} otherwise. */ public boolean isConsumerSame(@NonNull Consumer<T> consumer) { return mConsumer.equals(consumer); } }
core/java/android/view/WindowManagerGlobal.java +3 −5 Original line number Original line Diff line number Diff line Loading @@ -32,6 +32,7 @@ import android.content.res.TypedArray; import android.graphics.HardwareRenderer; import android.graphics.HardwareRenderer; import android.os.Binder; import android.os.Binder; import android.os.Build; import android.os.Build; import android.os.Handler; import android.os.IBinder; import android.os.IBinder; import android.os.Looper; import android.os.Looper; import android.os.RemoteException; import android.os.RemoteException; Loading @@ -40,6 +41,7 @@ import android.os.SystemProperties; import android.util.AndroidRuntimeException; import android.util.AndroidRuntimeException; import android.util.ArrayMap; import android.util.ArrayMap; import android.util.ArraySet; import android.util.ArraySet; import android.util.ListenerGroup; import android.util.Log; import android.util.Log; import android.util.Pair; import android.util.Pair; import android.util.SparseArray; import android.util.SparseArray; Loading Loading @@ -153,9 +155,8 @@ public final class WindowManagerGlobal { * The {@link ListenerGroup} that is associated to {@link #mViews}. * The {@link ListenerGroup} that is associated to {@link #mViews}. * @hide * @hide */ */ @GuardedBy("mLock") private final ListenerGroup<List<View>> mWindowViewsListenerGroup = private final ListenerGroup<List<View>> mWindowViewsListenerGroup = new ListenerGroup<>(new ArrayList<>()); new ListenerGroup<>(new ArrayList<>(), new Handler(Looper.getMainLooper())); @UnsupportedAppUsage @UnsupportedAppUsage private final ArrayList<ViewRootImpl> mRoots = new ArrayList<ViewRootImpl>(); private final ArrayList<ViewRootImpl> mRoots = new ArrayList<ViewRootImpl>(); @UnsupportedAppUsage @UnsupportedAppUsage Loading Loading @@ -336,9 +337,6 @@ public final class WindowManagerGlobal { public void addWindowViewsListener(@NonNull Executor executor, public void addWindowViewsListener(@NonNull Executor executor, @NonNull Consumer<List<View>> consumer) { @NonNull Consumer<List<View>> consumer) { synchronized (mLock) { synchronized (mLock) { if (mWindowViewsListenerGroup.isConsumerPresent(consumer)) { return; } mWindowViewsListenerGroup.addListener(executor, consumer); mWindowViewsListenerGroup.addListener(executor, consumer); } } } } Loading
core/java/android/view/inspector/WindowInspector.java +3 −1 Original line number Original line Diff line number Diff line Loading @@ -43,7 +43,9 @@ public final class WindowInspector { /** /** * Adds a listener that is notified whenever the value of {@link #getGlobalWindowViews()} * Adds a listener that is notified whenever the value of {@link #getGlobalWindowViews()} * changes. The current value is provided immediately using the provided {@link Executor}. * changes. The current value is provided immediately using the provided {@link Executor}. The * value is initially relayed using the main thread and then posted to the {@link Executor}. * Using a direct {@link Executor} and doing heavy calculations can cause performance issues. * If this {@link Consumer} is already registered, then this method is a no op. * If this {@link Consumer} is already registered, then this method is a no op. * @see #getGlobalWindowViews() * @see #getGlobalWindowViews() */ */ Loading
core/tests/utiltests/src/android/util/ListenerGroupTest.java 0 → 100644 +146 −0 Original line number Original line Diff line number Diff line /* * Copyright (C) 2025 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 android.util; import static junit.framework.Assert.assertEquals; import static junit.framework.Assert.assertTrue; import android.os.Handler; import android.os.Looper; import androidx.test.filters.SmallTest; import com.android.internal.annotations.GuardedBy; import org.junit.Before; import org.junit.Test; import java.util.ArrayList; import java.util.List; import java.util.Objects; import java.util.concurrent.CountDownLatch; import java.util.concurrent.TimeUnit; import java.util.function.Consumer; @SmallTest public class ListenerGroupTest { private Object mInitialObject = new Object(); private ListenerGroup<Object> mListenerGroup; @Before public void setUp() throws InterruptedException { mListenerGroup = new ListenerGroup<>(mInitialObject, new Handler(Looper.getMainLooper())); } @Test public void test_added_listener_gets_initial_value() { final int valueCount = 1; final ValueListener valueListener = new ValueListener(valueCount); mListenerGroup.addListener(Runnable::run, valueListener); final boolean waitCompleted = valueListener.waitForValues(); List<Object> values = valueListener.getValues(); assertTrue("waitForValues did not complete.", waitCompleted); assertEquals("Value count does not match.", valueCount, values.size()); assertEquals("First value does not match initial value", mInitialObject, valueListener.getValues().getFirst()); } @Test public void test_added_listener_gets_receives_updates() { int valueCount = 2; Object nextValue = new Object(); ValueListener valueListener = new ValueListener(2); mListenerGroup.addListener(Runnable::run, valueListener); mListenerGroup.accept(nextValue); boolean waitCompleted = valueListener.waitForValues(); List<Object> values = valueListener.getValues(); assertTrue("waitForValues did not complete.", waitCompleted); assertEquals("Value count does not match.", valueCount, values.size()); assertEquals("Next value not received", nextValue, valueListener.getValues().getLast()); } @Test public void test_removed_listener_stops_receiving_updates() { final int valueCount = 1; Object nextValue = new Object(); ValueListener valueListener = new ValueListener(valueCount); ValueListener stopListener = new ValueListener(valueCount); mListenerGroup.addListener(Runnable::run, valueListener); mListenerGroup.removeListener(valueListener); mListenerGroup.accept(nextValue); mListenerGroup.addListener(Runnable::run, stopListener); boolean waitCompleted = valueListener.waitForValues() && stopListener.waitForValues(); List<Object> values = valueListener.getValues(); assertTrue("waitForValues did not complete.", waitCompleted); assertEquals("Value count does not match.", valueCount, values.size()); assertEquals("Incorrect values received.", mInitialObject, values.getFirst()); assertEquals("stopListener must receive next value", nextValue, stopListener.getValues().getFirst()); } private static final class ValueListener implements Consumer<Object> { private final Object mLock = new Object(); @GuardedBy("mLock") private final List<Object> mValues = new ArrayList<>(); private final CountDownLatch mCountDownLatch; ValueListener(int valueCount) { mCountDownLatch = new CountDownLatch(valueCount); } @Override public void accept(Object o) { synchronized (mLock) { mValues.add(Objects.requireNonNull(o)); mCountDownLatch.countDown(); } } public boolean waitForValues() { try { return mCountDownLatch.await(16, TimeUnit.MILLISECONDS); } catch (InterruptedException e) { return false; } } public List<Object> getValues() { synchronized (mLock) { return new ArrayList<>(mValues); } } } }