From 297b2b99565b6e49082eaf7cb5d8ce7a5caa0bd9 Mon Sep 17 00:00:00 2001 From: tonihei Date: Thu, 16 Jan 2025 07:49:46 -0800 Subject: [PATCH] Add util to handle background state updates and placeholder states. This is a common pattern in media3 libraries where tasks are handled on a background thread, but the calling thread sees an immediate state update with a best-guess placeholder. This makes the integration for the caller very easy as the API surface appears to be synchronous. This util is a helper class to handle this logic and test it separately. PiperOrigin-RevId: 716233966 --- .../util/BackgroundThreadStateHandler.java | 172 +++++++++ .../BackgroundThreadStateHandlerTest.java | 346 ++++++++++++++++++ 2 files changed, 518 insertions(+) create mode 100644 libraries/common/src/main/java/androidx/media3/common/util/BackgroundThreadStateHandler.java create mode 100644 libraries/common/src/test/java/androidx/media3/common/util/BackgroundThreadStateHandlerTest.java diff --git a/libraries/common/src/main/java/androidx/media3/common/util/BackgroundThreadStateHandler.java b/libraries/common/src/main/java/androidx/media3/common/util/BackgroundThreadStateHandler.java new file mode 100644 index 0000000000..647e28c837 --- /dev/null +++ b/libraries/common/src/main/java/androidx/media3/common/util/BackgroundThreadStateHandler.java @@ -0,0 +1,172 @@ +/* + * Copyright 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 androidx.media3.common.util; + +import static androidx.media3.common.util.Assertions.checkState; + +import android.os.Looper; +import androidx.annotation.Nullable; +import com.google.common.base.Function; +import org.checkerframework.checker.nullness.qual.NonNull; + +/** + * Helper class to handle state updates on a background thread while maintaining a placeholder state + * on the foreground thread. + * + * @param An immutable object representing the entire state. Must implement {@link + * Object#equals(Object)}. + */ +@UnstableApi +public final class BackgroundThreadStateHandler { + + /** + * An interface to handle changes to the state on the foreground thread. + * + * @param An immutable object representing the entire state. Must implement {@link + * Object#equals(Object)}. + */ + public interface StateChangeListener { + + /** + * The state has changed. + * + *

A typical usage of this method is to inform external listeners. + * + *

This method will be called on the foreground thread. + * + * @param oldState The old state. + * @param newState The new state. + */ + void onStateChanged(T oldState, T newState); + } + + private final HandlerWrapper backgroundHandler; + private final HandlerWrapper foregroundHandler; + private final StateChangeListener onStateChanged; + + private T foregroundState; + private T backgroundState; + private int pendingOperations; + + /** + * Creates the helper for background thread state updates. + * + *

This constructor may be called on any thread. + * + * @param initialState The initial state value. + * @param backgroundLooper The {@link Looper} to run background operations on. + * @param foregroundLooper The {@link Looper} to run foreground operations on. + * @param clock The {@link Clock} to control the handler messages. + * @param onStateChanged The {@link StateChangeListener} to listen to state changes. + */ + public BackgroundThreadStateHandler( + T initialState, + Looper backgroundLooper, + Looper foregroundLooper, + Clock clock, + StateChangeListener onStateChanged) { + backgroundHandler = clock.createHandler(backgroundLooper, /* callback= */ null); + foregroundHandler = clock.createHandler(foregroundLooper, /* callback= */ null); + foregroundState = initialState; + backgroundState = initialState; + this.onStateChanged = onStateChanged; + } + + /** + * Returns the current state. + * + *

Can be called on either the foreground or background thread, returning the respective + * current state of this thread. + */ + public T get() { + @Nullable Looper myLooper = Looper.myLooper(); + if (myLooper == foregroundHandler.getLooper()) { + return foregroundState; + } + checkState(myLooper == backgroundHandler.getLooper()); + return backgroundState; + } + + /** + * Starts an asynchronous state update. + * + *

Must only be called on the foreground thread. + * + * @param placeholderState A function to create a placeholder state from the current state while + * the operation is pending. Will be called on the foreground thread. + * @param backgroundStateUpdate A function to handle the background state update, taking in the + * current background state and returning the updated state. Will be called on the background + * thread. + */ + public void updateStateAsync( + Function placeholderState, Function backgroundStateUpdate) { + checkState(Looper.myLooper() == foregroundHandler.getLooper()); + pendingOperations++; + backgroundHandler.post( + () -> { + backgroundState = backgroundStateUpdate.apply(backgroundState); + T newState = backgroundState; + foregroundHandler.post( + () -> { + if (--pendingOperations == 0) { + updateStateInForeground(newState); + } + }); + }); + updateStateInForeground(placeholderState.apply(foregroundState)); + } + + /** + * Updates the background state directly, independent to any operation started from the foreground + * thread. + * + *

Must only be called on the background thread. + * + * @param newState The new state. + */ + public void setStateInBackground(T newState) { + backgroundState = newState; + foregroundHandler.post( + () -> { + if (pendingOperations == 0) { + updateStateInForeground(newState); + } + }); + } + + /** + * Runs the provided {@link Runnable} on the background thread. + * + *

Can be called from any thread. + * + *

Note: This method is useful to update the state on the background using {@link + * #setStateInBackground} for events arriving from external sources. Use {@link #updateStateAsync} + * if the intention is update the state in response the a foreground thread method call. + * + * @param runnable The {@link Runnable} to be called on the background thread. + */ + public void runInBackground(Runnable runnable) { + backgroundHandler.post(runnable); + } + + private void updateStateInForeground(T newState) { + T oldState = foregroundState; + foregroundState = newState; + if (!oldState.equals(newState)) { + onStateChanged.onStateChanged(oldState, newState); + } + } +} diff --git a/libraries/common/src/test/java/androidx/media3/common/util/BackgroundThreadStateHandlerTest.java b/libraries/common/src/test/java/androidx/media3/common/util/BackgroundThreadStateHandlerTest.java new file mode 100644 index 0000000000..8f7a205c2e --- /dev/null +++ b/libraries/common/src/test/java/androidx/media3/common/util/BackgroundThreadStateHandlerTest.java @@ -0,0 +1,346 @@ +/* + * Copyright 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 androidx.media3.common.util; + +import static com.google.common.truth.Truth.assertThat; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoMoreInteractions; +import static org.robolectric.Shadows.shadowOf; + +import android.os.Handler; +import android.os.HandlerThread; +import android.os.Looper; +import androidx.annotation.Nullable; +import androidx.media3.common.util.BackgroundThreadStateHandler.StateChangeListener; +import androidx.test.ext.junit.runners.AndroidJUnit4; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.atomic.AtomicReference; +import org.junit.After; +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.robolectric.shadows.ShadowLooper; + +/** Unit test for {@link BackgroundThreadStateHandler}. */ +@SuppressWarnings("unchecked") // Mocks of listeners are all unchecked. +@RunWith(AndroidJUnit4.class) +public class BackgroundThreadStateHandlerTest { + + private HandlerThread backgroundThread; + + @Before + public void setUp() { + backgroundThread = new HandlerThread("BackgroundThreadStateHandlerTest"); + backgroundThread.start(); + } + + @After + public void tearDown() { + backgroundThread.quit(); + } + + @Test + public void get_afterConstructor_returnsInitialState() throws Exception { + TestState initialState = new TestState(2); + // Create handler from another thread to test promise it can be created on any thread. + AtomicReference> handler = new AtomicReference<>(); + StateChangeListener mockListener = mock(StateChangeListener.class); + Thread testThread = + new Thread("otherThread") { + @Override + public void run() { + handler.set( + new BackgroundThreadStateHandler<>( + initialState, + backgroundThread.getLooper(), + Looper.getMainLooper(), + Clock.DEFAULT, + mockListener)); + } + }; + testThread.start(); + testThread.join(); + + TestState foregroundState = handler.get().get(); + AtomicReference backgroundState = new AtomicReference<>(); + CountDownLatch waitForBackgroundState = new CountDownLatch(1); + new Handler(backgroundThread.getLooper()) + .post( + () -> { + backgroundState.set(handler.get().get()); + waitForBackgroundState.countDown(); + }); + waitForBackgroundState.await(); + + assertThat(foregroundState).isEqualTo(initialState); + assertThat(backgroundState.get()).isEqualTo(initialState); + verifyNoMoreInteractions(mockListener); + } + + @Test + public void get_immediatelyAfterUpdateStateAsync_returnsPlaceholderStateAndInformsListener() { + TestState initialState = new TestState(2); + TestState placeholderState = new TestState(3); + TestState finalState = new TestState(4); + StateChangeListener mockListener = mock(StateChangeListener.class); + BackgroundThreadStateHandler handler = + new BackgroundThreadStateHandler<>( + initialState, + backgroundThread.getLooper(), + Looper.getMainLooper(), + Clock.DEFAULT, + mockListener); + + AtomicReference placeholderArgument = new AtomicReference<>(); + handler.updateStateAsync( + /* placeholderState= */ state -> { + placeholderArgument.set(state); + return placeholderState; + }, + /* backgroundStateUpdate= */ state -> finalState); + TestState stateAfterUpdate = handler.get(); + + assertThat(stateAfterUpdate).isEqualTo(placeholderState); + assertThat(placeholderArgument.get()).isEqualTo(initialState); + verify(mockListener).onStateChanged(initialState, placeholderState); + verifyNoMoreInteractions(mockListener); + } + + @Test + public void get_afterUpdateStateAsyncWithIdenticalFinalValue_onlyCallsListenerOnce() { + TestState initialState = new TestState(2); + TestState updatedState = new TestState(3); + StateChangeListener mockListener = mock(StateChangeListener.class); + BackgroundThreadStateHandler handler = + new BackgroundThreadStateHandler<>( + initialState, + backgroundThread.getLooper(), + Looper.getMainLooper(), + Clock.DEFAULT, + mockListener); + + handler.updateStateAsync( + /* placeholderState= */ state -> updatedState, + /* backgroundStateUpdate= */ state -> updatedState); + waitForPendingTasks(); + TestState stateAfterUpdate = handler.get(); + + assertThat(stateAfterUpdate).isEqualTo(updatedState); + verify(mockListener).onStateChanged(initialState, updatedState); + verifyNoMoreInteractions(mockListener); + } + + @Test + public void get_afterUpdateStateAsyncWithDifferentFinalValue_callsListenerAgain() { + TestState initialState = new TestState(2); + TestState placeholderState = new TestState(3); + TestState finalState = new TestState(4); + StateChangeListener mockListener = mock(StateChangeListener.class); + BackgroundThreadStateHandler handler = + new BackgroundThreadStateHandler<>( + initialState, + backgroundThread.getLooper(), + Looper.getMainLooper(), + Clock.DEFAULT, + mockListener); + + AtomicReference backgroundUpdateArgument = new AtomicReference<>(); + handler.updateStateAsync( + /* placeholderState= */ state -> placeholderState, + /* backgroundStateUpdate= */ state -> { + backgroundUpdateArgument.set(state); + return finalState; + }); + waitForPendingTasks(); + TestState stateAfterUpdate = handler.get(); + + assertThat(stateAfterUpdate).isEqualTo(finalState); + verify(mockListener).onStateChanged(initialState, placeholderState); + verify(mockListener).onStateChanged(placeholderState, finalState); + verifyNoMoreInteractions(mockListener); + } + + @Test + public void get_afterMultipleOverlappingUpdateStateAsync_onlyReportsFinalState() { + TestState initialState = new TestState(2); + TestState placeholderState1 = new TestState(3); + TestState finalState1 = new TestState(4); + TestState placeholderState2 = new TestState(5); + TestState finalState2 = new TestState(6); + StateChangeListener mockListener = mock(StateChangeListener.class); + BackgroundThreadStateHandler handler = + new BackgroundThreadStateHandler<>( + initialState, + backgroundThread.getLooper(), + Looper.getMainLooper(), + Clock.DEFAULT, + mockListener); + + AtomicReference placeholderArgument1 = new AtomicReference<>(); + AtomicReference placeholderArgument2 = new AtomicReference<>(); + AtomicReference backgroundUpdateArgument1 = new AtomicReference<>(); + AtomicReference backgroundUpdateArgument2 = new AtomicReference<>(); + handler.updateStateAsync( + /* placeholderState= */ state -> { + placeholderArgument1.set(state); + return placeholderState1; + }, + /* backgroundStateUpdate= */ state -> { + backgroundUpdateArgument1.set(state); + return finalState1; + }); + handler.updateStateAsync( + /* placeholderState= */ state -> { + placeholderArgument2.set(state); + return placeholderState2; + }, + /* backgroundStateUpdate= */ state -> { + backgroundUpdateArgument2.set(state); + return finalState2; + }); + TestState stateImmediatelyAfterUpdate = handler.get(); + waitForPendingTasks(); + TestState stateAfterFinalUpdate = handler.get(); + + assertThat(stateImmediatelyAfterUpdate).isEqualTo(placeholderState2); + assertThat(stateAfterFinalUpdate).isEqualTo(finalState2); + assertThat(placeholderArgument1.get()).isEqualTo(initialState); + assertThat(placeholderArgument2.get()).isEqualTo(placeholderState1); + assertThat(backgroundUpdateArgument1.get()).isEqualTo(initialState); + assertThat(backgroundUpdateArgument2.get()).isEqualTo(finalState1); + verify(mockListener).onStateChanged(initialState, placeholderState1); + verify(mockListener).onStateChanged(placeholderState1, placeholderState2); + verify(mockListener).onStateChanged(placeholderState2, finalState2); + verifyNoMoreInteractions(mockListener); + } + + @Test + public void runInBackground_executesInBackground() throws Exception { + BackgroundThreadStateHandler handler = + new BackgroundThreadStateHandler<>( + new TestState(2), + backgroundThread.getLooper(), + Looper.getMainLooper(), + Clock.DEFAULT, + (oldState, newState) -> {}); + + // Test that calling this from another third thread is possible. + AtomicReference taskLooper = new AtomicReference<>(); + Thread testThread = + new Thread("otherThread") { + @Override + public void run() { + handler.runInBackground(() -> taskLooper.set(Looper.myLooper())); + } + }; + testThread.start(); + testThread.join(); + waitForPendingTasks(); + + assertThat(taskLooper.get()).isEqualTo(backgroundThread.getLooper()); + } + + @Test + public void setStateInBackground_updatesStateAndCallsListener() { + TestState initialState = new TestState(2); + TestState updatedState = new TestState(3); + StateChangeListener mockListener = mock(StateChangeListener.class); + BackgroundThreadStateHandler handler = + new BackgroundThreadStateHandler<>( + initialState, + backgroundThread.getLooper(), + Looper.getMainLooper(), + Clock.DEFAULT, + mockListener); + + handler.runInBackground(() -> handler.setStateInBackground(updatedState)); + waitForPendingTasks(); + TestState stateAfterUpdate = handler.get(); + + assertThat(stateAfterUpdate).isEqualTo(updatedState); + verify(mockListener).onStateChanged(initialState, updatedState); + verifyNoMoreInteractions(mockListener); + } + + @Test + public void setStateInBackground_whileAsyncUpdateInProgress_onlyUpdatesFinalState() + throws Exception { + TestState initialState = new TestState(2); + TestState updatedBackgroundState = new TestState(3); + TestState finalState = new TestState(4); + StateChangeListener mockListener = mock(StateChangeListener.class); + BackgroundThreadStateHandler handler = + new BackgroundThreadStateHandler<>( + initialState, + backgroundThread.getLooper(), + Looper.getMainLooper(), + Clock.DEFAULT, + mockListener); + + handler.runInBackground(() -> handler.setStateInBackground(updatedBackgroundState)); + AtomicReference backgroundUpdateArgument = new AtomicReference<>(); + handler.updateStateAsync( + /* placeholderState= */ state -> state, + /* backgroundStateUpdate= */ state -> { + backgroundUpdateArgument.set(state); + return finalState; + }); + waitForPendingTasks(); + TestState stateAfterUpdate = handler.get(); + + assertThat(stateAfterUpdate).isEqualTo(finalState); + assertThat(backgroundUpdateArgument.get()).isEqualTo(updatedBackgroundState); + verify(mockListener).onStateChanged(initialState, finalState); + verifyNoMoreInteractions(mockListener); + } + + private void waitForPendingTasks() { + shadowOf(backgroundThread.getLooper()).idle(); + ShadowLooper.idleMainLooper(); + } + + public static final class TestState { + + public final int value; + + public TestState(int value) { + this.value = value; + } + + @Override + public boolean equals(@Nullable Object o) { + if (this == o) { + return true; + } + if (!(o instanceof TestState)) { + return false; + } + TestState testState = (TestState) o; + return value == testState.value; + } + + @Override + public int hashCode() { + return value; + } + + @Override + public String toString() { + return Integer.toString(value); + } + } +}