diff --git a/packages/SystemUI/src/com/android/systemui/dreams/DreamOverlayContainerViewController.java b/packages/SystemUI/src/com/android/systemui/dreams/DreamOverlayContainerViewController.java index be76e8fcae37d..7450103721829 100644 --- a/packages/SystemUI/src/com/android/systemui/dreams/DreamOverlayContainerViewController.java +++ b/packages/SystemUI/src/com/android/systemui/dreams/DreamOverlayContainerViewController.java @@ -18,12 +18,9 @@ package com.android.systemui.dreams; import static com.android.systemui.doze.util.BurnInHelperKt.getBurnInOffset; -import android.graphics.Rect; -import android.graphics.Region; import android.os.Handler; import android.view.View; import android.view.ViewGroup; -import android.view.ViewTreeObserver; import com.android.internal.annotations.VisibleForTesting; import com.android.systemui.R; @@ -62,43 +59,6 @@ public class DreamOverlayContainerViewController extends ViewController { private final View mView; private final ComplicationLayoutParams mLayoutParams; + private final TouchInsetManager.TouchInsetSession mTouchInsetSession; private final Parent mParent; @Complication.Category private final int mCategory; @@ -61,7 +63,8 @@ public class ComplicationLayoutEngine { * Default constructor. {@link Parent} allows for the {@link ViewEntry}'s surrounding * view hierarchy to be accessed without traversing the entire view tree. */ - ViewEntry(View view, ComplicationLayoutParams layoutParams, int category, Parent parent, + ViewEntry(View view, ComplicationLayoutParams layoutParams, + TouchInsetManager.TouchInsetSession touchSession, int category, Parent parent, int margin) { mView = view; // Views that are generated programmatically do not have a unique id assigned to them @@ -70,9 +73,12 @@ public class ComplicationLayoutEngine { // {@link Complication.ViewHolder} should not reference the root container by id. mView.setId(View.generateViewId()); mLayoutParams = layoutParams; + mTouchInsetSession = touchSession; mCategory = category; mParent = parent; mMargin = margin; + + touchSession.addViewToTracking(mView); } /** @@ -217,6 +223,7 @@ public class ComplicationLayoutEngine { mParent.removeEntry(this); ((ViewGroup) mView.getParent()).removeView(mView); + mTouchInsetSession.removeViewFromTracking(mView); } @Override @@ -242,15 +249,18 @@ public class ComplicationLayoutEngine { */ private static class Builder { private final View mView; + private final TouchInsetManager.TouchInsetSession mTouchSession; private final ComplicationLayoutParams mLayoutParams; private final int mCategory; private Parent mParent; private int mMargin; - Builder(View view, ComplicationLayoutParams lp, @Complication.Category int category) { + Builder(View view, TouchInsetManager.TouchInsetSession touchSession, + ComplicationLayoutParams lp, @Complication.Category int category) { mView = view; mLayoutParams = lp; mCategory = category; + mTouchSession = touchSession; } /** @@ -291,7 +301,8 @@ public class ComplicationLayoutEngine { * Builds and returns the resulting {@link ViewEntry}. */ ViewEntry build() { - return new ViewEntry(mView, mLayoutParams, mCategory, mParent, mMargin); + return new ViewEntry(mView, mLayoutParams, mTouchSession, mCategory, mParent, + mMargin); } } @@ -442,13 +453,16 @@ public class ComplicationLayoutEngine { private final int mMargin; private final HashMap mEntries = new HashMap<>(); private final HashMap mPositions = new HashMap<>(); + private final TouchInsetManager.TouchInsetSession mSession; /** */ @Inject public ComplicationLayoutEngine(@Named(SCOPED_COMPLICATIONS_LAYOUT) ConstraintLayout layout, - @Named(COMPLICATION_MARGIN) int margin) { + @Named(COMPLICATION_MARGIN) int margin, + TouchInsetManager.TouchInsetSession session) { mLayout = layout; mMargin = margin; + mSession = session; } /** @@ -468,7 +482,7 @@ public class ComplicationLayoutEngine { removeComplication(id); } - final ViewEntry.Builder entryBuilder = new ViewEntry.Builder(view, lp, category) + final ViewEntry.Builder entryBuilder = new ViewEntry.Builder(view, mSession, lp, category) .setMargin(mMargin); // Add position group if doesn't already exist diff --git a/packages/SystemUI/src/com/android/systemui/dreams/dagger/DreamOverlayModule.java b/packages/SystemUI/src/com/android/systemui/dreams/dagger/DreamOverlayModule.java index 839a05e6e78fc..63676d629cb96 100644 --- a/packages/SystemUI/src/com/android/systemui/dreams/dagger/DreamOverlayModule.java +++ b/packages/SystemUI/src/com/android/systemui/dreams/dagger/DreamOverlayModule.java @@ -29,6 +29,9 @@ import com.android.systemui.R; import com.android.systemui.dagger.qualifiers.Main; import com.android.systemui.dreams.DreamOverlayContainerView; import com.android.systemui.dreams.DreamOverlayStatusBarView; +import com.android.systemui.touch.TouchInsetManager; + +import java.util.concurrent.Executor; import javax.inject.Named; @@ -63,6 +66,21 @@ public abstract class DreamOverlayModule { "R.id.dream_overlay_content must not be null"); } + /** */ + @Provides + public static TouchInsetManager.TouchInsetSession providesTouchInsetSession( + TouchInsetManager manager) { + return manager.createSession(); + } + + /** */ + @Provides + @DreamOverlayComponent.DreamOverlayScope + public static TouchInsetManager providesTouchInsetManager(@Main Executor executor, + DreamOverlayContainerView view) { + return new TouchInsetManager(executor, view); + } + /** */ @Provides @DreamOverlayComponent.DreamOverlayScope diff --git a/packages/SystemUI/src/com/android/systemui/touch/TouchInsetManager.java b/packages/SystemUI/src/com/android/systemui/touch/TouchInsetManager.java new file mode 100644 index 0000000000000..de4e1e2f58864 --- /dev/null +++ b/packages/SystemUI/src/com/android/systemui/touch/TouchInsetManager.java @@ -0,0 +1,181 @@ +/* + * 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.systemui.touch; + +import android.graphics.Rect; +import android.graphics.Region; +import android.view.View; +import android.view.ViewRootImpl; + +import java.util.HashMap; +import java.util.HashSet; +import java.util.concurrent.Executor; + +/** + * {@link TouchInsetManager} handles setting the touchable inset regions for a given View. This + * is useful for passing through touch events for all but select areas. + */ +public class TouchInsetManager { + /** + * {@link TouchInsetSession} provides an individualized session with the + * {@link TouchInsetManager}, linking any action to the client. + */ + public static class TouchInsetSession { + private final TouchInsetManager mManager; + + private final HashSet mTrackedViews; + private final Executor mExecutor; + + private final View.OnLayoutChangeListener mOnLayoutChangeListener = + (v, left, top, right, bottom, oldLeft, oldTop, oldRight, oldBottom) + -> updateTouchRegion(); + + /** + * Default constructor + * @param manager The parent {@link TouchInsetManager} which will be affected by actions on + * this session. + * @param rootView The parent of views that will be tracked. + * @param executor An executor for marshalling operations. + */ + TouchInsetSession(TouchInsetManager manager, Executor executor) { + mManager = manager; + mTrackedViews = new HashSet<>(); + mExecutor = executor; + } + + /** + * Adds a descendant of the root view to be tracked. + * @param view {@link View} to be tracked. + */ + public void addViewToTracking(View view) { + mExecutor.execute(() -> { + mTrackedViews.add(view); + view.addOnLayoutChangeListener(mOnLayoutChangeListener); + updateTouchRegion(); + }); + } + + /** + * Removes a view from further tracking + * @param view {@link View} to be removed. + */ + public void removeViewFromTracking(View view) { + mExecutor.execute(() -> { + mTrackedViews.remove(view); + view.removeOnLayoutChangeListener(mOnLayoutChangeListener); + updateTouchRegion(); + }); + } + + private void updateTouchRegion() { + final Region cumulativeRegion = Region.obtain(); + + mTrackedViews.stream().forEach(view -> { + final Rect boundaries = new Rect(); + view.getBoundsOnScreen(boundaries); + cumulativeRegion.op(boundaries, Region.Op.UNION); + }); + + mManager.setTouchRegion(this, cumulativeRegion); + + cumulativeRegion.recycle(); + } + + /** + * Removes all tracked views and updates insets accordingly. + */ + public void clear() { + mExecutor.execute(() -> { + mManager.clearRegion(this); + mTrackedViews.clear(); + }); + } + } + + private final HashMap mDefinedRegions = new HashMap<>(); + private final Executor mExecutor; + private final View mRootView; + + private final View.OnAttachStateChangeListener mAttachListener = + new View.OnAttachStateChangeListener() { + @Override + public void onViewAttachedToWindow(View v) { + updateTouchInset(); + } + + @Override + public void onViewDetachedFromWindow(View v) { + } + }; + + /** + * Default constructor. + * @param executor An {@link Executor} to marshal all operations on. + * @param rootView The root {@link View} for all views in sessions. + */ + public TouchInsetManager(Executor executor, View rootView) { + mExecutor = executor; + mRootView = rootView; + mRootView.addOnAttachStateChangeListener(mAttachListener); + + } + + /** + * Creates a new associated session. + */ + public TouchInsetSession createSession() { + return new TouchInsetSession(this, mExecutor); + } + + private void updateTouchInset() { + final ViewRootImpl viewRootImpl = mRootView.getViewRootImpl(); + + if (viewRootImpl == null) { + return; + } + + final Region aggregateRegion = Region.obtain(); + + for (Region region : mDefinedRegions.values()) { + aggregateRegion.op(region, Region.Op.UNION); + } + + viewRootImpl.setTouchableRegion(aggregateRegion); + + aggregateRegion.recycle(); + } + + protected void setTouchRegion(TouchInsetSession session, Region region) { + final Region introducedRegion = Region.obtain(region); + mExecutor.execute(() -> { + mDefinedRegions.put(session, introducedRegion); + updateTouchInset(); + }); + } + + private void clearRegion(TouchInsetSession session) { + mExecutor.execute(() -> { + final Region storedRegion = mDefinedRegions.remove(session); + + if (storedRegion != null) { + storedRegion.recycle(); + } + + updateTouchInset(); + }); + } +} diff --git a/packages/SystemUI/tests/src/com/android/systemui/dreams/DreamOverlayContainerViewControllerTest.java b/packages/SystemUI/tests/src/com/android/systemui/dreams/DreamOverlayContainerViewControllerTest.java index 7af039b7fa06c..8ce10b808f32e 100644 --- a/packages/SystemUI/tests/src/com/android/systemui/dreams/DreamOverlayContainerViewControllerTest.java +++ b/packages/SystemUI/tests/src/com/android/systemui/dreams/DreamOverlayContainerViewControllerTest.java @@ -17,7 +17,6 @@ package com.android.systemui.dreams; import static org.junit.Assert.assertEquals; -import static org.junit.Assert.assertNotNull; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyFloat; import static org.mockito.ArgumentMatchers.eq; @@ -117,31 +116,6 @@ public class DreamOverlayContainerViewControllerTest extends SysuiTestCase { DREAM_OVERLAY_NOTIFICATIONS_DRAG_AREA_HEIGHT); } - @Test - public void testOnViewAttachedRegistersComputeInsetsListener() { - mController.onViewAttached(); - verify(mViewTreeObserver).addOnComputeInternalInsetsListener(any()); - } - - @Test - public void testOnViewDetachedUnregistersComputeInsetsListener() { - mController.onViewDetached(); - verify(mViewTreeObserver).removeOnComputeInternalInsetsListener(any()); - } - - @Test - public void testComputeInsetsListenerReturnsRegion() { - final ArgumentCaptor - computeInsetsListenerCapture = - ArgumentCaptor.forClass(ViewTreeObserver.OnComputeInternalInsetsListener.class); - mController.onViewAttached(); - verify(mViewTreeObserver).addOnComputeInternalInsetsListener( - computeInsetsListenerCapture.capture()); - final ViewTreeObserver.InternalInsetsInfo info = new ViewTreeObserver.InternalInsetsInfo(); - computeInsetsListenerCapture.getValue().onComputeInternalInsets(info); - assertNotNull(info.touchableRegion); - } - @Test public void testBurnInProtectionStartsWhenContentViewAttached() { mController.onViewAttached(); diff --git a/packages/SystemUI/tests/src/com/android/systemui/dreams/DreamOverlayStatusBarViewControllerTest.java b/packages/SystemUI/tests/src/com/android/systemui/dreams/DreamOverlayStatusBarViewControllerTest.java index 658702929d7b8..ad8d44d62a5c6 100644 --- a/packages/SystemUI/tests/src/com/android/systemui/dreams/DreamOverlayStatusBarViewControllerTest.java +++ b/packages/SystemUI/tests/src/com/android/systemui/dreams/DreamOverlayStatusBarViewControllerTest.java @@ -29,6 +29,7 @@ import android.testing.AndroidTestingRunner; import androidx.test.filters.SmallTest; import com.android.systemui.SysuiTestCase; +import com.android.systemui.touch.TouchInsetManager; import org.junit.Before; import org.junit.Test; @@ -49,13 +50,16 @@ public class DreamOverlayStatusBarViewControllerTest extends SysuiTestCase { NetworkCapabilities mNetworkCapabilities; @Mock Network mNetwork; + @Mock + TouchInsetManager.TouchInsetSession mTouchSession; DreamOverlayStatusBarViewController mController; @Before public void setup() { MockitoAnnotations.initMocks(this); - mController = new DreamOverlayStatusBarViewController(mView, mConnectivityManager); + mController = new DreamOverlayStatusBarViewController(mView, mConnectivityManager, + mTouchSession); } @Test diff --git a/packages/SystemUI/tests/src/com/android/systemui/dreams/complication/ComplicationLayoutEngineTest.java b/packages/SystemUI/tests/src/com/android/systemui/dreams/complication/ComplicationLayoutEngineTest.java index 64b267d6e1424..51dcf2ec18f42 100644 --- a/packages/SystemUI/tests/src/com/android/systemui/dreams/complication/ComplicationLayoutEngineTest.java +++ b/packages/SystemUI/tests/src/com/android/systemui/dreams/complication/ComplicationLayoutEngineTest.java @@ -29,6 +29,7 @@ import androidx.test.filters.SmallTest; import com.android.systemui.R; import com.android.systemui.SysuiTestCase; +import com.android.systemui.touch.TouchInsetManager; import org.junit.Before; import org.junit.Test; @@ -46,6 +47,9 @@ public class ComplicationLayoutEngineTest extends SysuiTestCase { @Mock ConstraintLayout mLayout; + @Mock + TouchInsetManager.TouchInsetSession mTouchSession; + @Before public void setup() { MockitoAnnotations.initMocks(this); @@ -112,7 +116,8 @@ public class ComplicationLayoutEngineTest extends SysuiTestCase { Complication.CATEGORY_STANDARD, mLayout); - final ComplicationLayoutEngine engine = new ComplicationLayoutEngine(mLayout, 0); + final ComplicationLayoutEngine engine = + new ComplicationLayoutEngine(mLayout, 0, mTouchSession); addComplication(engine, firstViewInfo); // Ensure the view is added to the top end corner @@ -139,7 +144,8 @@ public class ComplicationLayoutEngineTest extends SysuiTestCase { Complication.CATEGORY_STANDARD, mLayout); - final ComplicationLayoutEngine engine = new ComplicationLayoutEngine(mLayout, 0); + final ComplicationLayoutEngine engine = + new ComplicationLayoutEngine(mLayout, 0, mTouchSession); addComplication(engine, firstViewInfo); // Ensure the view is added to the top end corner @@ -155,7 +161,8 @@ public class ComplicationLayoutEngineTest extends SysuiTestCase { */ @Test public void testDirectionLayout() { - final ComplicationLayoutEngine engine = new ComplicationLayoutEngine(mLayout, 0); + final ComplicationLayoutEngine engine = + new ComplicationLayoutEngine(mLayout, 0, mTouchSession); final ViewInfo firstViewInfo = new ViewInfo( new ComplicationLayoutParams( @@ -203,7 +210,8 @@ public class ComplicationLayoutEngineTest extends SysuiTestCase { */ @Test public void testPositionLayout() { - final ComplicationLayoutEngine engine = new ComplicationLayoutEngine(mLayout, 0); + final ComplicationLayoutEngine engine = + new ComplicationLayoutEngine(mLayout, 0, mTouchSession); final ViewInfo firstViewInfo = new ViewInfo( new ComplicationLayoutParams( @@ -290,7 +298,8 @@ public class ComplicationLayoutEngineTest extends SysuiTestCase { @Test public void testMargin() { final int margin = 5; - final ComplicationLayoutEngine engine = new ComplicationLayoutEngine(mLayout, margin); + final ComplicationLayoutEngine engine = + new ComplicationLayoutEngine(mLayout, margin, mTouchSession); final ViewInfo firstViewInfo = new ViewInfo( new ComplicationLayoutParams( @@ -364,7 +373,8 @@ public class ComplicationLayoutEngineTest extends SysuiTestCase { */ @Test public void testRemoval() { - final ComplicationLayoutEngine engine = new ComplicationLayoutEngine(mLayout, 0); + final ComplicationLayoutEngine engine = + new ComplicationLayoutEngine(mLayout, 0, mTouchSession); final ViewInfo firstViewInfo = new ViewInfo( new ComplicationLayoutParams( diff --git a/packages/SystemUI/tests/src/com/android/systemui/touch/TouchInsetManagerTest.java b/packages/SystemUI/tests/src/com/android/systemui/touch/TouchInsetManagerTest.java new file mode 100644 index 0000000000000..14b9bfb1393f9 --- /dev/null +++ b/packages/SystemUI/tests/src/com/android/systemui/touch/TouchInsetManagerTest.java @@ -0,0 +1,203 @@ +/* + * 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.systemui.touch; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.clearInvocations; +import static org.mockito.Mockito.doAnswer; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import android.graphics.Rect; +import android.graphics.Region; +import android.testing.AndroidTestingRunner; +import android.view.View; +import android.view.ViewRootImpl; + +import androidx.test.filters.SmallTest; + +import com.android.systemui.SysuiTestCase; +import com.android.systemui.util.concurrency.FakeExecutor; +import com.android.systemui.util.time.FakeSystemClock; + +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.mockito.ArgumentCaptor; +import org.mockito.Mock; +import org.mockito.Mockito; +import org.mockito.MockitoAnnotations; + +@SmallTest +@RunWith(AndroidTestingRunner.class) +public class TouchInsetManagerTest extends SysuiTestCase { + @Mock + private View mRootView; + + @Mock + private ViewRootImpl mRootViewImpl; + + private FakeExecutor mFakeExecutor = new FakeExecutor(new FakeSystemClock()); + + @Before + public void setup() { + MockitoAnnotations.initMocks(this); + when(mRootView.getViewRootImpl()).thenReturn(mRootViewImpl); + } + + @Test + public void testRootViewOnAttachedHandling() { + // Create inset manager + final TouchInsetManager insetManager = new TouchInsetManager(mFakeExecutor, + mRootView); + + final ArgumentCaptor listener = + ArgumentCaptor.forClass(View.OnAttachStateChangeListener.class); + + // Ensure manager has registered to listen to attached state of root view. + verify(mRootView).addOnAttachStateChangeListener(listener.capture()); + + // Trigger attachment and verify touchable region is set. + listener.getValue().onViewAttachedToWindow(mRootView); + verify(mRootViewImpl).setTouchableRegion(any()); + } + + @Test + public void testInsetRegionPropagation() { + // Create inset manager + final TouchInsetManager insetManager = new TouchInsetManager(mFakeExecutor, + mRootView); + + // Create session + final TouchInsetManager.TouchInsetSession session = insetManager.createSession(); + + // Add a view to the session. + final Rect rect = new Rect(0, 0, 2, 2); + + session.addViewToTracking(createView(rect)); + mFakeExecutor.runAllReady(); + + // Check to see if view was properly accounted for. + final Region expectedRegion = Region.obtain(); + expectedRegion.op(rect, Region.Op.UNION); + verify(mRootViewImpl).setTouchableRegion(eq(expectedRegion)); + } + + @Test + public void testMultipleRegions() { + // Create inset manager + final TouchInsetManager insetManager = new TouchInsetManager(mFakeExecutor, + mRootView); + + // Create session + final TouchInsetManager.TouchInsetSession session = insetManager.createSession(); + + // Add a view to the session. + final Rect firstBounds = new Rect(0, 0, 2, 2); + session.addViewToTracking(createView(firstBounds)); + + mFakeExecutor.runAllReady(); + clearInvocations(mRootViewImpl); + + // Create second session + final TouchInsetManager.TouchInsetSession secondSession = insetManager.createSession(); + + // Add a view to the second session. + final Rect secondBounds = new Rect(4, 4, 8, 10); + secondSession.addViewToTracking(createView(secondBounds)); + + mFakeExecutor.runAllReady(); + + // Check to see if all views and sessions was properly accounted for. + { + final Region expectedRegion = Region.obtain(); + expectedRegion.op(firstBounds, Region.Op.UNION); + expectedRegion.op(secondBounds, Region.Op.UNION); + verify(mRootViewImpl).setTouchableRegion(eq(expectedRegion)); + } + + + clearInvocations(mRootViewImpl); + + // clear first session, ensure second session is still reflected. + session.clear(); + mFakeExecutor.runAllReady(); + { + final Region expectedRegion = Region.obtain(); + expectedRegion.op(firstBounds, Region.Op.UNION); + verify(mRootViewImpl).setTouchableRegion(eq(expectedRegion)); + } + } + + @Test + public void testMultipleViews() { + // Create inset manager + final TouchInsetManager insetManager = new TouchInsetManager(mFakeExecutor, + mRootView); + + // Create session + final TouchInsetManager.TouchInsetSession session = insetManager.createSession(); + + // Add a view to the session. + final Rect firstViewBounds = new Rect(0, 0, 2, 2); + session.addViewToTracking(createView(firstViewBounds)); + + // only capture second invocation. + mFakeExecutor.runAllReady(); + clearInvocations(mRootViewImpl); + + // Add a second view to the session + final Rect secondViewBounds = new Rect(4, 4, 9, 10); + final View secondView = createView(secondViewBounds); + session.addViewToTracking(secondView); + + mFakeExecutor.runAllReady(); + + // Check to see if all views and sessions was properly accounted for. + { + final Region expectedRegion = Region.obtain(); + expectedRegion.op(firstViewBounds, Region.Op.UNION); + expectedRegion.op(secondViewBounds, Region.Op.UNION); + verify(mRootViewImpl).setTouchableRegion(eq(expectedRegion)); + } + + // Remove second view. + session.removeViewFromTracking(secondView); + + clearInvocations(mRootViewImpl); + mFakeExecutor.runAllReady(); + + // Ensure first view still reflected in touch region. + { + final Region expectedRegion = Region.obtain(); + expectedRegion.op(firstViewBounds, Region.Op.UNION); + verify(mRootViewImpl).setTouchableRegion(eq(expectedRegion)); + } + } + + private View createView(Rect bounds) { + final Rect rect = new Rect(bounds); + final View view = Mockito.mock(View.class); + doAnswer(invocation -> { + ((Rect) invocation.getArgument(0)).set(rect); + return null; + }).when(view).getBoundsOnScreen(any()); + + return view; + } +}