Merge "Add API to register listener for Sticky modifier state" into main
diff --git a/core/java/android/hardware/input/IInputManager.aidl b/core/java/android/hardware/input/IInputManager.aidl
index 6626baf..7bea9ae 100644
--- a/core/java/android/hardware/input/IInputManager.aidl
+++ b/core/java/android/hardware/input/IInputManager.aidl
@@ -25,6 +25,7 @@
 import android.hardware.input.IInputDeviceBatteryState;
 import android.hardware.input.IKeyboardBacklightListener;
 import android.hardware.input.IKeyboardBacklightState;
+import android.hardware.input.IStickyModifierStateListener;
 import android.hardware.input.ITabletModeChangedListener;
 import android.hardware.input.TouchCalibration;
 import android.os.CombinedVibration;
@@ -241,4 +242,14 @@
     void unregisterKeyboardBacklightListener(IKeyboardBacklightListener listener);
 
     HostUsiVersion getHostUsiVersionFromDisplayConfig(int displayId);
+
+    @EnforcePermission("MONITOR_STICKY_MODIFIER_STATE")
+    @JavaPassthrough(annotation="@android.annotation.RequiresPermission(value = "
+            + "android.Manifest.permission.MONITOR_STICKY_MODIFIER_STATE)")
+    void registerStickyModifierStateListener(IStickyModifierStateListener listener);
+
+    @EnforcePermission("MONITOR_STICKY_MODIFIER_STATE")
+    @JavaPassthrough(annotation="@android.annotation.RequiresPermission(value = "
+            + "android.Manifest.permission.MONITOR_STICKY_MODIFIER_STATE)")
+    void unregisterStickyModifierStateListener(IStickyModifierStateListener listener);
 }
diff --git a/core/java/android/hardware/input/IStickyModifierStateListener.aidl b/core/java/android/hardware/input/IStickyModifierStateListener.aidl
new file mode 100644
index 0000000..bd139ab
--- /dev/null
+++ b/core/java/android/hardware/input/IStickyModifierStateListener.aidl
@@ -0,0 +1,26 @@
+/*
+ * Copyright 2024 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package android.hardware.input;
+
+/** @hide */
+oneway interface IStickyModifierStateListener {
+
+    /**
+     * Called when the sticky modifier state is changed when A11y Sticky keys feature is enabled
+     */
+    void onStickyModifierStateChanged(int modifierState, int lockedModifierState);
+}
diff --git a/core/java/android/hardware/input/InputManager.java b/core/java/android/hardware/input/InputManager.java
index f941ad8..4ebbde7 100644
--- a/core/java/android/hardware/input/InputManager.java
+++ b/core/java/android/hardware/input/InputManager.java
@@ -1297,6 +1297,42 @@
     }
 
     /**
+     * Registers a Sticky modifier state change listener to be notified about {@link
+     * StickyModifierState} changes.
+     *
+     * @param executor an executor on which the callback will be called
+     * @param listener the {@link StickyModifierStateListener}
+     * @throws IllegalArgumentException if {@code listener} has already been registered previously.
+     * @throws NullPointerException     if {@code listener} or {@code executor} is null.
+     * @hide
+     * @see #unregisterStickyModifierStateListener(StickyModifierStateListener)
+     */
+    @RequiresPermission(Manifest.permission.MONITOR_STICKY_MODIFIER_STATE)
+    public void registerStickyModifierStateListener(@NonNull Executor executor,
+            @NonNull StickyModifierStateListener listener) throws IllegalArgumentException {
+        if (!InputSettings.isAccessibilityStickyKeysFeatureEnabled()) {
+            return;
+        }
+        mGlobal.registerStickyModifierStateListener(executor, listener);
+    }
+
+    /**
+     * Unregisters a previously added Sticky modifier state change listener.
+     *
+     * @param listener the {@link StickyModifierStateListener}
+     * @hide
+     * @see #registerStickyModifierStateListener(Executor, StickyModifierStateListener)
+     */
+    @RequiresPermission(Manifest.permission.MONITOR_STICKY_MODIFIER_STATE)
+    public void unregisterStickyModifierStateListener(
+            @NonNull StickyModifierStateListener listener) {
+        if (!InputSettings.isAccessibilityStickyKeysFeatureEnabled()) {
+            return;
+        }
+        mGlobal.unregisterStickyModifierStateListener(listener);
+    }
+
+    /**
      * A callback used to be notified about battery state changes for an input device. The
      * {@link #onBatteryStateChanged(int, long, BatteryState)} method will be called once after the
      * listener is successfully registered to provide the initial battery state of the device.
@@ -1378,4 +1414,23 @@
         void onKeyboardBacklightChanged(
                 int deviceId, @NonNull KeyboardBacklightState state, boolean isTriggeredByKeyPress);
     }
+
+    /**
+     * A callback used to be notified about sticky modifier state changes when A11y Sticky keys
+     * feature is enabled.
+     *
+     * @see #registerStickyModifierStateListener(Executor, StickyModifierStateListener)
+     * @see #unregisterStickyModifierStateListener(StickyModifierStateListener)
+     * @hide
+     */
+    public interface StickyModifierStateListener {
+        /**
+         * Called when the sticky modifier state changes.
+         * This method will be called once after the listener is successfully registered to provide
+         * the initial modifier state.
+         *
+         * @param state the new sticky modifier state, never null.
+         */
+        void onStickyModifierStateChanged(@NonNull StickyModifierState state);
+    }
 }
diff --git a/core/java/android/hardware/input/InputManagerGlobal.java b/core/java/android/hardware/input/InputManagerGlobal.java
index 24a6911..7c104a0 100644
--- a/core/java/android/hardware/input/InputManagerGlobal.java
+++ b/core/java/android/hardware/input/InputManagerGlobal.java
@@ -27,6 +27,7 @@
 import android.hardware.input.InputManager.InputDeviceListener;
 import android.hardware.input.InputManager.KeyboardBacklightListener;
 import android.hardware.input.InputManager.OnTabletModeChangedListener;
+import android.hardware.input.InputManager.StickyModifierStateListener;
 import android.hardware.lights.Light;
 import android.hardware.lights.LightState;
 import android.hardware.lights.LightsManager;
@@ -52,6 +53,7 @@
 import android.view.InputEvent;
 import android.view.InputMonitor;
 import android.view.KeyCharacterMap;
+import android.view.KeyEvent;
 import android.view.PointerIcon;
 
 import com.android.internal.annotations.GuardedBy;
@@ -100,6 +102,14 @@
     @GuardedBy("mKeyboardBacklightListenerLock")
     @Nullable private IKeyboardBacklightListener mKeyboardBacklightListener;
 
+    private final Object mStickyModifierStateListenerLock = new Object();
+    @GuardedBy("mStickyModifierStateListenerLock")
+    @Nullable
+    private ArrayList<StickyModifierStateListenerDelegate> mStickyModifierStateListeners;
+    @GuardedBy("mStickyModifierStateListenerLock")
+    @Nullable
+    private IStickyModifierStateListener mStickyModifierStateListener;
+
     // InputDeviceSensorManager gets notified synchronously from the binder thread when input
     // devices change, so it must be synchronized with the input device listeners.
     @GuardedBy("mInputDeviceListeners")
@@ -905,6 +915,158 @@
         }
     }
 
+    private static final class StickyModifierStateListenerDelegate {
+        final InputManager.StickyModifierStateListener mListener;
+        final Executor mExecutor;
+
+        StickyModifierStateListenerDelegate(StickyModifierStateListener listener,
+                Executor executor) {
+            mListener = listener;
+            mExecutor = executor;
+        }
+
+        void notifyStickyModifierStateChange(int modifierState, int lockedModifierState) {
+            mExecutor.execute(() ->
+                    mListener.onStickyModifierStateChanged(
+                            new LocalStickyModifierState(modifierState, lockedModifierState)));
+        }
+    }
+
+    private class LocalStickyModifierStateListener extends IStickyModifierStateListener.Stub {
+
+        @Override
+        public void onStickyModifierStateChanged(int modifierState, int lockedModifierState) {
+            synchronized (mStickyModifierStateListenerLock) {
+                if (mStickyModifierStateListeners == null) return;
+                final int numListeners = mStickyModifierStateListeners.size();
+                for (int i = 0; i < numListeners; i++) {
+                    mStickyModifierStateListeners.get(i)
+                            .notifyStickyModifierStateChange(modifierState, lockedModifierState);
+                }
+            }
+        }
+    }
+
+    // Implementation of the android.hardware.input.StickyModifierState interface used to report
+    // the sticky modifier state via the StickyModifierStateListener interfaces.
+    private static final class LocalStickyModifierState extends StickyModifierState {
+
+        private final int mModifierState;
+        private final int mLockedModifierState;
+
+        LocalStickyModifierState(int modifierState, int lockedModifierState) {
+            mModifierState = modifierState;
+            mLockedModifierState = lockedModifierState;
+        }
+
+        @Override
+        public boolean isShiftModifierOn() {
+            return (mModifierState & KeyEvent.META_SHIFT_ON) != 0;
+        }
+
+        @Override
+        public boolean isShiftModifierLocked() {
+            return (mLockedModifierState & KeyEvent.META_SHIFT_ON) != 0;
+        }
+
+        @Override
+        public boolean isCtrlModifierOn() {
+            return (mModifierState & KeyEvent.META_CTRL_ON) != 0;
+        }
+
+        @Override
+        public boolean isCtrlModifierLocked() {
+            return (mLockedModifierState & KeyEvent.META_CTRL_ON) != 0;
+        }
+
+        @Override
+        public boolean isMetaModifierOn() {
+            return (mModifierState & KeyEvent.META_META_ON) != 0;
+        }
+
+        @Override
+        public boolean isMetaModifierLocked() {
+            return (mLockedModifierState & KeyEvent.META_META_ON) != 0;
+        }
+
+        @Override
+        public boolean isAltModifierOn() {
+            return (mModifierState & KeyEvent.META_ALT_LEFT_ON) != 0;
+        }
+
+        @Override
+        public boolean isAltModifierLocked() {
+            return (mLockedModifierState & KeyEvent.META_ALT_LEFT_ON) != 0;
+        }
+
+        @Override
+        public boolean isAltGrModifierOn() {
+            return (mModifierState & KeyEvent.META_ALT_RIGHT_ON) != 0;
+        }
+
+        @Override
+        public boolean isAltGrModifierLocked() {
+            return (mLockedModifierState & KeyEvent.META_ALT_RIGHT_ON) != 0;
+        }
+    }
+
+    /**
+     * @see InputManager#registerStickyModifierStateListener(Executor, StickyModifierStateListener)
+     */
+    @RequiresPermission(Manifest.permission.MONITOR_STICKY_MODIFIER_STATE)
+    void registerStickyModifierStateListener(@NonNull Executor executor,
+            @NonNull StickyModifierStateListener listener) throws IllegalArgumentException {
+        Objects.requireNonNull(executor, "executor should not be null");
+        Objects.requireNonNull(listener, "listener should not be null");
+
+        synchronized (mStickyModifierStateListenerLock) {
+            if (mStickyModifierStateListener == null) {
+                mStickyModifierStateListeners = new ArrayList<>();
+                mStickyModifierStateListener = new LocalStickyModifierStateListener();
+
+                try {
+                    mIm.registerStickyModifierStateListener(mStickyModifierStateListener);
+                } catch (RemoteException e) {
+                    throw e.rethrowFromSystemServer();
+                }
+            }
+            final int numListeners = mStickyModifierStateListeners.size();
+            for (int i = 0; i < numListeners; i++) {
+                if (mStickyModifierStateListeners.get(i).mListener == listener) {
+                    throw new IllegalArgumentException("Listener has already been registered!");
+                }
+            }
+            StickyModifierStateListenerDelegate delegate =
+                    new StickyModifierStateListenerDelegate(listener, executor);
+            mStickyModifierStateListeners.add(delegate);
+        }
+    }
+
+    /**
+     * @see InputManager#unregisterStickyModifierStateListener(StickyModifierStateListener)
+     */
+    @RequiresPermission(Manifest.permission.MONITOR_STICKY_MODIFIER_STATE)
+    void unregisterStickyModifierStateListener(
+            @NonNull StickyModifierStateListener listener) {
+        Objects.requireNonNull(listener, "listener should not be null");
+
+        synchronized (mStickyModifierStateListenerLock) {
+            if (mStickyModifierStateListeners == null) {
+                return;
+            }
+            mStickyModifierStateListeners.removeIf((delegate) -> delegate.mListener == listener);
+            if (mStickyModifierStateListeners.isEmpty()) {
+                try {
+                    mIm.unregisterStickyModifierStateListener(mStickyModifierStateListener);
+                } catch (RemoteException e) {
+                    throw e.rethrowFromSystemServer();
+                }
+                mStickyModifierStateListeners = null;
+                mStickyModifierStateListener = null;
+            }
+        }
+    }
+
     /**
      * @see InputManager#getKeyboardLayoutsForInputDevice(InputDeviceIdentifier)
      */
diff --git a/core/java/android/hardware/input/StickyModifierState.java b/core/java/android/hardware/input/StickyModifierState.java
new file mode 100644
index 0000000..a3f7a0a
--- /dev/null
+++ b/core/java/android/hardware/input/StickyModifierState.java
@@ -0,0 +1,127 @@
+/*
+ * Copyright 2024 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package android.hardware.input;
+
+/**
+ * The StickyModifierState class is a representation of a modifier state when A11y Sticky keys
+ * feature is enabled
+ *
+ * @hide
+ */
+public abstract class StickyModifierState {
+
+    /**
+     * Represents whether current sticky modifier state includes 'Shift' modifier.
+     * <p> If {@code true} the next {@link android.view.KeyEvent} will contain 'Shift' modifier in
+     * its metaState.
+     *
+     * @return whether Shift modifier key is on.
+     */
+    public abstract boolean isShiftModifierOn();
+
+    /**
+     * Represents whether current sticky modifier state includes 'Shift' modifier, and it is
+     * locked.
+     * <p> If {@code true} any subsequent {@link android.view.KeyEvent} will contain 'Shift'
+     * modifier in its metaState and this state will remain sticky (will not be cleared), until
+     * user presses 'Shift' key again to clear the locked state.
+     *
+     * @return whether Shift modifier key is locked.
+     */
+    public abstract boolean isShiftModifierLocked();
+
+    /**
+     * Represents whether current sticky modifier state includes 'Ctrl' modifier.
+     * <p> If {@code true} the next {@link android.view.KeyEvent} will contain 'Ctrl' modifier in
+     * its metaState.
+     *
+     * @return whether Ctrl modifier key is on.
+     */
+    public abstract boolean isCtrlModifierOn();
+
+    /**
+     * Represents whether current sticky modifier state includes 'Ctrl' modifier, and it is
+     * locked.
+     * <p> If {@code true} any subsequent {@link android.view.KeyEvent} will contain 'Ctrl'
+     * modifier in its metaState and this state will remain sticky (will not be cleared), until
+     * user presses 'Ctrl' key again to clear the locked state.
+     *
+     * @return whether Ctrl modifier key is locked.
+     */
+    public abstract boolean isCtrlModifierLocked();
+
+    /**
+     * Represents whether current sticky modifier state includes 'Meta' modifier.
+     * <p> If {@code true} the next {@link android.view.KeyEvent} will contain 'Meta' modifier in
+     * its metaState.
+     *
+     * @return whether Meta modifier key is on.
+     */
+    public abstract boolean isMetaModifierOn();
+
+    /**
+     * Represents whether current sticky modifier state includes 'Meta' modifier, and it is
+     * locked.
+     * <p> If {@code true} any subsequent {@link android.view.KeyEvent} will contain 'Meta'
+     * modifier in its metaState and this state will remain sticky (will not be cleared), until
+     * user presses 'Meta' key again to clear the locked state.
+     *
+     * @return whether Meta modifier key is locked.
+     */
+    public abstract boolean isMetaModifierLocked();
+
+    /**
+     * Represents whether current sticky modifier state includes 'Alt' modifier.
+     * <p> If {@code true} the next {@link android.view.KeyEvent} will contain 'Alt' modifier in
+     * its metaState.
+     *
+     * @return whether Alt modifier key is on.
+     */
+    public abstract boolean isAltModifierOn();
+
+    /**
+     * Represents whether current sticky modifier state includes 'Alt' modifier, and it is
+     * locked.
+     * <p> If {@code true} any subsequent {@link android.view.KeyEvent} will contain 'Alt'
+     * modifier in its metaState and this state will remain sticky (will not be cleared), until
+     * user presses 'Alt' key again to clear the locked state.
+     *
+     * @return whether Alt modifier key is locked.
+     */
+    public abstract boolean isAltModifierLocked();
+
+    /**
+     * Represents whether current sticky modifier state includes 'AltGr' modifier.
+     * <p> If {@code true} the next {@link android.view.KeyEvent} will contain 'AltGr' modifier in
+     * its metaState.
+     *
+     * @return whether AltGr modifier key is on.
+     */
+    public abstract boolean isAltGrModifierOn();
+
+    /**
+     * Represents whether current sticky modifier state includes 'AltGr' modifier, and it is
+     * locked.
+     * <p> If {@code true} any subsequent {@link android.view.KeyEvent} will contain 'AltGr'
+     * modifier in its metaState and this state will remain sticky (will not be cleared), until
+     * user presses 'AltGr' key again to clear the locked state.
+     *
+     * @return whether AltGr modifier key is locked.
+     */
+    public abstract boolean isAltGrModifierLocked();
+}
+
diff --git a/core/res/AndroidManifest.xml b/core/res/AndroidManifest.xml
index 232a36f..e65bfab 100644
--- a/core/res/AndroidManifest.xml
+++ b/core/res/AndroidManifest.xml
@@ -7692,6 +7692,13 @@
     <permission android:name="android.permission.MONITOR_KEYBOARD_BACKLIGHT"
                 android:protectionLevel="signature" />
 
+    <!-- Allows low-level access to monitor sticky modifier state changes when A11Y Sticky keys
+         feature is enabled.
+         <p>Not for use by third-party applications.
+         @hide -->
+    <permission android:name="android.permission.MONITOR_STICKY_MODIFIER_STATE"
+                android:protectionLevel="signature" />
+
     <uses-permission android:name="android.permission.HANDLE_QUERY_PACKAGE_RESTART" />
 
     <!-- Allows financed device kiosk apps to perform actions on the Device Lock service
diff --git a/services/core/java/com/android/server/input/InputManagerService.java b/services/core/java/com/android/server/input/InputManagerService.java
index 087c525..36dac83 100644
--- a/services/core/java/com/android/server/input/InputManagerService.java
+++ b/services/core/java/com/android/server/input/InputManagerService.java
@@ -48,6 +48,7 @@
 import android.hardware.input.IInputManager;
 import android.hardware.input.IInputSensorEventListener;
 import android.hardware.input.IKeyboardBacklightListener;
+import android.hardware.input.IStickyModifierStateListener;
 import android.hardware.input.ITabletModeChangedListener;
 import android.hardware.input.InputDeviceIdentifier;
 import android.hardware.input.InputManager;
@@ -319,6 +320,9 @@
     // Manages Keyboard backlight
     private final KeyboardBacklightControllerInterface mKeyboardBacklightController;
 
+    // Manages Sticky modifier state
+    private final StickyModifierStateController mStickyModifierStateController;
+
     // Manages Keyboard modifier keys remapping
     private final KeyRemapper mKeyRemapper;
 
@@ -464,6 +468,7 @@
                 ? new KeyboardBacklightController(mContext, mNative, mDataStore,
                         injector.getLooper(), injector.getUEventManager())
                 : new KeyboardBacklightControllerInterface() {};
+        mStickyModifierStateController = new StickyModifierStateController();
         mKeyRemapper = new KeyRemapper(mContext, mNative, mDataStore, injector.getLooper());
 
         mUseDevInputEventForAudioJack =
@@ -2827,6 +2832,33 @@
                         yPosition)).sendToTarget();
     }
 
+    @Override
+    @EnforcePermission(Manifest.permission.MONITOR_STICKY_MODIFIER_STATE)
+    public void registerStickyModifierStateListener(
+            @NonNull IStickyModifierStateListener listener) {
+        super.registerStickyModifierStateListener_enforcePermission();
+        Objects.requireNonNull(listener);
+        mStickyModifierStateController.registerStickyModifierStateListener(listener,
+                Binder.getCallingPid());
+    }
+
+    @Override
+    @EnforcePermission(Manifest.permission.MONITOR_STICKY_MODIFIER_STATE)
+    public void unregisterStickyModifierStateListener(
+            @NonNull IStickyModifierStateListener listener) {
+        super.unregisterStickyModifierStateListener_enforcePermission();
+        Objects.requireNonNull(listener);
+        mStickyModifierStateController.unregisterStickyModifierStateListener(listener,
+                Binder.getCallingPid());
+    }
+
+    // Native callback
+    @SuppressWarnings("unused")
+    void notifyStickyModifierStateChanged(int modifierState, int lockedModifierState) {
+        mStickyModifierStateController.notifyStickyModifierStateChanged(modifierState,
+                lockedModifierState);
+    }
+
     // Native callback.
     @SuppressWarnings("unused")
     boolean isInputMethodConnectionActive() {
diff --git a/services/core/java/com/android/server/input/StickyModifierStateController.java b/services/core/java/com/android/server/input/StickyModifierStateController.java
new file mode 100644
index 0000000..5a22c10
--- /dev/null
+++ b/services/core/java/com/android/server/input/StickyModifierStateController.java
@@ -0,0 +1,133 @@
+/*
+ * Copyright 2024 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.server.input;
+
+import android.annotation.BinderThread;
+import android.hardware.input.IStickyModifierStateListener;
+import android.os.IBinder;
+import android.os.RemoteException;
+import android.util.Log;
+import android.util.Slog;
+import android.util.SparseArray;
+
+import com.android.internal.annotations.GuardedBy;
+
+/**
+ * A thread-safe component of {@link InputManagerService} responsible for managing the sticky
+ * modifier state for A11y Sticky keys feature.
+ */
+final class StickyModifierStateController {
+
+    private static final String TAG = "ModifierStateController";
+
+    // To enable these logs, run:
+    // 'adb shell setprop log.tag.ModifierStateController DEBUG' (requires restart)
+    private static final boolean DEBUG = Log.isLoggable(TAG, Log.DEBUG);
+
+    // List of currently registered sticky modifier state listeners
+    @GuardedBy("mStickyModifierStateListenerRecords")
+    private final SparseArray<StickyModifierStateListenerRecord>
+            mStickyModifierStateListenerRecords = new SparseArray<>();
+
+    public void notifyStickyModifierStateChanged(int modifierState, int lockedModifierState) {
+        if (DEBUG) {
+            Slog.d(TAG, "Sticky modifier state changed, modifierState = " + modifierState
+                    + ", lockedModifierState = " + lockedModifierState);
+        }
+
+        synchronized (mStickyModifierStateListenerRecords) {
+            for (int i = 0; i < mStickyModifierStateListenerRecords.size(); i++) {
+                mStickyModifierStateListenerRecords.valueAt(i).notifyStickyModifierStateChanged(
+                        modifierState, lockedModifierState);
+            }
+        }
+    }
+
+    /** Register the sticky modifier state listener for a process. */
+    @BinderThread
+    public void registerStickyModifierStateListener(IStickyModifierStateListener listener,
+            int pid) {
+        synchronized (mStickyModifierStateListenerRecords) {
+            if (mStickyModifierStateListenerRecords.get(pid) != null) {
+                throw new IllegalStateException("The calling process has already registered "
+                        + "a StickyModifierStateListener.");
+            }
+            StickyModifierStateListenerRecord record = new StickyModifierStateListenerRecord(pid,
+                    listener);
+            try {
+                listener.asBinder().linkToDeath(record, 0);
+            } catch (RemoteException ex) {
+                throw new RuntimeException(ex);
+            }
+            mStickyModifierStateListenerRecords.put(pid, record);
+        }
+    }
+
+    /** Unregister the sticky modifier state listener for a process. */
+    @BinderThread
+    public void unregisterStickyModifierStateListener(IStickyModifierStateListener listener,
+            int pid) {
+        synchronized (mStickyModifierStateListenerRecords) {
+            StickyModifierStateListenerRecord record = mStickyModifierStateListenerRecords.get(pid);
+            if (record == null) {
+                throw new IllegalStateException("The calling process has no registered "
+                        + "StickyModifierStateListener.");
+            }
+            if (record.mListener.asBinder() != listener.asBinder()) {
+                throw new IllegalStateException("The calling process has a different registered "
+                        + "StickyModifierStateListener.");
+            }
+            record.mListener.asBinder().unlinkToDeath(record, 0);
+            mStickyModifierStateListenerRecords.remove(pid);
+        }
+    }
+
+    private void onStickyModifierStateListenerDied(int pid) {
+        synchronized (mStickyModifierStateListenerRecords) {
+            mStickyModifierStateListenerRecords.remove(pid);
+        }
+    }
+
+    // A record of a registered sticky modifier state listener from one process.
+    private class StickyModifierStateListenerRecord implements IBinder.DeathRecipient {
+        public final int mPid;
+        public final IStickyModifierStateListener mListener;
+
+        StickyModifierStateListenerRecord(int pid, IStickyModifierStateListener listener) {
+            mPid = pid;
+            mListener = listener;
+        }
+
+        @Override
+        public void binderDied() {
+            if (DEBUG) {
+                Slog.d(TAG, "Sticky modifier state listener for pid " + mPid + " died.");
+            }
+            onStickyModifierStateListenerDied(mPid);
+        }
+
+        public void notifyStickyModifierStateChanged(int modifierState, int lockedModifierState) {
+            try {
+                mListener.onStickyModifierStateChanged(modifierState, lockedModifierState);
+            } catch (RemoteException ex) {
+                Slog.w(TAG, "Failed to notify process " + mPid
+                        + " that sticky modifier state changed, assuming it died.", ex);
+                binderDied();
+            }
+        }
+    }
+}
diff --git a/services/core/jni/com_android_server_input_InputManagerService.cpp b/services/core/jni/com_android_server_input_InputManagerService.cpp
index 9ba0a2a..afb0b20 100644
--- a/services/core/jni/com_android_server_input_InputManagerService.cpp
+++ b/services/core/jni/com_android_server_input_InputManagerService.cpp
@@ -114,6 +114,7 @@
     jmethodID notifyFocusChanged;
     jmethodID notifySensorEvent;
     jmethodID notifySensorAccuracy;
+    jmethodID notifyStickyModifierStateChanged;
     jmethodID notifyStylusGestureStarted;
     jmethodID isInputMethodConnectionActive;
     jmethodID notifyVibratorState;
@@ -270,7 +271,8 @@
 class NativeInputManager : public virtual InputReaderPolicyInterface,
                            public virtual InputDispatcherPolicyInterface,
                            public virtual PointerControllerPolicyInterface,
-                           public virtual PointerChoreographerPolicyInterface {
+                           public virtual PointerChoreographerPolicyInterface,
+                           public virtual InputFilterPolicyInterface {
 protected:
     virtual ~NativeInputManager();
 
@@ -388,6 +390,10 @@
             PointerControllerInterface::ControllerType type) override;
     void notifyPointerDisplayIdChanged(int32_t displayId, const FloatPoint& position) override;
 
+    /* --- InputFilterPolicyInterface implementation --- */
+    void notifyStickyModifierStateChanged(uint32_t modifierState,
+                                          uint32_t lockedModifierState) override;
+
 private:
     sp<InputManagerInterface> mInputManager;
 
@@ -477,7 +483,7 @@
 
     mServiceObj = env->NewGlobalRef(serviceObj);
 
-    InputManager* im = new InputManager(this, *this, *this);
+    InputManager* im = new InputManager(this, *this, *this, *this);
     mInputManager = im;
     defaultServiceManager()->addService(String16("inputflinger"), im);
 }
@@ -806,6 +812,14 @@
     checkAndClearExceptionFromCallback(env, "onPointerDisplayIdChanged");
 }
 
+void NativeInputManager::notifyStickyModifierStateChanged(uint32_t modifierState,
+                                                          uint32_t lockedModifierState) {
+    JNIEnv* env = jniEnv();
+    env->CallVoidMethod(mServiceObj, gServiceClassInfo.notifyStickyModifierStateChanged,
+                        modifierState, lockedModifierState);
+    checkAndClearExceptionFromCallback(env, "notifyStickyModifierStateChanged");
+}
+
 sp<SurfaceControl> NativeInputManager::getParentSurfaceForPointers(int displayId) {
     JNIEnv* env = jniEnv();
     jlong nativeSurfaceControlPtr =
@@ -2957,6 +2971,9 @@
     GET_METHOD_ID(gServiceClassInfo.onPointerDisplayIdChanged, clazz, "onPointerDisplayIdChanged",
                   "(IFF)V");
 
+    GET_METHOD_ID(gServiceClassInfo.notifyStickyModifierStateChanged, clazz,
+                  "notifyStickyModifierStateChanged", "(II)V");
+
     GET_METHOD_ID(gServiceClassInfo.onPointerDownOutsideFocus, clazz,
             "onPointerDownOutsideFocus", "(Landroid/os/IBinder;)V");
 
diff --git a/tests/Input/src/android/hardware/input/StickyModifierStateListenerTest.kt b/tests/Input/src/android/hardware/input/StickyModifierStateListenerTest.kt
new file mode 100644
index 0000000..e2b0c36
--- /dev/null
+++ b/tests/Input/src/android/hardware/input/StickyModifierStateListenerTest.kt
@@ -0,0 +1,215 @@
+/*
+ * Copyright 2024 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package android.hardware.input
+
+import android.content.Context
+import android.content.ContextWrapper
+import android.os.Handler
+import android.os.HandlerExecutor
+import android.os.test.TestLooper
+import android.platform.test.annotations.Presubmit
+import android.platform.test.flag.junit.SetFlagsRule
+import android.view.KeyEvent
+import androidx.test.core.app.ApplicationProvider
+import com.android.server.testutils.any
+import org.junit.After
+import org.junit.Before
+import org.junit.Rule
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.mockito.Mock
+import org.mockito.Mockito
+import org.mockito.Mockito.doAnswer
+import org.mockito.Mockito.`when`
+import org.mockito.junit.MockitoJUnitRunner
+import kotlin.test.assertEquals
+import kotlin.test.assertNotNull
+import kotlin.test.assertNull
+import kotlin.test.assertTrue
+import kotlin.test.fail
+
+/**
+ * Tests for [InputManager.StickyModifierStateListener].
+ *
+ * Build/Install/Run:
+ * atest InputTests:StickyModifierStateListenerTest
+ */
+@Presubmit
+@RunWith(MockitoJUnitRunner::class)
+class StickyModifierStateListenerTest {
+
+    @get:Rule
+    val rule = SetFlagsRule()
+
+    private val testLooper = TestLooper()
+    private val executor = HandlerExecutor(Handler(testLooper.looper))
+    private var registeredListener: IStickyModifierStateListener? = null
+    private lateinit var context: Context
+    private lateinit var inputManager: InputManager
+    private lateinit var inputManagerGlobalSession: InputManagerGlobal.TestSession
+
+    @Mock
+    private lateinit var iInputManagerMock: IInputManager
+
+    @Before
+    fun setUp() {
+        // Enable Sticky keys feature
+        rule.enableFlags(com.android.hardware.input.Flags.FLAG_KEYBOARD_A11Y_STICKY_KEYS_FLAG)
+        rule.enableFlags(com.android.input.flags.Flags.FLAG_ENABLE_INPUT_FILTER_RUST_IMPL)
+
+        context = Mockito.spy(ContextWrapper(ApplicationProvider.getApplicationContext()))
+        inputManagerGlobalSession = InputManagerGlobal.createTestSession(iInputManagerMock)
+        inputManager = InputManager(context)
+        `when`(context.getSystemService(Mockito.eq(Context.INPUT_SERVICE)))
+                .thenReturn(inputManager)
+
+        // Handle sticky modifier state listener registration.
+        doAnswer {
+            val listener = it.getArgument(0) as IStickyModifierStateListener
+            if (registeredListener != null &&
+                    registeredListener!!.asBinder() != listener.asBinder()) {
+                // There can only be one registered sticky modifier state listener per process.
+                fail("Trying to register a new listener when one already exists")
+            }
+            registeredListener = listener
+            null
+        }.`when`(iInputManagerMock).registerStickyModifierStateListener(any())
+
+        // Handle sticky modifier state listener being unregistered.
+        doAnswer {
+            val listener = it.getArgument(0) as IStickyModifierStateListener
+            if (registeredListener == null ||
+                    registeredListener!!.asBinder() != listener.asBinder()) {
+                fail("Trying to unregister a listener that is not registered")
+            }
+            registeredListener = null
+            null
+        }.`when`(iInputManagerMock).unregisterStickyModifierStateListener(any())
+    }
+
+    @After
+    fun tearDown() {
+        if (this::inputManagerGlobalSession.isInitialized) {
+            inputManagerGlobalSession.close()
+        }
+    }
+
+    private fun notifyStickyModifierStateChanged(modifierState: Int, lockedModifierState: Int) {
+        registeredListener!!.onStickyModifierStateChanged(modifierState, lockedModifierState)
+    }
+
+    @Test
+    fun testListenerIsNotifiedOnModifierStateChanged() {
+        var callbackCount = 0
+
+        // Add a sticky modifier state listener
+        inputManager.registerStickyModifierStateListener(executor) {
+            callbackCount++
+        }
+
+        // Notifying sticky modifier state change will notify the listener.
+        notifyStickyModifierStateChanged(0, 0)
+        testLooper.dispatchNext()
+        assertEquals(1, callbackCount)
+    }
+
+    @Test
+    fun testListenerHasCorrectModifierStateNotified() {
+        // Add a sticky modifier state listener
+        inputManager.registerStickyModifierStateListener(executor) {
+            state: StickyModifierState ->
+            assertTrue(state.isAltModifierOn)
+            assertTrue(state.isAltModifierLocked)
+            assertTrue(state.isShiftModifierOn)
+            assertTrue(!state.isShiftModifierLocked)
+            assertTrue(!state.isCtrlModifierOn)
+            assertTrue(!state.isCtrlModifierLocked)
+            assertTrue(!state.isMetaModifierOn)
+            assertTrue(!state.isMetaModifierLocked)
+            assertTrue(!state.isAltGrModifierOn)
+            assertTrue(!state.isAltGrModifierLocked)
+        }
+
+        // Notifying sticky modifier state change will notify the listener.
+        notifyStickyModifierStateChanged(
+                KeyEvent.META_ALT_ON or KeyEvent.META_ALT_LEFT_ON or
+                        KeyEvent.META_SHIFT_ON or KeyEvent.META_SHIFT_LEFT_ON,
+                KeyEvent.META_ALT_ON or KeyEvent.META_ALT_LEFT_ON
+        )
+        testLooper.dispatchNext()
+    }
+
+    @Test
+    fun testAddingListenersRegistersInternalCallbackListener() {
+        // Set up two callbacks.
+        val callback1 = InputManager.StickyModifierStateListener {}
+        val callback2 = InputManager.StickyModifierStateListener {}
+
+        assertNull(registeredListener)
+
+        // Adding the listener should register the callback with InputManagerService.
+        inputManager.registerStickyModifierStateListener(executor, callback1)
+        assertNotNull(registeredListener)
+
+        // Adding another listener should not register new internal listener.
+        val currListener = registeredListener
+        inputManager.registerStickyModifierStateListener(executor, callback2)
+        assertEquals(currListener, registeredListener)
+    }
+
+    @Test
+    fun testRemovingListenersUnregistersInternalCallbackListener() {
+        // Set up two callbacks.
+        val callback1 = InputManager.StickyModifierStateListener {}
+        val callback2 = InputManager.StickyModifierStateListener {}
+
+        inputManager.registerStickyModifierStateListener(executor, callback1)
+        inputManager.registerStickyModifierStateListener(executor, callback2)
+
+        // Only removing all listeners should remove the internal callback
+        inputManager.unregisterStickyModifierStateListener(callback1)
+        assertNotNull(registeredListener)
+        inputManager.unregisterStickyModifierStateListener(callback2)
+        assertNull(registeredListener)
+    }
+
+    @Test
+    fun testMultipleListeners() {
+        // Set up two callbacks.
+        var callbackCount1 = 0
+        var callbackCount2 = 0
+        val callback1 = InputManager.StickyModifierStateListener { _ -> callbackCount1++ }
+        val callback2 = InputManager.StickyModifierStateListener { _ -> callbackCount2++ }
+
+        // Add both sticky modifier state listeners
+        inputManager.registerStickyModifierStateListener(executor, callback1)
+        inputManager.registerStickyModifierStateListener(executor, callback2)
+
+        // Notifying sticky modifier state change trigger the both callbacks.
+        notifyStickyModifierStateChanged(0, 0)
+        testLooper.dispatchAll()
+        assertEquals(1, callbackCount1)
+        assertEquals(1, callbackCount2)
+
+        inputManager.unregisterStickyModifierStateListener(callback2)
+        // Notifying sticky modifier state change should still trigger callback1 but not callback2.
+        notifyStickyModifierStateChanged(0, 0)
+        testLooper.dispatchAll()
+        assertEquals(2, callbackCount1)
+        assertEquals(1, callbackCount2)
+    }
+}