diff --git a/packages/SystemUI/Android.bp b/packages/SystemUI/Android.bp index 137a1fd4f299a..b1cfb1118bb6a 100644 --- a/packages/SystemUI/Android.bp +++ b/packages/SystemUI/Android.bp @@ -107,6 +107,7 @@ android_library { "androidx.slice_slice-view", "androidx.slice_slice-builders", "androidx.arch.core_core-runtime", + "androidx.lifecycle_lifecycle-common-java8", "androidx.lifecycle_lifecycle-extensions", "androidx.dynamicanimation_dynamicanimation", "androidx-constraintlayout_constraintlayout", @@ -209,6 +210,7 @@ android_library { "androidx.slice_slice-view", "androidx.slice_slice-builders", "androidx.arch.core_core-runtime", + "androidx.lifecycle_lifecycle-common-java8", "androidx.lifecycle_lifecycle-extensions", "androidx.dynamicanimation_dynamicanimation", "androidx-constraintlayout_constraintlayout", diff --git a/packages/SystemUI/src/com/android/systemui/dreams/DreamOverlayService.java b/packages/SystemUI/src/com/android/systemui/dreams/DreamOverlayService.java index 16ed1fbba75f9..3ee0cad320972 100644 --- a/packages/SystemUI/src/com/android/systemui/dreams/DreamOverlayService.java +++ b/packages/SystemUI/src/com/android/systemui/dreams/DreamOverlayService.java @@ -29,9 +29,12 @@ import androidx.lifecycle.LifecycleRegistry; import androidx.lifecycle.ViewModelStore; import com.android.internal.policy.PhoneWindow; +import com.android.keyguard.KeyguardUpdateMonitor; +import com.android.keyguard.KeyguardUpdateMonitorCallback; import com.android.systemui.dagger.qualifiers.Main; import com.android.systemui.dreams.complication.Complication; import com.android.systemui.dreams.dagger.DreamOverlayComponent; +import com.android.systemui.dreams.touch.DreamOverlayTouchMonitor; import java.util.concurrent.Executor; @@ -53,6 +56,7 @@ public class DreamOverlayService extends android.service.dreams.DreamOverlayServ // A controller for the dream overlay container view (which contains both the status bar and the // content area). private final DreamOverlayContainerViewController mDreamOverlayContainerViewController; + private final KeyguardUpdateMonitor mKeyguardUpdateMonitor; // A reference to the {@link Window} used to hold the dream overlay. private Window mWindow; @@ -68,19 +72,40 @@ public class DreamOverlayService extends android.service.dreams.DreamOverlayServ private ViewModelStore mViewModelStore = new ViewModelStore(); + private DreamOverlayTouchMonitor mDreamOverlayTouchMonitor; + + private final KeyguardUpdateMonitorCallback mKeyguardCallback = + new KeyguardUpdateMonitorCallback() { + @Override + public void onShadeExpandedChanged(boolean expanded) { + if (mLifecycleRegistry.getCurrentState() != Lifecycle.State.RESUMED + && mLifecycleRegistry.getCurrentState() != Lifecycle.State.STARTED) { + return; + } + + mLifecycleRegistry.setCurrentState( + expanded ? Lifecycle.State.STARTED : Lifecycle.State.RESUMED); + } + }; + @Inject public DreamOverlayService( Context context, @Main Executor executor, - DreamOverlayComponent.Factory dreamOverlayComponentFactory) { + DreamOverlayComponent.Factory dreamOverlayComponentFactory, + KeyguardUpdateMonitor keyguardUpdateMonitor) { mContext = context; mExecutor = executor; + mKeyguardUpdateMonitor = keyguardUpdateMonitor; + mKeyguardUpdateMonitor.registerCallback(mKeyguardCallback); final DreamOverlayComponent component = dreamOverlayComponentFactory.create(mViewModelStore, mHost); mDreamOverlayContainerViewController = component.getDreamOverlayContainerViewController(); setCurrentState(Lifecycle.State.CREATED); mLifecycleRegistry = component.getLifecycleRegistry(); + mDreamOverlayTouchMonitor = component.getDreamOverlayTouchMonitor(); + mDreamOverlayTouchMonitor.init(); } private void setCurrentState(Lifecycle.State state) { @@ -89,6 +114,7 @@ public class DreamOverlayService extends android.service.dreams.DreamOverlayServ @Override public void onDestroy() { + mKeyguardUpdateMonitor.registerCallback(mKeyguardCallback); setCurrentState(Lifecycle.State.DESTROYED); final WindowManager windowManager = mContext.getSystemService(WindowManager.class); windowManager.removeView(mWindow.getDecorView()); diff --git a/packages/SystemUI/src/com/android/systemui/dreams/dagger/DreamModule.java b/packages/SystemUI/src/com/android/systemui/dreams/dagger/DreamModule.java index d5053a03fec64..3d2f924563f38 100644 --- a/packages/SystemUI/src/com/android/systemui/dreams/dagger/DreamModule.java +++ b/packages/SystemUI/src/com/android/systemui/dreams/dagger/DreamModule.java @@ -16,13 +16,18 @@ package com.android.systemui.dreams.dagger; +import com.android.systemui.dreams.touch.dagger.DreamTouchModule; + import dagger.Module; /** * Dagger Module providing Communal-related functionality. */ -@Module(subcomponents = { - DreamOverlayComponent.class, -}) +@Module(includes = { + DreamTouchModule.class, + }, + subcomponents = { + DreamOverlayComponent.class, + }) public interface DreamModule { } \ No newline at end of file diff --git a/packages/SystemUI/src/com/android/systemui/dreams/dagger/DreamOverlayComponent.java b/packages/SystemUI/src/com/android/systemui/dreams/dagger/DreamOverlayComponent.java index f0ab6964faa1b..05ab9015feccc 100644 --- a/packages/SystemUI/src/com/android/systemui/dreams/dagger/DreamOverlayComponent.java +++ b/packages/SystemUI/src/com/android/systemui/dreams/dagger/DreamOverlayComponent.java @@ -25,6 +25,7 @@ import androidx.lifecycle.ViewModelStore; import com.android.systemui.dreams.DreamOverlayContainerViewController; import com.android.systemui.dreams.complication.Complication; import com.android.systemui.dreams.complication.dagger.ComplicationModule; +import com.android.systemui.dreams.touch.DreamOverlayTouchMonitor; import java.lang.annotation.Documented; import java.lang.annotation.Retention; @@ -64,4 +65,7 @@ public interface DreamOverlayComponent { /** Builds a {@link androidx.lifecycle.LifecycleOwner} */ LifecycleOwner getLifecycleOwner(); + + /** Builds a {@link DreamOverlayTouchMonitor} */ + DreamOverlayTouchMonitor getDreamOverlayTouchMonitor(); } 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 b56aa2c92a274..503817a23f7f4 100644 --- a/packages/SystemUI/src/com/android/systemui/dreams/dagger/DreamOverlayModule.java +++ b/packages/SystemUI/src/com/android/systemui/dreams/dagger/DreamOverlayModule.java @@ -22,6 +22,7 @@ import android.os.Handler; import android.view.LayoutInflater; import android.view.ViewGroup; +import androidx.lifecycle.Lifecycle; import androidx.lifecycle.LifecycleOwner; import androidx.lifecycle.LifecycleRegistry; @@ -33,6 +34,7 @@ import com.android.systemui.broadcast.BroadcastDispatcher; import com.android.systemui.dagger.qualifiers.Main; import com.android.systemui.dreams.DreamOverlayContainerView; import com.android.systemui.dreams.DreamOverlayStatusBarView; +import com.android.systemui.dreams.touch.DreamTouchHandler; import com.android.systemui.statusbar.policy.BatteryController; import com.android.systemui.statusbar.policy.ConfigurationController; import com.android.systemui.tuner.TunerService; @@ -42,6 +44,7 @@ import javax.inject.Named; import dagger.Lazy; import dagger.Module; import dagger.Provides; +import dagger.multibindings.IntoSet; /** Dagger module for {@link DreamOverlayComponent}. */ @Module @@ -140,4 +143,18 @@ public abstract class DreamOverlayModule { static LifecycleRegistry providesLifecycleRegistry(LifecycleOwner lifecycleOwner) { return new LifecycleRegistry(lifecycleOwner); } + + @Provides + @DreamOverlayComponent.DreamOverlayScope + static Lifecycle providesLifecycle(LifecycleOwner lifecycleOwner) { + return lifecycleOwner.getLifecycle(); + } + + // TODO: This stub should be removed once there is a {@link DreamTouchHandler} + // implementation present. + @Provides + @IntoSet + static DreamTouchHandler provideDreamTouchHandler() { + return session -> { }; + } } diff --git a/packages/SystemUI/src/com/android/systemui/dreams/touch/DreamOverlayTouchMonitor.java b/packages/SystemUI/src/com/android/systemui/dreams/touch/DreamOverlayTouchMonitor.java new file mode 100644 index 0000000000000..3e5efb2115a0a --- /dev/null +++ b/packages/SystemUI/src/com/android/systemui/dreams/touch/DreamOverlayTouchMonitor.java @@ -0,0 +1,368 @@ +/* + * 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.dreams.touch; + +import android.view.GestureDetector; +import android.view.InputEvent; +import android.view.MotionEvent; + +import androidx.annotation.NonNull; +import androidx.concurrent.futures.CallbackToFutureAdapter; +import androidx.lifecycle.DefaultLifecycleObserver; +import androidx.lifecycle.Lifecycle; +import androidx.lifecycle.LifecycleObserver; +import androidx.lifecycle.LifecycleOwner; + +import com.android.systemui.dagger.qualifiers.Main; +import com.android.systemui.dreams.touch.dagger.InputSessionComponent; +import com.android.systemui.shared.system.InputChannelCompat; + +import com.google.common.util.concurrent.ListenableFuture; + +import java.util.Collection; +import java.util.HashSet; +import java.util.Set; +import java.util.concurrent.Executor; +import java.util.function.Consumer; +import java.util.stream.Collectors; + +import javax.inject.Inject; + +/** + * {@link DreamOverlayTouchMonitor} is responsible for monitoring touches and gestures over the + * dream overlay and redirecting them to a set of listeners. This monitor is in charge of figuring + * out when listeners are eligible for receiving touches and filtering the listener pool if + * touches are consumed. + */ +public class DreamOverlayTouchMonitor { + // This executor is used to protect {@code mActiveTouchSessions} from being modified + // concurrently. Any operation that adds or removes values should use this executor. + private final Executor mExecutor; + private final Lifecycle mLifecycle; + + /** + * Adds a new {@link TouchSessionImpl} to participate in receiving future touches and gestures. + */ + private ListenableFuture push( + TouchSessionImpl touchSessionImpl) { + return CallbackToFutureAdapter.getFuture(completer -> { + mExecutor.execute(() -> { + if (!mActiveTouchSessions.remove(touchSessionImpl)) { + completer.set(null); + return; + } + + final TouchSessionImpl touchSession = + new TouchSessionImpl(this, touchSessionImpl); + mActiveTouchSessions.add(touchSession); + completer.set(touchSession); + }); + + return "DreamOverlayTouchMonitor::push"; + }); + } + + /** + * Removes a {@link TouchSessionImpl} from receiving further updates. + */ + private ListenableFuture pop( + TouchSessionImpl touchSessionImpl) { + return CallbackToFutureAdapter.getFuture(completer -> { + mExecutor.execute(() -> { + if (mActiveTouchSessions.remove(touchSessionImpl)) { + touchSessionImpl.onRemoved(); + + final TouchSessionImpl predecessor = touchSessionImpl.getPredecessor(); + + if (predecessor != null) { + mActiveTouchSessions.add(predecessor); + } + + completer.set(predecessor); + } + }); + + return "DreamOverlayTouchMonitor::pop"; + }); + } + + /** + * {@link TouchSessionImpl} implements {@link DreamTouchHandler.TouchSession} for + * {@link DreamOverlayTouchMonitor}. It enables the monitor to access the associated listeners + * and provides the associated client with access to the monitor. + */ + private static class TouchSessionImpl implements DreamTouchHandler.TouchSession { + private final HashSet mEventListeners = + new HashSet<>(); + private final HashSet mGestureListeners = + new HashSet<>(); + private final HashSet mCallbacks = new HashSet<>(); + + private final TouchSessionImpl mPredecessor; + private final DreamOverlayTouchMonitor mTouchMonitor; + + TouchSessionImpl(DreamOverlayTouchMonitor touchMonitor, TouchSessionImpl predecessor) { + mPredecessor = predecessor; + mTouchMonitor = touchMonitor; + } + + @Override + public void registerCallback(Callback callback) { + mCallbacks.add(callback); + } + + @Override + public boolean registerInputListener( + InputChannelCompat.InputEventListener inputEventListener) { + return mEventListeners.add(inputEventListener); + } + + @Override + public boolean registerGestureListener(GestureDetector.OnGestureListener gestureListener) { + return mGestureListeners.add(gestureListener); + } + + @Override + public ListenableFuture push() { + return mTouchMonitor.push(this); + } + + @Override + public ListenableFuture pop() { + return mTouchMonitor.pop(this); + } + + /** + * Returns the active listeners to receive touch events. + */ + public Collection getEventListeners() { + return mEventListeners; + } + + /** + * Returns the active listeners to receive gesture events. + */ + public Collection getGestureListeners() { + return mGestureListeners; + } + + /** + * Returns the {@link TouchSessionImpl} that preceded this current session. This will + * become the new active session when this session is popped. + */ + private TouchSessionImpl getPredecessor() { + return mPredecessor; + } + + /** + * Called by the monitor when this session is removed. + */ + private void onRemoved() { + mCallbacks.forEach(callback -> callback.onRemoved()); + } + } + + /** + * This lifecycle observer ensures touch monitoring only occurs while the overlay is "resumed". + * This concept is mapped over from the equivalent view definition: The {@link LifecycleOwner} + * will report the dream is not resumed when it is obscured (from the notification shade being + * expanded for example) or not active (such as when it is destroyed). + */ + private final LifecycleObserver mLifecycleObserver = new DefaultLifecycleObserver() { + @Override + public void onResume(@NonNull LifecycleOwner owner) { + startMonitoring(); + } + + @Override + public void onPause(@NonNull LifecycleOwner owner) { + stopMonitoring(); + } + }; + + /** + * When invoked, instantiates a new {@link InputSession} to monitor touch events. + */ + private void startMonitoring() { + stopMonitoring(); + mCurrentInputSession = mInputSessionFactory.create( + "dreamOverlay", + mInputEventListener, + mOnGestureListener, + true) + .getInputSession(); + } + + /** + * Destroys any active {@link InputSession}. + */ + private void stopMonitoring() { + if (mCurrentInputSession == null) { + return; + } + + mCurrentInputSession.dispose(); + mCurrentInputSession = null; + } + + + private final HashSet mActiveTouchSessions = new HashSet<>(); + private final Collection mHandlers; + + private InputChannelCompat.InputEventListener mInputEventListener = + new InputChannelCompat.InputEventListener() { + @Override + public void onInputEvent(InputEvent ev) { + // No Active sessions are receiving touches. Create sessions for each listener + if (mActiveTouchSessions.isEmpty()) { + for (DreamTouchHandler handler : mHandlers) { + final TouchSessionImpl sessionStack = + new TouchSessionImpl(DreamOverlayTouchMonitor.this, null); + mActiveTouchSessions.add(sessionStack); + handler.onSessionStart(sessionStack); + } + } + + // Find active sessions and invoke on InputEvent. + mActiveTouchSessions.stream() + .map(touchSessionStack -> touchSessionStack.getEventListeners()) + .flatMap(Collection::stream) + .forEach(inputEventListener -> inputEventListener.onInputEvent(ev)); + } + }; + + /** + * The {@link Evaluator} interface allows for callers to inspect a listener from the + * {@link android.view.GestureDetector.OnGestureListener} set. This helps reduce duplicated + * iteration loops over this set. + */ + private interface Evaluator { + boolean evaluate(GestureDetector.OnGestureListener listener); + } + + private GestureDetector.OnGestureListener mOnGestureListener = + new GestureDetector.OnGestureListener() { + private boolean evaluate(Evaluator evaluator) { + final Set consumingSessions = new HashSet<>(); + + // When a gesture is consumed, it is assumed that all touches for the current session + // should be directed only to those TouchSessions until those sessions are popped. All + // non-participating sessions are removed from receiving further updates with + // {@link DreamOverlayTouchMonitor#isolate}. + final boolean eventConsumed = mActiveTouchSessions.stream() + .map(touchSession -> { + boolean consume = touchSession.getGestureListeners() + .stream() + .map(listener -> evaluator.evaluate(listener)) + .anyMatch(consumed -> consumed); + + if (consume) { + consumingSessions.add(touchSession); + } + return consume; + }).anyMatch(consumed -> consumed); + + if (eventConsumed) { + DreamOverlayTouchMonitor.this.isolate(consumingSessions); + } + + return eventConsumed; + } + + // This method is called for gesture events that cannot be consumed. + private void observe(Consumer consumer) { + mActiveTouchSessions.stream() + .map(touchSession -> touchSession.getGestureListeners()) + .flatMap(Collection::stream) + .forEach(listener -> consumer.accept(listener)); + } + + @Override + public boolean onDown(MotionEvent e) { + return evaluate(listener -> listener.onDown(e)); + } + + @Override + public boolean onFling(MotionEvent e1, MotionEvent e2, float velocityX, float velocityY) { + return evaluate(listener -> listener.onFling(e1, e2, velocityX, velocityY)); + } + + @Override + public void onLongPress(MotionEvent e) { + observe(listener -> listener.onLongPress(e)); + } + + @Override + public boolean onScroll(MotionEvent e1, MotionEvent e2, float distanceX, float distanceY) { + return evaluate(listener -> listener.onScroll(e1, e2, distanceX, distanceY)); + } + + @Override + public void onShowPress(MotionEvent e) { + observe(listener -> listener.onShowPress(e)); + } + + @Override + public boolean onSingleTapUp(MotionEvent e) { + return evaluate(listener -> listener.onSingleTapUp(e)); + } + }; + + private InputSessionComponent.Factory mInputSessionFactory; + private InputSession mCurrentInputSession; + + /** + * Designated constructor for {@link DreamOverlayTouchMonitor} + * @param executor This executor will be used for maintaining the active listener list to avoid + * concurrent modification. + * @param lifecycle {@link DreamOverlayTouchMonitor} will listen to this lifecycle to determine + * whether touch monitoring should be active. + * @param inputSessionFactory This factory will generate the {@link InputSession} requested by + * the monitor. Each session should be unique and valid when + * returned. + * @param handlers This set represents the {@link DreamTouchHandler} instances that will + * participate in touch handling. + */ + @Inject + public DreamOverlayTouchMonitor( + @Main Executor executor, + Lifecycle lifecycle, + InputSessionComponent.Factory inputSessionFactory, + Set handlers) { + mHandlers = handlers; + mInputSessionFactory = inputSessionFactory; + mExecutor = executor; + mLifecycle = lifecycle; + } + + /** + * Initializes the monitor. should only be called once after creation. + */ + public void init() { + mLifecycle.addObserver(mLifecycleObserver); + } + + private void isolate(Set sessions) { + Collection removedSessions = mActiveTouchSessions.stream() + .filter(touchSession -> !sessions.contains(touchSession)) + .collect(Collectors.toCollection(HashSet::new)); + + removedSessions.forEach(touchSession -> touchSession.onRemoved()); + + mActiveTouchSessions.removeAll(removedSessions); + } +} diff --git a/packages/SystemUI/src/com/android/systemui/dreams/touch/DreamTouchHandler.java b/packages/SystemUI/src/com/android/systemui/dreams/touch/DreamTouchHandler.java new file mode 100644 index 0000000000000..c73ff733854d8 --- /dev/null +++ b/packages/SystemUI/src/com/android/systemui/dreams/touch/DreamTouchHandler.java @@ -0,0 +1,92 @@ +/* + * 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.dreams.touch; + +import android.view.GestureDetector; + +import com.android.systemui.shared.system.InputChannelCompat; + +import com.google.common.util.concurrent.ListenableFuture; + +/** + * The {@link DreamTouchHandler} interface provides a way for dream overlay components to observe + * touch events and gestures with the ability to intercept the latter. Touch interaction sequences + * are abstracted as sessions. A session represents the time of first + * {@code android.view.MotionEvent.ACTION_DOWN} event to the last {@link DreamTouchHandler} + * stopping interception of gestures. If no gesture is intercepted, the session continues + * indefinitely. {@link DreamTouchHandler} have the ability to create a stack of sessions, which + * allows for motion logic to be captured in modal states. + */ +public interface DreamTouchHandler { + /** + * A touch session captures the interaction surface of a {@link DreamTouchHandler}. Clients + * register listeners as desired to participate in motion/gesture callbacks. + */ + interface TouchSession { + interface Callback { + void onRemoved(); + } + + void registerCallback(Callback callback); + + /** + * Adds a input event listener for the given session. + * @param inputEventListener + */ + boolean registerInputListener(InputChannelCompat.InputEventListener inputEventListener); + + /** + * Adds a gesture listener for the given session. + * @param gestureListener + */ + boolean registerGestureListener(GestureDetector.OnGestureListener gestureListener); + + /** + * Creates a new {@link TouchSession} that will receive any updates that would have been + * directed to this {@link TouchSession}. + * @return The future which will return a new {@link TouchSession} that will receive + * subsequent events. If the operation fails, {@code null} will be returned. + */ + ListenableFuture push(); + + /** + * Explicitly releases this {@link TouchSession}. The registered listeners will no longer + * receive any further updates. + * @return The future containing the {@link TouchSession} that will receive subsequent + * events. This session will be the direct predecessor of the popped session. {@code null} + * if the popped {@link TouchSession} was the initial session or has already been popped. + */ + ListenableFuture pop(); + } + + /** + * Informed a new touch session has begun. The first touch event will be delivered to any + * listener registered through + * {@link TouchSession#registerInputListener(InputChannelCompat.InputEventListener)} during this + * call. If there are no interactions with this touch session after this method returns, it will + * be dropped. + * @param session + */ + void onSessionStart(TouchSession session); + + /** + * Invoked when a session has ended. This will be invoked for every session completion, even + * those that are removed through {@link TouchSession#pop()}. + * @param session + */ + default void onSessionEnd(TouchSession session) { } +} diff --git a/packages/SystemUI/src/com/android/systemui/dreams/touch/InputSession.java b/packages/SystemUI/src/com/android/systemui/dreams/touch/InputSession.java new file mode 100644 index 0000000000000..43827573470f4 --- /dev/null +++ b/packages/SystemUI/src/com/android/systemui/dreams/touch/InputSession.java @@ -0,0 +1,90 @@ +/* + * 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.dreams.touch; + +import static com.android.systemui.dreams.touch.dagger.DreamTouchModule.INPUT_SESSION_NAME; +import static com.android.systemui.dreams.touch.dagger.DreamTouchModule.PILFER_ON_GESTURE_CONSUME; + +import android.os.Looper; +import android.view.Choreographer; +import android.view.Display; +import android.view.GestureDetector; +import android.view.MotionEvent; + +import com.android.systemui.shared.system.InputChannelCompat; +import com.android.systemui.shared.system.InputMonitorCompat; + +import javax.inject.Inject; +import javax.inject.Named; + +/** + * {@link InputSession} encapsulates components behind input monitoring and handles their lifecycle. + * Sessions are meant to be disposable; actions such as exclusively capturing touch events is modal + * and destroying the sessions allows a reset. Additionally, {@link InputSession} is meant to have + * a single listener for input and gesture. Any broadcasting must be accomplished elsewhere. + */ +public class InputSession { + private final InputMonitorCompat mInputMonitor; + private final InputChannelCompat.InputEventReceiver mInputEventReceiver; + private final GestureDetector mGestureDetector; + + /** + * Default session constructor. + * @param sessionName The session name that will be applied to the underlying + * {@link InputMonitorCompat}. + * @param inputEventListener A listener to receive input events. + * @param gestureListener A listener to receive gesture events. + * @param pilferOnGestureConsume Whether touch events should be pilfered after a gesture has + * been consumed. + */ + @Inject + public InputSession(@Named(INPUT_SESSION_NAME) String sessionName, + InputChannelCompat.InputEventListener inputEventListener, + GestureDetector.OnGestureListener gestureListener, + @Named(PILFER_ON_GESTURE_CONSUME) boolean pilferOnGestureConsume) { + mInputMonitor = new InputMonitorCompat(sessionName, Display.DEFAULT_DISPLAY); + mGestureDetector = new GestureDetector(gestureListener); + + mInputEventReceiver = mInputMonitor.getInputReceiver(Looper.getMainLooper(), + Choreographer.getInstance(), + ev -> { + // Process event. Since sometimes input may be a prerequisite for some + // gesture logic, process input first. + inputEventListener.onInputEvent(ev); + + if (ev instanceof MotionEvent + && mGestureDetector.onTouchEvent((MotionEvent) ev) + && pilferOnGestureConsume) { + mInputMonitor.pilferPointers(); + } + }); + } + + /** + * Destroys the {@link InputSession}, removing any component from listening to future touch + * events. + */ + public void dispose() { + if (mInputEventReceiver != null) { + mInputEventReceiver.dispose(); + } + + if (mInputMonitor != null) { + mInputMonitor.dispose(); + } + } +} diff --git a/packages/SystemUI/src/com/android/systemui/dreams/touch/dagger/DreamTouchModule.java b/packages/SystemUI/src/com/android/systemui/dreams/touch/dagger/DreamTouchModule.java new file mode 100644 index 0000000000000..7b77b593b330e --- /dev/null +++ b/packages/SystemUI/src/com/android/systemui/dreams/touch/dagger/DreamTouchModule.java @@ -0,0 +1,30 @@ +/* + * 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.dreams.touch.dagger; + +import dagger.Module; + +/** + * {@link DreamTouchModule} encapsulates dream touch-related components. + */ +@Module(subcomponents = { + InputSessionComponent.class, +}) +public interface DreamTouchModule { + String INPUT_SESSION_NAME = "INPUT_SESSION_NAME"; + String PILFER_ON_GESTURE_CONSUME = "PILFER_ON_GESTURE_CONSUME"; +} diff --git a/packages/SystemUI/src/com/android/systemui/dreams/touch/dagger/InputSessionComponent.java b/packages/SystemUI/src/com/android/systemui/dreams/touch/dagger/InputSessionComponent.java new file mode 100644 index 0000000000000..ad59a2e2b5c30 --- /dev/null +++ b/packages/SystemUI/src/com/android/systemui/dreams/touch/dagger/InputSessionComponent.java @@ -0,0 +1,51 @@ +/* + * 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.dreams.touch.dagger; + +import static com.android.systemui.dreams.touch.dagger.DreamTouchModule.INPUT_SESSION_NAME; +import static com.android.systemui.dreams.touch.dagger.DreamTouchModule.PILFER_ON_GESTURE_CONSUME; + +import android.view.GestureDetector; + +import com.android.systemui.dreams.touch.InputSession; +import com.android.systemui.shared.system.InputChannelCompat; + +import javax.inject.Named; + +import dagger.BindsInstance; +import dagger.Subcomponent; + +/** + * {@link InputSessionComponent} generates {@link InputSession} with specific instances bound for + * the session name and whether touches should be pilfered when consumed. + */ +@Subcomponent +public interface InputSessionComponent { + /** + * Generates {@link InputSessionComponent}. + */ + @Subcomponent.Factory + interface Factory { + InputSessionComponent create(@Named(INPUT_SESSION_NAME) @BindsInstance String name, + @BindsInstance InputChannelCompat.InputEventListener inputEventListener, + @BindsInstance GestureDetector.OnGestureListener gestureListener, + @Named(PILFER_ON_GESTURE_CONSUME) @BindsInstance boolean pilferOnGestureConsume); + } + + /** */ + InputSession getInputSession(); +} diff --git a/packages/SystemUI/tests/src/com/android/systemui/dreams/DreamOverlayServiceTest.java b/packages/SystemUI/tests/src/com/android/systemui/dreams/DreamOverlayServiceTest.java index 6b156a42dcc81..b3b5fa509105a 100644 --- a/packages/SystemUI/tests/src/com/android/systemui/dreams/DreamOverlayServiceTest.java +++ b/packages/SystemUI/tests/src/com/android/systemui/dreams/DreamOverlayServiceTest.java @@ -33,12 +33,12 @@ import android.view.WindowManagerImpl; import androidx.lifecycle.LifecycleOwner; import androidx.lifecycle.LifecycleRegistry; -import androidx.test.InstrumentationRegistry; import androidx.test.filters.SmallTest; +import com.android.keyguard.KeyguardUpdateMonitor; import com.android.systemui.SysuiTestCase; -import com.android.systemui.SysuiTestableContext; import com.android.systemui.dreams.dagger.DreamOverlayComponent; +import com.android.systemui.dreams.touch.DreamOverlayTouchMonitor; import com.android.systemui.util.concurrency.FakeExecutor; import com.android.systemui.util.time.FakeSystemClock; import com.android.systemui.utils.leaks.LeakCheckedTest; @@ -65,10 +65,6 @@ public class DreamOverlayServiceTest extends SysuiTestCase { @Rule public final LeakCheckedTest.SysuiLeakCheck mLeakCheck = new LeakCheckedTest.SysuiLeakCheck(); - @Rule - public SysuiTestableContext mContext = new SysuiTestableContext( - InstrumentationRegistry.getContext(), mLeakCheck); - WindowManager.LayoutParams mWindowParams = new WindowManager.LayoutParams(); @Mock @@ -89,6 +85,13 @@ public class DreamOverlayServiceTest extends SysuiTestCase { @Mock DreamOverlayContainerViewController mDreamOverlayContainerViewController; + @Mock + KeyguardUpdateMonitor mKeyguardUpdateMonitor; + + @Mock + DreamOverlayTouchMonitor mDreamOverlayTouchMonitor; + + DreamOverlayService mService; @Before @@ -102,6 +105,8 @@ public class DreamOverlayServiceTest extends SysuiTestCase { .thenReturn(mLifecycleOwner); when(mDreamOverlayComponent.getLifecycleRegistry()) .thenReturn(mLifecycleRegistry); + when(mDreamOverlayComponent.getDreamOverlayTouchMonitor()) + .thenReturn(mDreamOverlayTouchMonitor); when(mDreamOverlayComponentFactory .create(any(), any())) .thenReturn(mDreamOverlayComponent); @@ -109,7 +114,8 @@ public class DreamOverlayServiceTest extends SysuiTestCase { .thenReturn(mDreamOverlayContainerView); mService = new DreamOverlayService(mContext, mMainExecutor, - mDreamOverlayComponentFactory); + mDreamOverlayComponentFactory, + mKeyguardUpdateMonitor); final IBinder proxy = mService.onBind(new Intent()); final IDreamOverlay overlay = IDreamOverlay.Stub.asInterface(proxy); diff --git a/packages/SystemUI/tests/src/com/android/systemui/dreams/complication/ComplicationCollectionLiveDataTest.java b/packages/SystemUI/tests/src/com/android/systemui/dreams/complication/ComplicationCollectionLiveDataTest.java index afc0309dd1950..feeea5dff7ea2 100644 --- a/packages/SystemUI/tests/src/com/android/systemui/dreams/complication/ComplicationCollectionLiveDataTest.java +++ b/packages/SystemUI/tests/src/com/android/systemui/dreams/complication/ComplicationCollectionLiveDataTest.java @@ -85,7 +85,9 @@ public class ComplicationCollectionLiveDataTest extends SysuiTestCase { verify(observer).onChanged(collectionCaptor.capture()); - assertThat(collectionCaptor.getValue().equals(targetCollection)).isTrue(); + final Collection collection = collectionCaptor.getValue(); + assertThat(collection.containsAll(targetCollection) + && targetCollection.containsAll(collection)).isTrue(); Mockito.clearInvocations(observer); } } diff --git a/packages/SystemUI/tests/src/com/android/systemui/dreams/complication/ComplicationLayoutParamsTest.java b/packages/SystemUI/tests/src/com/android/systemui/dreams/complication/ComplicationLayoutParamsTest.java index d080bbca06168..967b30d07e639 100644 --- a/packages/SystemUI/tests/src/com/android/systemui/dreams/complication/ComplicationLayoutParamsTest.java +++ b/packages/SystemUI/tests/src/com/android/systemui/dreams/complication/ComplicationLayoutParamsTest.java @@ -81,7 +81,7 @@ public class ComplicationLayoutParamsTest extends SysuiTestCase { final boolean properDirection = (invalidPosition & position) != invalidPosition; try { - final ComplicationLayoutParams params = new ComplicationLayoutParams( + new ComplicationLayoutParams( 100, 100, position, diff --git a/packages/SystemUI/tests/src/com/android/systemui/dreams/touch/DreamOverlayTouchMonitorTest.java b/packages/SystemUI/tests/src/com/android/systemui/dreams/touch/DreamOverlayTouchMonitorTest.java new file mode 100644 index 0000000000000..74b217b2fdbfd --- /dev/null +++ b/packages/SystemUI/tests/src/com/android/systemui/dreams/touch/DreamOverlayTouchMonitorTest.java @@ -0,0 +1,369 @@ +/* + * 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.dreams.touch; + +import static com.google.common.truth.Truth.assertThat; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyBoolean; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import android.testing.AndroidTestingRunner; +import android.util.Pair; +import android.view.GestureDetector; +import android.view.InputEvent; +import android.view.MotionEvent; + +import androidx.lifecycle.DefaultLifecycleObserver; +import androidx.lifecycle.Lifecycle; +import androidx.lifecycle.LifecycleObserver; +import androidx.lifecycle.LifecycleOwner; +import androidx.test.filters.SmallTest; + +import com.android.systemui.SysuiTestCase; +import com.android.systemui.dreams.touch.dagger.InputSessionComponent; +import com.android.systemui.shared.system.InputChannelCompat; +import com.android.systemui.util.concurrency.FakeExecutor; +import com.android.systemui.util.time.FakeSystemClock; + +import com.google.common.util.concurrent.ListenableFuture; + +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.mockito.ArgumentCaptor; +import org.mockito.Mockito; +import org.mockito.MockitoAnnotations; + +import java.util.HashSet; +import java.util.Set; +import java.util.concurrent.ExecutionException; +import java.util.function.Consumer; +import java.util.stream.Collectors; +import java.util.stream.Stream; + +@SmallTest +@RunWith(AndroidTestingRunner.class) +public class DreamOverlayTouchMonitorTest extends SysuiTestCase { + @Before + public void setup() { + MockitoAnnotations.initMocks(this); + } + + private static class Environment { + private final InputSessionComponent.Factory mInputFactory; + private final InputSession mInputSession; + private final Lifecycle mLifecycle; + private final LifecycleOwner mLifecycleOwner; + private final DreamOverlayTouchMonitor mMonitor; + private final DefaultLifecycleObserver mLifecycleObserver; + private final InputChannelCompat.InputEventListener mEventListener; + private final GestureDetector.OnGestureListener mGestureListener; + private final FakeExecutor mExecutor = new FakeExecutor(new FakeSystemClock()); + + Environment(Set handlers) { + mLifecycle = Mockito.mock(Lifecycle.class); + mLifecycleOwner = Mockito.mock(LifecycleOwner.class); + + mInputFactory = Mockito.mock(InputSessionComponent.Factory.class); + final InputSessionComponent inputComponent = Mockito.mock(InputSessionComponent.class); + mInputSession = Mockito.mock(InputSession.class); + + when(mInputFactory.create(any(), any(), any(), anyBoolean())) + .thenReturn(inputComponent); + when(inputComponent.getInputSession()).thenReturn(mInputSession); + + mMonitor = new DreamOverlayTouchMonitor(mExecutor, mLifecycle, mInputFactory, handlers); + mMonitor.init(); + + final ArgumentCaptor lifecycleObserverCaptor = + ArgumentCaptor.forClass(LifecycleObserver.class); + verify(mLifecycle).addObserver(lifecycleObserverCaptor.capture()); + assertThat(lifecycleObserverCaptor.getValue() instanceof DefaultLifecycleObserver) + .isTrue(); + mLifecycleObserver = (DefaultLifecycleObserver) lifecycleObserverCaptor.getValue(); + + updateLifecycle(observer -> observer.first.onResume(observer.second)); + + // Capture creation request. + final ArgumentCaptor inputEventListenerCaptor = + ArgumentCaptor.forClass(InputChannelCompat.InputEventListener.class); + final ArgumentCaptor gestureListenerCaptor = + ArgumentCaptor.forClass(GestureDetector.OnGestureListener.class); + verify(mInputFactory).create(any(), inputEventListenerCaptor.capture(), + gestureListenerCaptor.capture(), + eq(true)); + mEventListener = inputEventListenerCaptor.getValue(); + mGestureListener = gestureListenerCaptor.getValue(); + } + + void executeAll() { + mExecutor.runAllReady(); + } + + void publishInputEvent(InputEvent event) { + mEventListener.onInputEvent(event); + } + + void publishGestureEvent(Consumer listenerConsumer) { + listenerConsumer.accept(mGestureListener); + } + + void updateLifecycle(Consumer> consumer) { + consumer.accept(Pair.create(mLifecycleObserver, mLifecycleOwner)); + } + + void verifyInputSessionDispose() { + verify(mInputSession).dispose(); + Mockito.clearInvocations(mInputSession); + } + } + + @Test + public void testInputEventPropagation() { + final DreamTouchHandler touchHandler = Mockito.mock(DreamTouchHandler.class); + + final Environment environment = new Environment(Stream.of(touchHandler) + .collect(Collectors.toCollection(HashSet::new))); + + final InputEvent initialEvent = Mockito.mock(InputEvent.class); + environment.publishInputEvent(initialEvent); + + // Ensure session started + final InputChannelCompat.InputEventListener eventListener = + registerInputEventListener(touchHandler); + + // First event will be missed since we register after the execution loop, + final InputEvent event = Mockito.mock(InputEvent.class); + environment.publishInputEvent(event); + verify(eventListener).onInputEvent(eq(event)); + } + + @Test + public void testInputGesturePropagation() { + final DreamTouchHandler touchHandler = Mockito.mock(DreamTouchHandler.class); + + final Environment environment = new Environment(Stream.of(touchHandler) + .collect(Collectors.toCollection(HashSet::new))); + + final InputEvent initialEvent = Mockito.mock(InputEvent.class); + environment.publishInputEvent(initialEvent); + + // Ensure session started + final GestureDetector.OnGestureListener gestureListener = + registerGestureListener(touchHandler); + + final MotionEvent event = Mockito.mock(MotionEvent.class); + environment.publishGestureEvent(onGestureListener -> onGestureListener.onShowPress(event)); + verify(gestureListener).onShowPress(eq(event)); + } + + @Test + public void testGestureConsumption() { + final DreamTouchHandler touchHandler = Mockito.mock(DreamTouchHandler.class); + + final Environment environment = new Environment(Stream.of(touchHandler) + .collect(Collectors.toCollection(HashSet::new))); + + final InputEvent initialEvent = Mockito.mock(InputEvent.class); + environment.publishInputEvent(initialEvent); + + // Ensure session started + final GestureDetector.OnGestureListener gestureListener = + registerGestureListener(touchHandler); + + when(gestureListener.onDown(any())).thenReturn(true); + final MotionEvent event = Mockito.mock(MotionEvent.class); + environment.publishGestureEvent(onGestureListener -> { + assertThat(onGestureListener.onDown(event)).isTrue(); + }); + + verify(gestureListener).onDown(eq(event)); + } + + @Test + public void testBroadcast() { + final DreamTouchHandler touchHandler = Mockito.mock(DreamTouchHandler.class); + final DreamTouchHandler touchHandler2 = Mockito.mock(DreamTouchHandler.class); + + final Environment environment = new Environment(Stream.of(touchHandler, touchHandler2) + .collect(Collectors.toCollection(HashSet::new))); + + final InputEvent initialEvent = Mockito.mock(InputEvent.class); + environment.publishInputEvent(initialEvent); + + final HashSet inputListeners = new HashSet<>(); + + inputListeners.add(registerInputEventListener(touchHandler)); + inputListeners.add(registerInputEventListener(touchHandler)); + inputListeners.add(registerInputEventListener(touchHandler2)); + + final MotionEvent event = Mockito.mock(MotionEvent.class); + environment.publishInputEvent(event); + + inputListeners + .stream() + .forEach(inputEventListener -> verify(inputEventListener).onInputEvent(event)); + } + + @Test + public void testPush() throws InterruptedException, ExecutionException { + final DreamTouchHandler touchHandler = Mockito.mock(DreamTouchHandler.class); + + final Environment environment = new Environment(Stream.of(touchHandler) + .collect(Collectors.toCollection(HashSet::new))); + + final InputEvent initialEvent = Mockito.mock(InputEvent.class); + environment.publishInputEvent(initialEvent); + + final DreamTouchHandler.TouchSession session = captureSession(touchHandler); + final InputChannelCompat.InputEventListener eventListener = + registerInputEventListener(session); + + final ListenableFuture frontSessionFuture = session.push(); + environment.executeAll(); + final DreamTouchHandler.TouchSession frontSession = frontSessionFuture.get(); + final InputChannelCompat.InputEventListener frontEventListener = + registerInputEventListener(frontSession); + + final MotionEvent event = Mockito.mock(MotionEvent.class); + environment.publishInputEvent(event); + + verify(frontEventListener).onInputEvent(eq(event)); + verify(eventListener, never()).onInputEvent(any()); + + Mockito.clearInvocations(eventListener, frontEventListener); + + ListenableFuture sessionFuture = frontSession.pop(); + environment.executeAll(); + + DreamTouchHandler.TouchSession returnedSession = sessionFuture.get(); + assertThat(session == returnedSession).isTrue(); + + environment.executeAll(); + + final MotionEvent followupEvent = Mockito.mock(MotionEvent.class); + environment.publishInputEvent(followupEvent); + + verify(eventListener).onInputEvent(eq(followupEvent)); + verify(frontEventListener, never()).onInputEvent(any()); + } + + @Test + public void testPause() { + final DreamTouchHandler touchHandler = Mockito.mock(DreamTouchHandler.class); + + final Environment environment = new Environment(Stream.of(touchHandler) + .collect(Collectors.toCollection(HashSet::new))); + + final InputEvent initialEvent = Mockito.mock(InputEvent.class); + environment.publishInputEvent(initialEvent); + + // Ensure session started + final InputChannelCompat.InputEventListener eventListener = + registerInputEventListener(touchHandler); + + // First event will be missed since we register after the execution loop, + final InputEvent event = Mockito.mock(InputEvent.class); + environment.publishInputEvent(event); + verify(eventListener).onInputEvent(eq(event)); + + environment.updateLifecycle(observerOwnerPair -> { + observerOwnerPair.first.onPause(observerOwnerPair.second); + }); + + environment.verifyInputSessionDispose(); + } + + @Test + public void testPilfering() { + final DreamTouchHandler touchHandler1 = Mockito.mock(DreamTouchHandler.class); + final DreamTouchHandler touchHandler2 = Mockito.mock(DreamTouchHandler.class); + + final Environment environment = new Environment(Stream.of(touchHandler1, touchHandler2) + .collect(Collectors.toCollection(HashSet::new))); + + final InputEvent initialEvent = Mockito.mock(InputEvent.class); + environment.publishInputEvent(initialEvent); + + final DreamTouchHandler.TouchSession session1 = captureSession(touchHandler1); + final GestureDetector.OnGestureListener gestureListener1 = + registerGestureListener(session1); + + final DreamTouchHandler.TouchSession session2 = captureSession(touchHandler2); + final GestureDetector.OnGestureListener gestureListener2 = + registerGestureListener(session2); + when(gestureListener2.onDown(any())).thenReturn(true); + + final MotionEvent gestureEvent = Mockito.mock(MotionEvent.class); + environment.publishGestureEvent( + onGestureListener -> onGestureListener.onDown(gestureEvent)); + + Mockito.clearInvocations(gestureListener1, gestureListener2); + + final MotionEvent followupEvent = Mockito.mock(MotionEvent.class); + environment.publishGestureEvent( + onGestureListener -> onGestureListener.onDown(followupEvent)); + + verify(gestureListener1, never()).onDown(any()); + verify(gestureListener2).onDown(eq(followupEvent)); + } + + public GestureDetector.OnGestureListener registerGestureListener(DreamTouchHandler handler) { + final GestureDetector.OnGestureListener gestureListener = Mockito.mock( + GestureDetector.OnGestureListener.class); + final ArgumentCaptor sessionCaptor = + ArgumentCaptor.forClass(DreamTouchHandler.TouchSession.class); + verify(handler).onSessionStart(sessionCaptor.capture()); + sessionCaptor.getValue().registerGestureListener(gestureListener); + + return gestureListener; + } + + public GestureDetector.OnGestureListener registerGestureListener( + DreamTouchHandler.TouchSession session) { + final GestureDetector.OnGestureListener gestureListener = Mockito.mock( + GestureDetector.OnGestureListener.class); + session.registerGestureListener(gestureListener); + + return gestureListener; + } + + public InputChannelCompat.InputEventListener registerInputEventListener( + DreamTouchHandler.TouchSession session) { + final InputChannelCompat.InputEventListener eventListener = Mockito.mock( + InputChannelCompat.InputEventListener.class); + session.registerInputListener(eventListener); + + return eventListener; + } + + public DreamTouchHandler.TouchSession captureSession(DreamTouchHandler handler) { + final ArgumentCaptor sessionCaptor = + ArgumentCaptor.forClass(DreamTouchHandler.TouchSession.class); + verify(handler).onSessionStart(sessionCaptor.capture()); + return sessionCaptor.getValue(); + } + + public InputChannelCompat.InputEventListener registerInputEventListener( + DreamTouchHandler handler) { + return registerInputEventListener(captureSession(handler)); + } +}