diff options
| author | 2024-08-20 13:59:21 -0700 | |
|---|---|---|
| committer | 2024-08-29 14:25:55 -0700 | |
| commit | 8de0b9079f9fc905c01901192afd4be7eded0df4 (patch) | |
| tree | 2cdf8bfc1bc34aebff3ab51a62ee35adcf843132 /libs | |
| parent | 88f1c7e682fe8c15d3c003faf2433ad2a764b7e8 (diff) | |
Move RegionSamplingHelper into WMShell shared
This is used by launcher for gesture nav & bubble bar handle. I wanna
use it for the handle on the bubble bar expanded view, which is in
WMShell. I don't think shell can depend on sysui shared lib since
that lib already depends on shell, so I'm moving this into shell.
Flag: EXEMPT moving a class from sysui to shell
Test: atest RegionSamplingHelperTest
Bug: 353160491
Change-Id: I994cb0f4103ebb9a88262069851aecfd2403d441
Diffstat (limited to 'libs')
3 files changed, 519 insertions, 0 deletions
diff --git a/libs/WindowManager/Shell/shared/src/com/android/wm/shell/shared/handles/RegionSamplingHelper.java b/libs/WindowManager/Shell/shared/src/com/android/wm/shell/shared/handles/RegionSamplingHelper.java new file mode 100644 index 000000000000..b92b8ef657a3 --- /dev/null +++ b/libs/WindowManager/Shell/shared/src/com/android/wm/shell/shared/handles/RegionSamplingHelper.java @@ -0,0 +1,359 @@ +/* + * Copyright (C) 2020 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.wm.shell.shared.handles; + +import static android.view.Display.DEFAULT_DISPLAY; + +import android.annotation.TargetApi; +import android.graphics.Rect; +import android.os.Build; +import android.os.Handler; +import android.view.CompositionSamplingListener; +import android.view.SurfaceControl; +import android.view.View; +import android.view.ViewRootImpl; +import android.view.ViewTreeObserver; + +import androidx.annotation.VisibleForTesting; + +import java.io.PrintWriter; +import java.util.concurrent.Executor; + +/** + * A helper class to sample regions on the screen and inspect its luminosity. + */ +@TargetApi(Build.VERSION_CODES.Q) +public class RegionSamplingHelper implements View.OnAttachStateChangeListener, + View.OnLayoutChangeListener { + + // Luminance threshold to determine black/white contrast for the navigation affordances. + // Passing the threshold of this luminance value will make the button black otherwise white + private static final float NAVIGATION_LUMINANCE_THRESHOLD = 0.5f; + // Luminance change threshold that allows applying new value if difference was exceeded + private static final float NAVIGATION_LUMINANCE_CHANGE_THRESHOLD = 0.05f; + + private final Handler mHandler = new Handler(); + private final View mSampledView; + + private final CompositionSamplingListener mSamplingListener; + + /** + * The requested sampling bounds that we want to sample from + */ + private final Rect mSamplingRequestBounds = new Rect(); + + /** + * The sampling bounds that are currently registered. + */ + private final Rect mRegisteredSamplingBounds = new Rect(); + private final SamplingCallback mCallback; + private final Executor mBackgroundExecutor; + private final SysuiCompositionSamplingListener mCompositionSamplingListener; + private boolean mSamplingEnabled = false; + private boolean mSamplingListenerRegistered = false; + + private float mLastMedianLuma; + private float mCurrentMedianLuma; + private boolean mWaitingOnDraw; + private boolean mIsDestroyed; + + private boolean mFirstSamplingAfterStart; + private boolean mWindowVisible; + private boolean mWindowHasBlurs; + private SurfaceControl mRegisteredStopLayer = null; + // A copy of mRegisteredStopLayer where we own the life cycle and can access from a bg thread. + private SurfaceControl mWrappedStopLayer = null; + private ViewTreeObserver.OnDrawListener mUpdateOnDraw = new ViewTreeObserver.OnDrawListener() { + @Override + public void onDraw() { + // We need to post the remove runnable, since it's not allowed to remove in onDraw + mHandler.post(mRemoveDrawRunnable); + RegionSamplingHelper.this.onDraw(); + } + }; + private Runnable mRemoveDrawRunnable = new Runnable() { + @Override + public void run() { + mSampledView.getViewTreeObserver().removeOnDrawListener(mUpdateOnDraw); + } + }; + + /** + * @deprecated Pass a main executor. + */ + public RegionSamplingHelper(View sampledView, SamplingCallback samplingCallback, + Executor backgroundExecutor) { + this(sampledView, samplingCallback, sampledView.getContext().getMainExecutor(), + backgroundExecutor); + } + + public RegionSamplingHelper(View sampledView, SamplingCallback samplingCallback, + Executor mainExecutor, Executor backgroundExecutor) { + this(sampledView, samplingCallback, mainExecutor, + backgroundExecutor, new SysuiCompositionSamplingListener()); + } + + @VisibleForTesting + RegionSamplingHelper(View sampledView, SamplingCallback samplingCallback, + Executor mainExecutor, Executor backgroundExecutor, + SysuiCompositionSamplingListener compositionSamplingListener) { + mBackgroundExecutor = backgroundExecutor; + mCompositionSamplingListener = compositionSamplingListener; + mSamplingListener = new CompositionSamplingListener(mainExecutor) { + @Override + public void onSampleCollected(float medianLuma) { + if (mSamplingEnabled) { + updateMedianLuma(medianLuma); + } + } + }; + mSampledView = sampledView; + mSampledView.addOnAttachStateChangeListener(this); + mSampledView.addOnLayoutChangeListener(this); + + mCallback = samplingCallback; + } + + /** + * Make callback accessible + */ + @VisibleForTesting + public SamplingCallback getCallback() { + return mCallback; + } + + private void onDraw() { + if (mWaitingOnDraw) { + mWaitingOnDraw = false; + updateSamplingListener(); + } + } + + public void start(Rect initialSamplingBounds) { + if (!mCallback.isSamplingEnabled()) { + return; + } + if (initialSamplingBounds != null) { + mSamplingRequestBounds.set(initialSamplingBounds); + } + mSamplingEnabled = true; + // make sure we notify once + mLastMedianLuma = -1; + mFirstSamplingAfterStart = true; + updateSamplingListener(); + } + + public void stop() { + mSamplingEnabled = false; + updateSamplingListener(); + } + + public void stopAndDestroy() { + stop(); + mBackgroundExecutor.execute(mSamplingListener::destroy); + mIsDestroyed = true; + } + + @Override + public void onViewAttachedToWindow(View view) { + updateSamplingListener(); + } + + @Override + public void onViewDetachedFromWindow(View view) { + stopAndDestroy(); + } + + @Override + public void onLayoutChange(View v, int left, int top, int right, int bottom, + int oldLeft, int oldTop, int oldRight, int oldBottom) { + updateSamplingRect(); + } + + private void updateSamplingListener() { + boolean isSamplingEnabled = mSamplingEnabled + && !mSamplingRequestBounds.isEmpty() + && mWindowVisible + && !mWindowHasBlurs + && (mSampledView.isAttachedToWindow() || mFirstSamplingAfterStart); + if (isSamplingEnabled) { + ViewRootImpl viewRootImpl = mSampledView.getViewRootImpl(); + SurfaceControl stopLayerControl = null; + if (viewRootImpl != null) { + stopLayerControl = viewRootImpl.getSurfaceControl(); + } + if (stopLayerControl == null || !stopLayerControl.isValid()) { + if (!mWaitingOnDraw) { + mWaitingOnDraw = true; + // The view might be attached but we haven't drawn yet, so wait until the + // next draw to update the listener again with the stop layer, such that our + // own drawing doesn't affect the sampling. + if (mHandler.hasCallbacks(mRemoveDrawRunnable)) { + mHandler.removeCallbacks(mRemoveDrawRunnable); + } else { + mSampledView.getViewTreeObserver().addOnDrawListener(mUpdateOnDraw); + } + } + // If there's no valid surface, let's just sample without a stop layer, so we + // don't have to delay + stopLayerControl = null; + } + if (!mSamplingRequestBounds.equals(mRegisteredSamplingBounds) + || mRegisteredStopLayer != stopLayerControl) { + // We only want to re-register if something actually changed + unregisterSamplingListener(); + mSamplingListenerRegistered = true; + SurfaceControl wrappedStopLayer = wrap(stopLayerControl); + + // pass this to background thread to avoid empty Rect race condition + final Rect boundsCopy = new Rect(mSamplingRequestBounds); + + mBackgroundExecutor.execute(() -> { + if (wrappedStopLayer != null && !wrappedStopLayer.isValid()) { + return; + } + mCompositionSamplingListener.register(mSamplingListener, DEFAULT_DISPLAY, + wrappedStopLayer, boundsCopy); + }); + mRegisteredSamplingBounds.set(mSamplingRequestBounds); + mRegisteredStopLayer = stopLayerControl; + mWrappedStopLayer = wrappedStopLayer; + } + mFirstSamplingAfterStart = false; + } else { + unregisterSamplingListener(); + } + } + + @VisibleForTesting + protected SurfaceControl wrap(SurfaceControl stopLayerControl) { + return stopLayerControl == null ? null : new SurfaceControl(stopLayerControl, + "regionSampling"); + } + + private void unregisterSamplingListener() { + if (mSamplingListenerRegistered) { + mSamplingListenerRegistered = false; + SurfaceControl wrappedStopLayer = mWrappedStopLayer; + mRegisteredStopLayer = null; + mWrappedStopLayer = null; + mRegisteredSamplingBounds.setEmpty(); + mBackgroundExecutor.execute(() -> { + mCompositionSamplingListener.unregister(mSamplingListener); + if (wrappedStopLayer != null && wrappedStopLayer.isValid()) { + wrappedStopLayer.release(); + } + }); + } + } + + private void updateMedianLuma(float medianLuma) { + mCurrentMedianLuma = medianLuma; + + // If the difference between the new luma and the current luma is larger than threshold + // then apply the current luma, this is to prevent small changes causing colors to flicker + if (Math.abs(mCurrentMedianLuma - mLastMedianLuma) + > NAVIGATION_LUMINANCE_CHANGE_THRESHOLD) { + mCallback.onRegionDarknessChanged( + medianLuma < NAVIGATION_LUMINANCE_THRESHOLD /* isRegionDark */); + mLastMedianLuma = medianLuma; + } + } + + public void updateSamplingRect() { + Rect sampledRegion = mCallback.getSampledRegion(mSampledView); + if (!mSamplingRequestBounds.equals(sampledRegion)) { + mSamplingRequestBounds.set(sampledRegion); + updateSamplingListener(); + } + } + + public void setWindowVisible(boolean visible) { + mWindowVisible = visible; + updateSamplingListener(); + } + + /** + * If we're blurring the shade window. + */ + public void setWindowHasBlurs(boolean hasBlurs) { + mWindowHasBlurs = hasBlurs; + updateSamplingListener(); + } + + public void dump(PrintWriter pw) { + dump("", pw); + } + + public void dump(String prefix, PrintWriter pw) { + pw.println(prefix + "RegionSamplingHelper:"); + pw.println(prefix + "\tsampleView isAttached: " + mSampledView.isAttachedToWindow()); + pw.println(prefix + "\tsampleView isScValid: " + (mSampledView.isAttachedToWindow() + ? mSampledView.getViewRootImpl().getSurfaceControl().isValid() + : "notAttached")); + pw.println(prefix + "\tmSamplingEnabled: " + mSamplingEnabled); + pw.println(prefix + "\tmSamplingListenerRegistered: " + mSamplingListenerRegistered); + pw.println(prefix + "\tmSamplingRequestBounds: " + mSamplingRequestBounds); + pw.println(prefix + "\tmRegisteredSamplingBounds: " + mRegisteredSamplingBounds); + pw.println(prefix + "\tmLastMedianLuma: " + mLastMedianLuma); + pw.println(prefix + "\tmCurrentMedianLuma: " + mCurrentMedianLuma); + pw.println(prefix + "\tmWindowVisible: " + mWindowVisible); + pw.println(prefix + "\tmWindowHasBlurs: " + mWindowHasBlurs); + pw.println(prefix + "\tmWaitingOnDraw: " + mWaitingOnDraw); + pw.println(prefix + "\tmRegisteredStopLayer: " + mRegisteredStopLayer); + pw.println(prefix + "\tmWrappedStopLayer: " + mWrappedStopLayer); + pw.println(prefix + "\tmIsDestroyed: " + mIsDestroyed); + } + + public interface SamplingCallback { + /** + * Called when the darkness of the sampled region changes + * @param isRegionDark true if the sampled luminance is below the luminance threshold + */ + void onRegionDarknessChanged(boolean isRegionDark); + + /** + * Get the sampled region of interest from the sampled view + * @param sampledView The view that this helper is attached to for convenience + * @return the region to be sampled in sceen coordinates. Return {@code null} to avoid + * sampling in this frame + */ + Rect getSampledRegion(View sampledView); + + /** + * @return if sampling should be enabled in the current configuration + */ + default boolean isSamplingEnabled() { + return true; + } + } + + @VisibleForTesting + public static class SysuiCompositionSamplingListener { + public void register(CompositionSamplingListener listener, + int displayId, SurfaceControl stopLayer, Rect samplingArea) { + CompositionSamplingListener.register(listener, displayId, stopLayer, samplingArea); + } + + /** + * Unregisters a sampling listener. + */ + public void unregister(CompositionSamplingListener listener) { + CompositionSamplingListener.unregister(listener); + } + } +} diff --git a/libs/WindowManager/Shell/tests/unittest/src/com/android/wm/shell/TestShellExecutor.java b/libs/WindowManager/Shell/tests/unittest/src/com/android/wm/shell/TestShellExecutor.java index 499870220190..f31722d3c1a5 100644 --- a/libs/WindowManager/Shell/tests/unittest/src/com/android/wm/shell/TestShellExecutor.java +++ b/libs/WindowManager/Shell/tests/unittest/src/com/android/wm/shell/TestShellExecutor.java @@ -19,6 +19,7 @@ package com.android.wm.shell; import com.android.wm.shell.common.ShellExecutor; import java.util.ArrayList; +import java.util.List; /** * Really basic test executor. It just gathers all events in a blob. The only option is to @@ -52,4 +53,9 @@ public class TestShellExecutor implements ShellExecutor { mRunnables.remove(0).run(); } } + + /** Returns the list of callbacks for this executor. */ + public List<Runnable> getCallbacks() { + return mRunnables; + } } diff --git a/libs/WindowManager/Shell/tests/unittest/src/com/android/wm/shell/shared/handles/RegionSamplingHelperTest.kt b/libs/WindowManager/Shell/tests/unittest/src/com/android/wm/shell/shared/handles/RegionSamplingHelperTest.kt new file mode 100644 index 000000000000..d3e291f7dd1f --- /dev/null +++ b/libs/WindowManager/Shell/tests/unittest/src/com/android/wm/shell/shared/handles/RegionSamplingHelperTest.kt @@ -0,0 +1,154 @@ +/* + * Copyright (C) 2022 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.wm.shell.shared.handles + + +import android.graphics.Rect +import android.testing.TestableLooper.RunWithLooper +import android.view.SurfaceControl +import android.view.View +import android.view.ViewRootImpl +import androidx.concurrent.futures.DirectExecutor +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.filters.SmallTest +import androidx.test.platform.app.InstrumentationRegistry.getInstrumentation +import com.android.wm.shell.ShellTestCase +import com.android.wm.shell.TestShellExecutor +import com.google.common.truth.Truth.assertThat +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.ArgumentMatchers.any +import org.mockito.ArgumentMatchers.anyInt +import org.mockito.ArgumentMatchers.eq +import org.mockito.Mock +import org.mockito.Mockito.mock +import org.mockito.Mockito.never +import org.mockito.Mockito.verify +import org.mockito.Mockito.`when` as whenever +import org.mockito.junit.MockitoJUnit +import org.mockito.kotlin.argumentCaptor + +@RunWith(AndroidJUnit4::class) +@SmallTest +@RunWithLooper +class RegionSamplingHelperTest : ShellTestCase() { + + @Mock + lateinit var sampledView: View + @Mock + lateinit var samplingCallback: RegionSamplingHelper.SamplingCallback + @Mock + lateinit var compositionListener: RegionSamplingHelper.SysuiCompositionSamplingListener + @Mock + lateinit var viewRootImpl: ViewRootImpl + @Mock + lateinit var surfaceControl: SurfaceControl + @Mock + lateinit var wrappedSurfaceControl: SurfaceControl + @JvmField @Rule + var rule = MockitoJUnit.rule() + lateinit var regionSamplingHelper: RegionSamplingHelper + + @Before + fun setup() { + whenever(sampledView.isAttachedToWindow).thenReturn(true) + whenever(sampledView.viewRootImpl).thenReturn(viewRootImpl) + whenever(viewRootImpl.surfaceControl).thenReturn(surfaceControl) + whenever(surfaceControl.isValid).thenReturn(true) + whenever(wrappedSurfaceControl.isValid).thenReturn(true) + whenever(samplingCallback.isSamplingEnabled).thenReturn(true) + getInstrumentation().runOnMainSync(Runnable { + regionSamplingHelper = object : RegionSamplingHelper( + sampledView, samplingCallback, + DirectExecutor.INSTANCE, DirectExecutor.INSTANCE, compositionListener + ) { + override fun wrap(stopLayerControl: SurfaceControl?): SurfaceControl { + return wrappedSurfaceControl + } + } + }) + regionSamplingHelper.setWindowVisible(true) + } + + @Test + fun testStart_register() { + regionSamplingHelper.start(Rect(0, 0, 100, 100)) + verify(compositionListener).register(any(), anyInt(), eq(wrappedSurfaceControl), any()) + } + + @Test + fun testStart_unregister() { + regionSamplingHelper.start(Rect(0, 0, 100, 100)) + regionSamplingHelper.setWindowVisible(false) + verify(compositionListener).unregister(any()) + } + + @Test + fun testStart_hasBlur_neverRegisters() { + regionSamplingHelper.setWindowHasBlurs(true) + regionSamplingHelper.start(Rect(0, 0, 100, 100)) + verify(compositionListener, never()) + .register(any(), anyInt(), eq(wrappedSurfaceControl), any()) + } + + @Test + fun testStart_stopAndDestroy() { + regionSamplingHelper.start(Rect(0, 0, 100, 100)) + regionSamplingHelper.stopAndDestroy() + verify(compositionListener).unregister(any()) + } + + @Test + fun testCompositionSamplingListener_has_nonEmptyRect() { + // simulate race condition + val fakeExecutor = TestShellExecutor() // pass in as backgroundExecutor + val fakeSamplingCallback = mock(RegionSamplingHelper.SamplingCallback::class.java) + + whenever(fakeSamplingCallback.isSamplingEnabled).thenReturn(true) + whenever(wrappedSurfaceControl.isValid).thenReturn(true) + getInstrumentation().runOnMainSync(Runnable { + regionSamplingHelper = object : RegionSamplingHelper( + sampledView, fakeSamplingCallback, + DirectExecutor.INSTANCE, fakeExecutor, compositionListener + ) { + override fun wrap(stopLayerControl: SurfaceControl?): SurfaceControl { + return wrappedSurfaceControl + } + } + }) + regionSamplingHelper.setWindowVisible(true) + regionSamplingHelper.start(Rect(0, 0, 100, 100)) + + // make sure background task is enqueued + assertThat(fakeExecutor.getCallbacks().size).isEqualTo(1) + + // make sure regionSamplingHelper will have empty Rect + whenever(fakeSamplingCallback.getSampledRegion(any())).thenReturn(Rect(0, 0, 0, 0)) + regionSamplingHelper.onLayoutChange(sampledView, 0, 0, 0, 0, 0, 0, 0, 0) + + // resume running of background thread + fakeExecutor.flushAll() + + // grab Rect passed into compositionSamplingListener and make sure it's not empty + val argumentGrabber = argumentCaptor<Rect>() + verify(compositionListener).register(any(), anyInt(), eq(wrappedSurfaceControl), + argumentGrabber.capture()) + assertThat(argumentGrabber.firstValue.isEmpty).isFalse() + } +}
\ No newline at end of file |