Merge "Adding the workflow for HW_CUSTOM_INPUT in Car Framework"
diff --git a/car-lib/src/android/car/input/CarInputManager.java b/car-lib/src/android/car/input/CarInputManager.java
index dbbf18c..ebcc193 100644
--- a/car-lib/src/android/car/input/CarInputManager.java
+++ b/car-lib/src/android/car/input/CarInputManager.java
@@ -23,6 +23,7 @@
 import android.car.CarManagerBase;
 import android.os.IBinder;
 import android.os.RemoteException;
+import android.util.Slog;
 import android.util.SparseArray;
 import android.view.KeyEvent;
 
@@ -42,6 +43,10 @@
  */
 public final class CarInputManager extends CarManagerBase {
 
+    private static final String TAG = CarInputManager.class.getSimpleName();
+
+    private static final boolean DEBUG = false;
+
     /**
      * Callback for capturing input events.
      */
@@ -49,20 +54,27 @@
         /**
          * Key events were captured.
          */
-        void onKeyEvents(int targetDisplayId, @NonNull List<KeyEvent> keyEvents);
+        // TODO(b/164195589): Rename targetDisplayId parameter to targetDisplayType
+        default void onKeyEvents(int targetDisplayId, @NonNull List<KeyEvent> keyEvents) {}
 
         /**
          * Rotary events were captured.
          */
-        void onRotaryEvents(int targetDisplayId, @NonNull List<RotaryEvent> events);
+        default void onRotaryEvents(int targetDisplayId, @NonNull List<RotaryEvent> events) {}
 
         /**
          * Capture state for the display has changed due to other client making requests or
          * releasing capture. Client should check {@code activeInputTypes} for which input types
          * are currently captured.
          */
-        void onCaptureStateChanged(int targetDisplayId,
-                @NonNull @InputTypeEnum int[] activeInputTypes);
+        default void onCaptureStateChanged(int targetDisplayId,
+                @NonNull @InputTypeEnum int[] activeInputTypes) {}
+
+        /**
+         * Custom input events were captured.
+         */
+        default void onCustomInputEvents(int targetDisplayId,
+                @NonNull List<CustomInputEvent> events) {}
     }
 
     /**
@@ -137,15 +149,20 @@
     public static final int INPUT_TYPE_DPAD_KEYS = 100;
 
     /**
-     * This is for all KEYCODE_NAVIGATE_* keys.
+     * This is for all {@code KeyEvent#KEYCODE_NAVIGATE_*} keys.
      */
     public static final int INPUT_TYPE_NAVIGATE_KEYS = 101;
 
     /**
-     * This is for all KEYCODE_SYSTEM_NAVIGATE_* keys.
+     * This is for all {@code KeyEvent#KEYCODE_SYSTEM_NAVIGATE_*} keys.
      */
     public static final int INPUT_TYPE_SYSTEM_NAVIGATE_KEYS = 102;
 
+    /**
+     * This is for {@code HW_CUSTOM_INPUT} events.
+     */
+    public static final int INPUT_TYPE_CUSTOM_INPUT_EVENT = 200;
+
     /** @hide */
     @Retention(RetentionPolicy.SOURCE)
     @IntDef(prefix = "INPUT_TYPE_", value = {
@@ -154,7 +171,8 @@
             INPUT_TYPE_ROTARY_VOLUME,
             INPUT_TYPE_DPAD_KEYS,
             INPUT_TYPE_NAVIGATE_KEYS,
-            INPUT_TYPE_SYSTEM_NAVIGATE_KEYS
+            INPUT_TYPE_SYSTEM_NAVIGATE_KEYS,
+            INPUT_TYPE_CUSTOM_INPUT_EVENT,
     })
     @Target({ElementType.TYPE_USE})
     public @interface InputTypeEnum {}
@@ -299,6 +317,18 @@
         });
     }
 
+    private void dispatchCustomInputEvents(int targetDisplayType, List<CustomInputEvent> events) {
+        getEventHandler().post(() -> {
+            CarInputCaptureCallback callback = getCallback(targetDisplayType);
+            if (DEBUG) {
+                Slog.d(TAG, "Firing events " + events + " on callback " + callback);
+            }
+            if (callback != null) {
+                callback.onCustomInputEvents(targetDisplayType, events);
+            }
+        });
+    }
+
     private static final class ICarInputCallbackImpl extends ICarInputCallback.Stub {
 
         private final WeakReference<CarInputManager> mManager;
@@ -333,5 +363,14 @@
             }
             manager.dispatchOnCaptureStateChanged(targetDisplayType, activeInputTypes);
         }
+
+        @Override
+        public void onCustomInputEvents(int targetDisplayId, List<CustomInputEvent> events) {
+            CarInputManager manager = mManager.get();
+            if (manager == null) {
+                return;
+            }
+            manager.dispatchCustomInputEvents(targetDisplayId, events);
+        }
     }
 }
diff --git a/car-lib/src/android/car/input/CustomInputEvent.aidl b/car-lib/src/android/car/input/CustomInputEvent.aidl
new file mode 100644
index 0000000..01bb4ec
--- /dev/null
+++ b/car-lib/src/android/car/input/CustomInputEvent.aidl
@@ -0,0 +1,18 @@
+/*
+ * Copyright (C) 2020 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package android.car.input;
+
+parcelable CustomInputEvent;
diff --git a/car-lib/src/android/car/input/CustomInputEvent.java b/car-lib/src/android/car/input/CustomInputEvent.java
new file mode 100644
index 0000000..129d8fa
--- /dev/null
+++ b/car-lib/src/android/car/input/CustomInputEvent.java
@@ -0,0 +1,316 @@
+/*
+ * Copyright (C) 2020 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package android.car.input;
+
+import android.annotation.NonNull;
+import android.os.Parcel;
+import android.os.Parcelable;
+
+import com.android.internal.util.DataClass;
+
+/**
+ * {@code Parcelable} containing custom input event.
+ *
+ * <p>A custom input event representing HW_CUSTOM_INPUT event defined in
+ * {@code hardware/interfaces/automotive/vehicle/2.0/types.hal}.
+ *
+ * @hide
+ */
+// Note: When re-generating code, make sure inputCodeToString raises an exception in case of invalid
+//       input.
+// TODO(b/12219669): Check with INPUT_CODE_Fn constants should move to
+//     android/car/Constants/CommonConstants.java. If keeping these constants, than add unit tests.
+@DataClass(
+        genEqualsHashCode = true,
+        genAidl = true)
+public final class CustomInputEvent implements Parcelable {
+
+    // The following constant values must be in sync with the ones defined in
+    // {@code hardware/interfaces/automotive/vehicle/2.0/types.hal}
+    public static final int INPUT_CODE_F1 = 1001;
+    public static final int INPUT_CODE_F2 = 1002;
+    public static final int INPUT_CODE_F3 = 1003;
+    public static final int INPUT_CODE_F4 = 1004;
+    public static final int INPUT_CODE_F5 = 1005;
+    public static final int INPUT_CODE_F6 = 1006;
+    public static final int INPUT_CODE_F7 = 1007;
+    public static final int INPUT_CODE_F8 = 1008;
+    public static final int INPUT_CODE_F9 = 1009;
+    public static final int INPUT_CODE_F10 = 1010;
+
+    @InputCode
+    private final int mInputCode;
+
+    private final int mTargetDisplayType;
+    private final int mRepeatCounter;
+
+
+    // Code below generated by codegen v1.0.15.
+    //
+    // DO NOT MODIFY!
+    // CHECKSTYLE:OFF Generated code
+    //
+    // To regenerate run:
+    // $ codegen --to-string $ANDROID_BUILD_TOP/packages/services/Car/car-lib/src/android/car
+    // /input/CustomInputEvent.java
+    //
+    // To exclude the generated code from IntelliJ auto-formatting enable (one-time):
+    //   Settings > Editor > Code Style > Formatter Control
+    //@formatter:off
+
+
+    @android.annotation.IntDef(prefix = "INPUT_CODE_", value = {
+            INPUT_CODE_F1,
+            INPUT_CODE_F2,
+            INPUT_CODE_F3,
+            INPUT_CODE_F4,
+            INPUT_CODE_F5,
+            INPUT_CODE_F6,
+            INPUT_CODE_F7,
+            INPUT_CODE_F8,
+            INPUT_CODE_F9,
+            INPUT_CODE_F10
+    })
+    @java.lang.annotation.Retention(java.lang.annotation.RetentionPolicy.SOURCE)
+    @DataClass.Generated.Member
+    public @interface InputCode {
+    }
+
+    @DataClass.Generated.Member
+    public static String inputCodeToString(@InputCode int value) {
+        switch (value) {
+            case INPUT_CODE_F1:
+                return "INPUT_CODE_F1";
+            case INPUT_CODE_F2:
+                return "INPUT_CODE_F2";
+            case INPUT_CODE_F3:
+                return "INPUT_CODE_F3";
+            case INPUT_CODE_F4:
+                return "INPUT_CODE_F4";
+            case INPUT_CODE_F5:
+                return "INPUT_CODE_F5";
+            case INPUT_CODE_F6:
+                return "INPUT_CODE_F6";
+            case INPUT_CODE_F7:
+                return "INPUT_CODE_F7";
+            case INPUT_CODE_F8:
+                return "INPUT_CODE_F8";
+            case INPUT_CODE_F9:
+                return "INPUT_CODE_F9";
+            case INPUT_CODE_F10:
+                return "INPUT_CODE_F10";
+            default:
+                throw new java.lang.IllegalArgumentException(
+                        "Invalid inputCode {" + value + "}");
+        }
+    }
+
+    @DataClass.Generated.Member
+    public CustomInputEvent(
+            @InputCode int inputCode,
+            int targetDisplayType,
+            int repeatCounter) {
+        this.mInputCode = inputCode;
+
+        if (!(mInputCode == INPUT_CODE_F1)
+                && !(mInputCode == INPUT_CODE_F2)
+                && !(mInputCode == INPUT_CODE_F3)
+                && !(mInputCode == INPUT_CODE_F4)
+                && !(mInputCode == INPUT_CODE_F5)
+                && !(mInputCode == INPUT_CODE_F6)
+                && !(mInputCode == INPUT_CODE_F7)
+                && !(mInputCode == INPUT_CODE_F8)
+                && !(mInputCode == INPUT_CODE_F9)
+                && !(mInputCode == INPUT_CODE_F10)) {
+            throw new java.lang.IllegalArgumentException(
+                    "inputCode was " + mInputCode + " but must be one of: "
+                            + "INPUT_CODE_F1(" + INPUT_CODE_F1 + "), "
+                            + "INPUT_CODE_F2(" + INPUT_CODE_F2 + "), "
+                            + "INPUT_CODE_F3(" + INPUT_CODE_F3 + "), "
+                            + "INPUT_CODE_F4(" + INPUT_CODE_F4 + "), "
+                            + "INPUT_CODE_F5(" + INPUT_CODE_F5 + "), "
+                            + "INPUT_CODE_F6(" + INPUT_CODE_F6 + "), "
+                            + "INPUT_CODE_F7(" + INPUT_CODE_F7 + "), "
+                            + "INPUT_CODE_F8(" + INPUT_CODE_F8 + "), "
+                            + "INPUT_CODE_F9(" + INPUT_CODE_F9 + "), "
+                            + "INPUT_CODE_F10(" + INPUT_CODE_F10 + ")");
+        }
+
+        this.mTargetDisplayType = targetDisplayType;
+        this.mRepeatCounter = repeatCounter;
+
+        // onConstructed(); // You can define this method to get a callback
+    }
+
+    @DataClass.Generated.Member
+    public @InputCode
+    int getInputCode() {
+        return mInputCode;
+    }
+
+    @DataClass.Generated.Member
+    public int getTargetDisplayType() {
+        return mTargetDisplayType;
+    }
+
+    @DataClass.Generated.Member
+    public int getRepeatCounter() {
+        return mRepeatCounter;
+    }
+
+    @Override
+    @DataClass.Generated.Member
+    public String toString() {
+        // You can override field toString logic by defining methods like:
+        // String fieldNameToString() { ... }
+
+        return "CustomInputEvent { " +
+                "inputCode = " + inputCodeToString(mInputCode) + ", " +
+                "targetDisplayType = " + mTargetDisplayType + ", " +
+                "repeatCounter = " + mRepeatCounter +
+                " }";
+    }
+
+    @Override
+    @DataClass.Generated.Member
+    public boolean equals(@android.annotation.Nullable Object o) {
+        // You can override field equality logic by defining either of the methods like:
+        // boolean fieldNameEquals(CustomInputEvent other) { ... }
+        // boolean fieldNameEquals(FieldType otherValue) { ... }
+
+        if (this == o) return true;
+        if (o == null || getClass() != o.getClass()) return false;
+        @SuppressWarnings("unchecked")
+        CustomInputEvent that = (CustomInputEvent) o;
+        //noinspection PointlessBooleanExpression
+        return true
+                && mInputCode == that.mInputCode
+                && mTargetDisplayType == that.mTargetDisplayType
+                && mRepeatCounter == that.mRepeatCounter;
+    }
+
+    @Override
+    @DataClass.Generated.Member
+    public int hashCode() {
+        // You can override field hashCode logic by defining methods like:
+        // int fieldNameHashCode() { ... }
+
+        int _hash = 1;
+        _hash = 31 * _hash + mInputCode;
+        _hash = 31 * _hash + mTargetDisplayType;
+        _hash = 31 * _hash + mRepeatCounter;
+        return _hash;
+    }
+
+    @Override
+    @DataClass.Generated.Member
+    public void writeToParcel(@NonNull Parcel dest, int flags) {
+        // You can override field parcelling by defining methods like:
+        // void parcelFieldName(Parcel dest, int flags) { ... }
+
+        dest.writeInt(mInputCode);
+        dest.writeInt(mTargetDisplayType);
+        dest.writeInt(mRepeatCounter);
+    }
+
+    @Override
+    @DataClass.Generated.Member
+    public int describeContents() {
+        return 0;
+    }
+
+    /** @hide */
+    @SuppressWarnings({"unchecked", "RedundantCast"})
+    @DataClass.Generated.Member
+    /* package-private */ CustomInputEvent(@NonNull Parcel in) {
+        // You can override field unparcelling by defining methods like:
+        // static FieldType unparcelFieldName(Parcel in) { ... }
+
+        int inputCode = in.readInt();
+        int targetDisplayType = in.readInt();
+        int repeatCounter = in.readInt();
+
+        this.mInputCode = inputCode;
+
+        if (!(mInputCode == INPUT_CODE_F1)
+                && !(mInputCode == INPUT_CODE_F2)
+                && !(mInputCode == INPUT_CODE_F3)
+                && !(mInputCode == INPUT_CODE_F4)
+                && !(mInputCode == INPUT_CODE_F5)
+                && !(mInputCode == INPUT_CODE_F6)
+                && !(mInputCode == INPUT_CODE_F7)
+                && !(mInputCode == INPUT_CODE_F8)
+                && !(mInputCode == INPUT_CODE_F9)
+                && !(mInputCode == INPUT_CODE_F10)) {
+            throw new java.lang.IllegalArgumentException(
+                    "inputCode was " + mInputCode + " but must be one of: "
+                            + "INPUT_CODE_F1(" + INPUT_CODE_F1 + "), "
+                            + "INPUT_CODE_F2(" + INPUT_CODE_F2 + "), "
+                            + "INPUT_CODE_F3(" + INPUT_CODE_F3 + "), "
+                            + "INPUT_CODE_F4(" + INPUT_CODE_F4 + "), "
+                            + "INPUT_CODE_F5(" + INPUT_CODE_F5 + "), "
+                            + "INPUT_CODE_F6(" + INPUT_CODE_F6 + "), "
+                            + "INPUT_CODE_F7(" + INPUT_CODE_F7 + "), "
+                            + "INPUT_CODE_F8(" + INPUT_CODE_F8 + "), "
+                            + "INPUT_CODE_F9(" + INPUT_CODE_F9 + "), "
+                            + "INPUT_CODE_F10(" + INPUT_CODE_F10 + ")");
+        }
+
+        this.mTargetDisplayType = targetDisplayType;
+        this.mRepeatCounter = repeatCounter;
+
+        // onConstructed(); // You can define this method to get a callback
+    }
+
+    @DataClass.Generated.Member
+    public static final @NonNull
+    Parcelable.Creator<CustomInputEvent> CREATOR
+            = new Parcelable.Creator<CustomInputEvent>() {
+        @Override
+        public CustomInputEvent[] newArray(int size) {
+            return new CustomInputEvent[size];
+        }
+
+        @Override
+        public CustomInputEvent createFromParcel(@NonNull Parcel in) {
+            return new CustomInputEvent(in);
+        }
+    };
+
+    @DataClass.Generated(
+            time = 1600715769152L,
+            codegenVersion = "1.0.15",
+            sourceFile = "packages/services/Car/car-lib/src/android/car/input/CustomInputEvent"
+                    + ".java",
+            inputSignatures = "public static final  int INPUT_CODE_F1\npublic static final  int "
+                    + "INPUT_CODE_F2\npublic static final  int INPUT_CODE_F3\npublic static final"
+                    + "  int INPUT_CODE_F4\npublic static final  int INPUT_CODE_F5\npublic static"
+                    + " final  int INPUT_CODE_F6\npublic static final  int INPUT_CODE_F7\npublic "
+                    + "static final  int INPUT_CODE_F8\npublic static final  int "
+                    + "INPUT_CODE_F9\npublic static final  int INPUT_CODE_F10\nprivate final "
+                    + "@android.car.input.CustomInputEvent.InputCode int mInputCode\nprivate "
+                    + "final  int mTargetDisplayType\nprivate final  int mRepeatCounter\nclass "
+                    + "CustomInputEvent extends java.lang.Object implements [android.os"
+                    + ".Parcelable]\n@com.android.internal.util.DataClass(genEqualsHashCode=true,"
+                    + " genAidl=true)")
+    @Deprecated
+    private void __metadata() {
+    }
+
+    //@formatter:on
+    // End of generated code
+}
diff --git a/car-lib/src/android/car/input/ICarInputCallback.aidl b/car-lib/src/android/car/input/ICarInputCallback.aidl
index 218ff2c..afe5cec 100644
--- a/car-lib/src/android/car/input/ICarInputCallback.aidl
+++ b/car-lib/src/android/car/input/ICarInputCallback.aidl
@@ -15,6 +15,7 @@
  */
 package android.car.input;
 
+import android.car.input.CustomInputEvent;
 import android.car.input.RotaryEvent;
 import android.view.KeyEvent;
 
@@ -27,4 +28,5 @@
     void onKeyEvents(int targetDisplayType, in List<KeyEvent> keyEvents) = 1;
     void onRotaryEvents(int targetDisplayType, in List<RotaryEvent> events) = 2;
     void onCaptureStateChanged(int targetDisplayType, in int[] activeInputTypes) = 3;
+    void onCustomInputEvents(int targetDisplayType, in List<CustomInputEvent> events) = 4;
 }
diff --git a/service/src/com/android/car/CarInputService.java b/service/src/com/android/car/CarInputService.java
index 409095a..74c1679 100644
--- a/service/src/com/android/car/CarInputService.java
+++ b/service/src/com/android/car/CarInputService.java
@@ -27,6 +27,7 @@
 import android.bluetooth.BluetoothProfile;
 import android.car.CarProjectionManager;
 import android.car.input.CarInputManager;
+import android.car.input.CustomInputEvent;
 import android.car.input.ICarInput;
 import android.car.input.ICarInputCallback;
 import android.car.input.RotaryEvent;
@@ -363,6 +364,17 @@
         }
     }
 
+    @Override
+    public void onCustomInputEvent(CustomInputEvent event) {
+        if (!mCaptureController.onCustomInputEvent(event)) {
+            Log.w(CarLog.TAG_INPUT, "Failed to propagate " + event);
+            return;
+        }
+        if (DBG) {
+            Log.d(CarLog.TAG_INPUT, "Succeed injecting " + event);
+        }
+    }
+
     private static List<KeyEvent> rotaryEventToKeyEvents(RotaryEvent event) {
         int numClicks = event.getNumberOfClicks();
         int numEvents = numClicks * 2; // up / down per each click
diff --git a/service/src/com/android/car/CarShellCommand.java b/service/src/com/android/car/CarShellCommand.java
index eb2f958..f7fbf02 100644
--- a/service/src/com/android/car/CarShellCommand.java
+++ b/service/src/com/android/car/CarShellCommand.java
@@ -32,6 +32,7 @@
 import android.app.UiModeManager;
 import android.car.Car;
 import android.car.input.CarInputManager;
+import android.car.input.CustomInputEvent;
 import android.car.input.RotaryEvent;
 import android.car.user.CarUserManager;
 import android.car.user.UserCreationResult;
@@ -126,6 +127,7 @@
     private static final String COMMAND_DISABLE_FEATURE = "disable-feature";
     private static final String COMMAND_INJECT_KEY = "inject-key";
     private static final String COMMAND_INJECT_ROTARY = "inject-rotary";
+    private static final String COMMAND_INJECT_CUSTOM_INPUT = "inject-custom-input";
     private static final String COMMAND_GET_INITIAL_USER_INFO = "get-initial-user-info";
     private static final String COMMAND_SILENT_MODE = "silent-mode";
     private static final String COMMAND_SWITCH_USER = "switch-user";
@@ -194,8 +196,10 @@
     private static final SparseArray<String> VALID_USER_AUTH_SET_VALUES;
     private static final String VALID_USER_AUTH_SET_VALUES_HELP;
 
+    private static final ArrayMap<String, Integer> CUSTOM_INPUT_FUNCTION_ARGS;
+
     static {
-        VALID_USER_AUTH_TYPES = new SparseArray<String>(5);
+        VALID_USER_AUTH_TYPES = new SparseArray<>(5);
         VALID_USER_AUTH_TYPES.put(KEY_FOB, UserIdentificationAssociationType.toString(KEY_FOB));
         VALID_USER_AUTH_TYPES.put(CUSTOM_1, UserIdentificationAssociationType.toString(CUSTOM_1));
         VALID_USER_AUTH_TYPES.put(CUSTOM_2, UserIdentificationAssociationType.toString(CUSTOM_2));
@@ -203,7 +207,7 @@
         VALID_USER_AUTH_TYPES.put(CUSTOM_4, UserIdentificationAssociationType.toString(CUSTOM_4));
         VALID_USER_AUTH_TYPES_HELP = getHelpString("types", VALID_USER_AUTH_TYPES);
 
-        VALID_USER_AUTH_SET_VALUES = new SparseArray<String>(3);
+        VALID_USER_AUTH_SET_VALUES = new SparseArray<>(3);
         VALID_USER_AUTH_SET_VALUES.put(ASSOCIATE_CURRENT_USER,
                 UserIdentificationAssociationSetValue.toString(ASSOCIATE_CURRENT_USER));
         VALID_USER_AUTH_SET_VALUES.put(DISASSOCIATE_CURRENT_USER,
@@ -211,6 +215,18 @@
         VALID_USER_AUTH_SET_VALUES.put(DISASSOCIATE_ALL_USERS,
                 UserIdentificationAssociationSetValue.toString(DISASSOCIATE_ALL_USERS));
         VALID_USER_AUTH_SET_VALUES_HELP = getHelpString("values", VALID_USER_AUTH_SET_VALUES);
+
+        CUSTOM_INPUT_FUNCTION_ARGS = new ArrayMap<>(10);
+        CUSTOM_INPUT_FUNCTION_ARGS.put("f1", CustomInputEvent.INPUT_CODE_F1);
+        CUSTOM_INPUT_FUNCTION_ARGS.put("f2", CustomInputEvent.INPUT_CODE_F2);
+        CUSTOM_INPUT_FUNCTION_ARGS.put("f3", CustomInputEvent.INPUT_CODE_F3);
+        CUSTOM_INPUT_FUNCTION_ARGS.put("f4", CustomInputEvent.INPUT_CODE_F4);
+        CUSTOM_INPUT_FUNCTION_ARGS.put("f5", CustomInputEvent.INPUT_CODE_F5);
+        CUSTOM_INPUT_FUNCTION_ARGS.put("f6", CustomInputEvent.INPUT_CODE_F6);
+        CUSTOM_INPUT_FUNCTION_ARGS.put("f7", CustomInputEvent.INPUT_CODE_F7);
+        CUSTOM_INPUT_FUNCTION_ARGS.put("f8", CustomInputEvent.INPUT_CODE_F8);
+        CUSTOM_INPUT_FUNCTION_ARGS.put("f9", CustomInputEvent.INPUT_CODE_F9);
+        CUSTOM_INPUT_FUNCTION_ARGS.put("f10", CustomInputEvent.INPUT_CODE_F10);
     }
 
     @NonNull
@@ -362,7 +378,11 @@
         pw.println("\t             counter-clockwise. If not specified, it will be false.");
         pw.println("\t  delta_times_ms: a list of delta time (current time minus event time)");
         pw.println("\t                  in descending order. If not specified, it will be 0.");
-
+        pw.println("\tinject-custom-input [-d display] [-r repeatCounter] EVENT");
+        pw.println("\t  display: 0 for main, 1 for cluster. If not specified, it will be 0.");
+        pw.println("\t  repeatCounter: number of times the button was hit (default value is 1)");
+        pw.println("\t  EVENT: mandatory last argument. Possible values for for this flag are ");
+        pw.println("\t         F1, F2, up to F10 (functions to defined by OEM partners)");
         pw.printf("\t%s <REQ_TYPE> [--timeout TIMEOUT_MS]\n", COMMAND_GET_INITIAL_USER_INFO);
         pw.println("\t  Calls the Vehicle HAL to get the initial boot info, passing the given");
         pw.println("\t  REQ_TYPE (which could be either FIRST_BOOT, FIRST_BOOT_AFTER_OTA, ");
@@ -615,6 +635,12 @@
                 }
                 injectRotary(args, writer);
                 break;
+            case COMMAND_INJECT_CUSTOM_INPUT:
+                if (args.length < 2) {
+                    return showInvalidArguments(writer);
+                }
+                injectCustomInputEvent(args, writer);
+                break;
             case COMMAND_GET_INITIAL_USER_INFO:
                 getInitialUserInfo(args, writer);
                 break;
@@ -849,6 +875,47 @@
         writer.println("Succeeded in injecting: " + rotaryEvent);
     }
 
+    private void injectCustomInputEvent(String[] args, PrintWriter writer) {
+        int display = InputHalService.DISPLAY_MAIN;
+        int repeatCounter = 1;
+
+        int argIdx = 1;
+        for (; argIdx < args.length - 1; argIdx++) {
+            switch (args[argIdx]) {
+                case "-d":
+                    display = Integer.parseInt(args[++argIdx]);
+                    break;
+                case "-r":
+                    repeatCounter = Integer.parseInt(args[++argIdx]);
+                    break;
+                default:
+                    writer.printf("Unrecognized argument: {%s}\n", args[argIdx]);
+                    writer.println("Pass -help to see the full list of options");
+                    return;
+            }
+        }
+
+        if (argIdx == args.length) {
+            writer.println("Last mandatory argument (fn) not passed.");
+            writer.println("Pass -help to see the full list of options");
+            return;
+        }
+
+        // Processing the last remaining argument (expected to be 'f1', 'f2', ..., 'f10').
+        String eventValue = args[argIdx].toLowerCase();
+        Integer inputCode = CUSTOM_INPUT_FUNCTION_ARGS.get(eventValue);
+        if (inputCode == null) {
+            writer.printf("Invalid input event value {%s}, valid values are f1, f2, ..., f10\n",
+                    eventValue);
+            writer.println("Pass -help to see the full list of options");
+            return;
+        }
+
+        CustomInputEvent event = new CustomInputEvent(inputCode, display, repeatCounter);
+        mCarInputService.onCustomInputEvent(event);
+        writer.printf("Succeeded in injecting {%s}\n", event);
+    }
+
     private void getInitialUserInfo(String[] args, PrintWriter writer) {
         if (args.length < 2) {
             writer.println("Insufficient number of args");
diff --git a/service/src/com/android/car/InputCaptureClientController.java b/service/src/com/android/car/InputCaptureClientController.java
index 8d140bd..01f0aa0 100644
--- a/service/src/com/android/car/InputCaptureClientController.java
+++ b/service/src/com/android/car/InputCaptureClientController.java
@@ -20,6 +20,7 @@
 
 import android.annotation.NonNull;
 import android.car.input.CarInputManager;
+import android.car.input.CustomInputEvent;
 import android.car.input.ICarInputCallback;
 import android.car.input.RotaryEvent;
 import android.content.Context;
@@ -56,9 +57,9 @@
 
     private static final String TAG = CarLog.TAG_INPUT;
     /**
-     *  This table decides which input key goes into which input type. Not mapped here means it is
-     *  not supported for capturing. Rotary events are treated separately and this is only for
-     *  key events.
+     * This table decides which input key goes into which input type. Not mapped here means it is
+     * not supported for capturing. Rotary events are treated separately and this is only for
+     * key events.
      */
     private static final Map<Integer, Integer> KEY_EVENT_TO_INPUT_TYPE = Map.ofEntries(
             entry(KeyEvent.KEYCODE_DPAD_CENTER, CarInputManager.INPUT_TYPE_DPAD_KEYS),
@@ -89,7 +90,8 @@
             CarInputManager.INPUT_TYPE_ROTARY_NAVIGATION,
             CarInputManager.INPUT_TYPE_DPAD_KEYS,
             CarInputManager.INPUT_TYPE_NAVIGATE_KEYS,
-            CarInputManager.INPUT_TYPE_SYSTEM_NAVIGATE_KEYS
+            CarInputManager.INPUT_TYPE_SYSTEM_NAVIGATE_KEYS,
+            CarInputManager.INPUT_TYPE_CUSTOM_INPUT_EVENT
     );
 
     private static final Set<Integer> VALID_ROTARY_TYPES = Set.of(
@@ -214,6 +216,10 @@
     /** Accessed from dispatch thread only */
     private final ArrayList<RotaryEvent> mRotaryEventDispatchScratchList = new ArrayList<>(1);
 
+    /** Accessed from dispatch thread only */
+    private final ArrayList<CustomInputEvent> mCustomInputEventDispatchScratchList =
+            new ArrayList<>(1);
+
     @GuardedBy("mLock")
     private int mNumKeyEventsDispatched;
     @GuardedBy("mLock")
@@ -411,7 +417,7 @@
 
         if (DBG_CALLS) {
             Log.i(TAG, "releaseInputEventCapture callback:" + callback
-                            + ", display:" + targetDisplayType);
+                    + ", display:" + targetDisplayType);
         }
         ClientsToDispatch clientsToDispatch = new ClientsToDispatch(targetDisplayType);
         synchronized (mLock) {
@@ -481,9 +487,10 @@
     /**
      * Dispatches the given {@code KeyEvent} to a capturing client if there is one.
      *
-     * @param displayType Should be a display type defined in {@code CarInputManager} such as
-     *                    {@link CarInputManager#TARGET_DISPLAY_TYPE_MAIN}.
-     * @param event
+     * @param displayType the display type defined in {@code CarInputManager} such as
+     *                    {@link CarInputManager#TARGET_DISPLAY_TYPE_MAIN}
+     * @param event the key event to handle
+     *
      * @return true if the event was consumed.
      */
     public boolean onKeyEvent(int displayType, KeyEvent event) {
@@ -510,9 +517,10 @@
     /**
      * Dispatches the given {@code RotaryEvent} to a capturing client if there is one.
      *
-     * @param displayType Should be a display type defined in {@code CarInputManager} such as
-     *                    {@link CarInputManager#TARGET_DISPLAY_TYPE_MAIN}.
-     * @param event
+     * @param displayType the display type defined in {@code CarInputManager} such as
+     *                    {@link CarInputManager#TARGET_DISPLAY_TYPE_MAIN}
+     * @param event the Rotary event to handle
+     *
      * @return true if the event was consumed.
      */
     public boolean onRotaryEvent(int displayType, RotaryEvent event) {
@@ -542,6 +550,37 @@
         return true;
     }
 
+    /**
+     * Dispatches the given {@link CustomInputEvent} to a capturing client if there is one.
+     * Nothing happens if no callback was registered for the incoming event. In this case this
+     * method will return {@code false}.
+     * <p>
+     * In case of there are more than one client registered for this event, then only the first one
+     * will be notified.
+     *
+     * @param event the {@link CustomInputEvent} to dispatch
+     * @return {@code true} if the event was consumed.
+     */
+    public boolean onCustomInputEvent(CustomInputEvent event) {
+        int displayType = event.getTargetDisplayType();
+        if (!SUPPORTED_DISPLAY_TYPES.contains(displayType)) {
+            Log.w(TAG, "onCustomInputEvent for not supported display:" + displayType);
+            return false;
+        }
+        ICarInputCallback callback;
+        synchronized (mLock) {
+            callback = getClientForInputTypeLocked(displayType,
+                    CarInputManager.INPUT_TYPE_CUSTOM_INPUT_EVENT);
+            if (callback == null) {
+                Log.w(TAG, "No client for input: " + CarInputManager.INPUT_TYPE_CUSTOM_INPUT_EVENT
+                        + " and display: " + displayType);
+                return false;
+            }
+        }
+        dispatchCustomInputEvent(displayType, event, callback);
+        return true;
+    }
+
     ICarInputCallback getClientForInputTypeLocked(int targetDisplayType, int inputType) {
         LinkedList<ClientInfoForDisplay> fullCapturersStack = mFullDisplayEventCapturers.get(
                 targetDisplayType);
@@ -571,14 +610,14 @@
     public void dump(PrintWriter writer) {
         writer.println("**InputCaptureClientController**");
         synchronized (mLock) {
-            for (int display: SUPPORTED_DISPLAY_TYPES) {
+            for (int display : SUPPORTED_DISPLAY_TYPES) {
                 writer.println("***Display:" + display);
 
                 HashMap<IBinder, ClientInfoForDisplay> allClientsForDisplay = mAllClients.get(
                         display);
                 if (allClientsForDisplay != null) {
                     writer.println("****All clients:");
-                    for (ClientInfoForDisplay client: allClientsForDisplay.values()) {
+                    for (ClientInfoForDisplay client : allClientsForDisplay.values()) {
                         writer.println(client);
                     }
                 }
@@ -587,7 +626,7 @@
                         mFullDisplayEventCapturers.get(display);
                 if (fullCapturersStack != null) {
                     writer.println("****Full capture stack");
-                    for (ClientInfoForDisplay client: fullCapturersStack) {
+                    for (ClientInfoForDisplay client : fullCapturersStack) {
                         writer.println(client);
                     }
                 }
@@ -599,7 +638,7 @@
                         LinkedList<ClientInfoForDisplay> perInputStack = perInputStacks.valueAt(i);
                         if (perInputStack.size() > 0) {
                             writer.println("**** Per Input stack, input type:" + inputType);
-                            for (ClientInfoForDisplay client: perInputStack) {
+                            for (ClientInfoForDisplay client : perInputStack) {
                                 writer.println(client);
                             }
                         }
@@ -659,7 +698,9 @@
             try {
                 callback.onKeyEvents(targetDisplayType, mKeyEventDispatchScratchList);
             } catch (RemoteException e) {
-                // Ignore. Let death handler deal with it.
+                if (DBG_DISPATCH) {
+                    Log.e(TAG, "Failed to dispatch KeyEvent " + event, e);
+                }
             }
         });
     }
@@ -669,13 +710,36 @@
         if (DBG_DISPATCH) {
             Log.i(TAG, "dispatchRotaryEvent:" + event);
         }
+        // TODO(b/159623196): Use HandlerThread for dispatching rather than relying on the main
+        //     thread. Change here and other dispatch methods.
         CarServiceUtils.runOnMain(() -> {
             mRotaryEventDispatchScratchList.clear();
             mRotaryEventDispatchScratchList.add(event);
             try {
                 callback.onRotaryEvents(targetDisplayType, mRotaryEventDispatchScratchList);
             } catch (RemoteException e) {
-                // Ignore. Let death handler deal with it.
+                if (DBG_DISPATCH) {
+                    Log.e(TAG, "Failed to dispatch RotaryEvent " + event, e);
+                }
+            }
+        });
+    }
+
+    private void dispatchCustomInputEvent(int targetDisplayType, CustomInputEvent event,
+            ICarInputCallback callback) {
+        if (DBG_DISPATCH) {
+            Log.d(TAG, "dispatchCustomInputEvent:" + event);
+        }
+        CarServiceUtils.runOnMain(() -> {
+            mCustomInputEventDispatchScratchList.clear();
+            mCustomInputEventDispatchScratchList.add(event);
+            try {
+                callback.onCustomInputEvents(targetDisplayType,
+                        mCustomInputEventDispatchScratchList);
+            } catch (RemoteException e) {
+                if (DBG_DISPATCH) {
+                    Log.e(TAG, "Failed to dispatch CustomInputEvent " + event, e);
+                }
             }
         });
     }
diff --git a/service/src/com/android/car/hal/InputHalService.java b/service/src/com/android/car/hal/InputHalService.java
index 8cee8e9..a438e99 100644
--- a/service/src/com/android/car/hal/InputHalService.java
+++ b/service/src/com/android/car/hal/InputHalService.java
@@ -15,12 +15,16 @@
  */
 package com.android.car.hal;
 
+import static android.hardware.automotive.vehicle.V2_0.CustomInputType.CUSTOM_EVENT_F1;
+import static android.hardware.automotive.vehicle.V2_0.CustomInputType.CUSTOM_EVENT_F10;
 import static android.hardware.automotive.vehicle.V2_0.RotaryInputType.ROTARY_INPUT_TYPE_AUDIO_VOLUME;
 import static android.hardware.automotive.vehicle.V2_0.RotaryInputType.ROTARY_INPUT_TYPE_SYSTEM_NAVIGATION;
+import static android.hardware.automotive.vehicle.V2_0.VehicleProperty.HW_CUSTOM_INPUT;
 import static android.hardware.automotive.vehicle.V2_0.VehicleProperty.HW_KEY_INPUT;
 import static android.hardware.automotive.vehicle.V2_0.VehicleProperty.HW_ROTARY_INPUT;
 
 import android.car.input.CarInputManager;
+import android.car.input.CustomInputEvent;
 import android.car.input.RotaryEvent;
 import android.hardware.automotive.vehicle.V2_0.VehicleDisplay;
 import android.hardware.automotive.vehicle.V2_0.VehicleHwKeyInputAction;
@@ -54,7 +58,8 @@
 
     private static final int[] SUPPORTED_PROPERTIES = new int[] {
             HW_KEY_INPUT,
-            HW_ROTARY_INPUT
+            HW_ROTARY_INPUT,
+            HW_CUSTOM_INPUT
     };
 
     private final VehicleHal mHal;
@@ -72,6 +77,8 @@
         void onKeyEvent(KeyEvent event, int targetDisplay);
         /** Called for rotary event */
         void onRotaryEvent(RotaryEvent event, int targetDisplay);
+        /** Called for OEM custom input event */
+        void onCustomInputEvent(CustomInputEvent event);
     }
 
     /** The current press state of a key. */
@@ -87,10 +94,13 @@
     private final Object mLock = new Object();
 
     @GuardedBy("mLock")
-    private boolean mKeyInputSupported = false;
+    private boolean mKeyInputSupported;
 
     @GuardedBy("mLock")
-    private boolean mRotaryInputSupported = false;
+    private boolean mRotaryInputSupported;
+
+    @GuardedBy("mLock")
+    private boolean mCustomInputSupported;
 
     @GuardedBy("mLock")
     private InputListener mListener;
@@ -114,8 +124,9 @@
     public void setInputListener(InputListener listener) {
         boolean keyInputSupported;
         boolean rotaryInputSupported;
+        boolean customInputSupported;
         synchronized (mLock) {
-            if (!mKeyInputSupported && !mRotaryInputSupported) {
+            if (!mKeyInputSupported && !mRotaryInputSupported && !mCustomInputSupported) {
                 Log.w(CarLog.TAG_INPUT,
                         "input listener set while rotary and key input not supported");
                 return;
@@ -123,6 +134,7 @@
             mListener = listener;
             keyInputSupported = mKeyInputSupported;
             rotaryInputSupported = mRotaryInputSupported;
+            customInputSupported = mCustomInputSupported;
         }
         if (keyInputSupported) {
             mHal.subscribeProperty(this, HW_KEY_INPUT);
@@ -130,6 +142,9 @@
         if (rotaryInputSupported) {
             mHal.subscribeProperty(this, HW_ROTARY_INPUT);
         }
+        if (customInputSupported) {
+            mHal.subscribeProperty(this, HW_CUSTOM_INPUT);
+        }
     }
 
     /** Returns whether {@code HW_KEY_INPUT} is supported. */
@@ -146,6 +161,13 @@
         }
     }
 
+    /** Returns whether {@code HW_CUSTOM_INPUT} is supported. */
+    public boolean isCustomInputSupported() {
+        synchronized (mLock) {
+            return mCustomInputSupported;
+        }
+    }
+
     @Override
     public void init() {
     }
@@ -156,6 +178,7 @@
             mListener = null;
             mKeyInputSupported = false;
             mRotaryInputSupported = false;
+            mCustomInputSupported = false;
         }
     }
 
@@ -178,6 +201,11 @@
                         mRotaryInputSupported = true;
                     }
                     break;
+                case HW_CUSTOM_INPUT:
+                    synchronized (mLock) {
+                        mCustomInputSupported = true;
+                    }
+                    break;
             }
         }
     }
@@ -200,6 +228,9 @@
                 case HW_ROTARY_INPUT:
                     dispatchRotaryInput(listener, value);
                     break;
+                case HW_CUSTOM_INPUT:
+                    dispatchCustomInput(listener, value);
+                    break;
                 default:
                     Log.e(CarLog.TAG_INPUT,
                             "Wrong event dispatched, prop:0x" + Integer.toHexString(value.prop));
@@ -345,7 +376,6 @@
                 action,
                 code,
                 repeat,
-                0 /* meta state */,
                 0 /* deviceId */,
                 0 /* scancode */,
                 0 /* flags */,
@@ -357,6 +387,23 @@
         listener.onKeyEvent(event, display);
     }
 
+    private void dispatchCustomInput(InputListener listener, VehiclePropValue value) {
+        if (DBG) {
+            Log.d(CarLog.TAG_INPUT, "Dispatching CustomInputEvent for listener="
+                    + listener + " and value=" + value);
+        }
+        int inputCode = value.value.int32Values.get(0);
+        int targetDisplayType = value.value.int32Values.get(1);
+        int repeatCounter = value.value.int32Values.get(2);
+
+        if (inputCode < CUSTOM_EVENT_F1 || inputCode > CUSTOM_EVENT_F10) {
+            Log.e(CarLog.TAG_INPUT, "Unknown custom input code: " + inputCode);
+            return;
+        }
+        CustomInputEvent event = new CustomInputEvent(inputCode, targetDisplayType, repeatCounter);
+        listener.onCustomInputEvent(event);
+    }
+
     @Override
     public void dump(PrintWriter writer) {
         writer.println("*Input HAL*");
diff --git a/tests/CustomInputTestService/Android.bp b/tests/CustomInputTestService/Android.bp
new file mode 100644
index 0000000..18c45db
--- /dev/null
+++ b/tests/CustomInputTestService/Android.bp
@@ -0,0 +1,88 @@
+// Copyright (C) 2020 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+filegroup {
+    name: "CustomInputTestService-srcs",
+    srcs: [
+        "src/**/*.java",
+    ],
+}
+
+android_app {
+    name: "CustomInputTestService",
+    srcs: [":CustomInputTestService-srcs"],
+    resource_dirs: ["res"],
+
+    // Because it uses a platform API (CarInputManager).
+    platform_apis: true,
+
+    // This app should be platform signed because it requires android.permission.MONITOR_INPUT
+    // permission, which is of type "signature".
+    certificate: "platform",
+
+    optimize: {
+        enabled: false,
+    },
+
+    dex_preopt: {
+        enabled: false,
+    },
+
+    libs: [
+        "android.car",
+    ],
+
+    product_variables: {
+        pdk: {
+            enabled: false,
+        },
+    },
+}
+
+android_test {
+    name: "CustomInputTestServiceTest",
+
+    srcs: [
+        "tests/src/**/*.java",
+        ":CustomInputTestService-srcs",
+    ],
+
+    manifest: "tests/AndroidManifest.xml",
+
+    platform_apis: true,
+
+    static_libs: [
+        "mockito-target-extended",
+        "androidx.test.core",
+        "androidx.test.ext.junit",
+        "androidx.test.rules",
+        "truth-prebuilt",
+    ],
+
+    libs: [
+        "android.car",
+        "android.test.mock",
+        "android.test.base",
+        "android.test.runner",
+    ],
+
+    // Required by mockito-target-extended (lib used to mock final classes).
+    jni_libs: [
+        "libdexmakerjvmtiagent",
+    ],
+
+    optimize: {
+        enabled: false,
+    },
+}
diff --git a/tests/CustomInputTestService/AndroidManifest.xml b/tests/CustomInputTestService/AndroidManifest.xml
new file mode 100644
index 0000000..6aba2a7
--- /dev/null
+++ b/tests/CustomInputTestService/AndroidManifest.xml
@@ -0,0 +1,34 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!-- Copyright (C) 2020 The Android Open Source Project
+
+     Licensed under the Apache License, Version 2.0 (the "License");
+     you may not use this file except in compliance with the License.
+     You may obtain a copy of the License at
+
+          http://www.apache.org/licenses/LICENSE-2.0
+
+     Unless required by applicable law or agreed to in writing, software
+     distributed under the License is distributed on an "AS IS" BASIS,
+     WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+     See the License for the specific language governing permissions and
+     limitations under the License.
+-->
+
+<manifest xmlns:android="http://schemas.android.com/apk/res/android"
+          package="com.android.car.custominput.test">
+
+    <uses-permission android:name="android.permission.MONITOR_INPUT"/>
+    <uses-permission android:name="android.permission.FOREGROUND_SERVICE"/>
+    <uses-permission android:name="android.permission.SYSTEM_ALERT_WINDOW"/>
+    <uses-permission android:name="android.permission.INTERACT_ACROSS_USERS_FULL"/>
+
+    <application>
+        <service android:name=".CustomInputTestService"
+                 android:exported="true" android:enabled="true" >
+            <intent-filter>
+                <action android:name="com.android.car.custominput.action.START_SILENT"/>
+            </intent-filter>
+        </service>
+    </application>
+</manifest>
+
diff --git a/tests/CustomInputTestService/readme.md b/tests/CustomInputTestService/readme.md
new file mode 100644
index 0000000..1b1d7a7
--- /dev/null
+++ b/tests/CustomInputTestService/readme.md
@@ -0,0 +1,52 @@
+# Custom Input Event
+
+## Building
+```bash
+make CustomInputTestService -j64
+```
+
+### Installing
+```bash
+adb install $OUT/target/product/emulator_car_x86/system/app/CustomInputTestService/CustomInputTestService.apk
+```
+
+## Start CustomInputTestService
+```bash
+adb shell am start-foreground-service com.android.car.custominput.test/.CustomInputTestService
+```
+
+### Running tests
+
+Steps to run unit tests:
+
+1. Build and install CustomInputTestService.apk (see above sections).
+1. Then run:
+
+```bash
+atest CustomInputTestServiceTest
+```
+
+## Inject events (test scripts)
+
+These are the test scripts to demonstrate how CustomInputEvent can be used to implement OEM
+partners non-standard events. They all represent hypothetical features for the sake of documentation
+ only.
+
+*Note*: Make sure CustomInputTestService is installed and started. Especially if you've just
+        ran tests. Depending on the configuration you use, running CustomInputTestServiceTest may
+        uninstall  CustomInputTestService.
+
+### Inject Maps event from steering wheel control
+
+For this example, press home first, then inject the event to start Maps activity by running:
+
+```
+adb shell cmd car_service inject-custom-input -d 0 -customEvent f1
+```
+
+Parameters are:
+* `-d 0`: sets target display type to main display;
+* `-customEvent f1`: sets the OEM partner function to execute. In this implementation, `f1` argument
+    represents the action used to launch Google maps app;
+
+*Note*: For this command to run, make sure Google Maps app is installed first.
diff --git a/tests/CustomInputTestService/res/drawable/custom_input_ref_service.png b/tests/CustomInputTestService/res/drawable/custom_input_ref_service.png
new file mode 100644
index 0000000..4814ff1
--- /dev/null
+++ b/tests/CustomInputTestService/res/drawable/custom_input_ref_service.png
Binary files differ
diff --git a/tests/CustomInputTestService/res/values/strings.xml b/tests/CustomInputTestService/res/values/strings.xml
new file mode 100644
index 0000000..fd465e9
--- /dev/null
+++ b/tests/CustomInputTestService/res/values/strings.xml
@@ -0,0 +1,25 @@
+<!--
+  ~ Copyright (C) 2020 The Android Open Source Project
+  ~
+  ~ Licensed under the Apache License, Version 2.0 (the "License");
+  ~ you may not use this file except in compliance with the License.
+  ~ You may obtain a copy of the License at
+  ~
+  ~      http://www.apache.org/licenses/LICENSE-2.0
+  ~
+  ~ Unless required by applicable law or agreed to in writing, software
+  ~ distributed under the License is distributed on an "AS IS" BASIS,
+  ~ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+  ~ See the License for the specific language governing permissions and
+  ~ limitations under the License.
+  -->
+
+<!-- TODO(b/159623196): set translatable="false" -->
+<!-- TODO(b/159623196): Move this to config.xml -->
+<!-- TODO(b/159623196): Specify this activity, follow the same example as in
+     https://source.corp.google.com/android/packages/services/Car/service/res/values/config.xml;l=55
+ -->
+<resources>
+    <string name="maps_app_package">"com.google.android.apps.maps"</string>
+    <string name="maps_activity_class">com.google.android.maps.MapsActivity</string>
+</resources>
diff --git a/tests/CustomInputTestService/src/com/android/car/custominput/test/CustomInputEventListener.java b/tests/CustomInputTestService/src/com/android/car/custominput/test/CustomInputEventListener.java
new file mode 100644
index 0000000..805565a
--- /dev/null
+++ b/tests/CustomInputTestService/src/com/android/car/custominput/test/CustomInputEventListener.java
@@ -0,0 +1,162 @@
+/*
+ * Copyright (C) 2020 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.car.custominput.test;
+
+import android.annotation.IntDef;
+import android.annotation.NonNull;
+import android.app.ActivityOptions;
+import android.car.input.CarInputManager;
+import android.car.input.CustomInputEvent;
+import android.content.Context;
+import android.content.Intent;
+import android.os.UserHandle;
+import android.util.Log;
+
+import java.lang.annotation.Retention;
+import java.lang.annotation.RetentionPolicy;
+
+/**
+ * Handles incoming {@link CustomInputEvent}. In this implementation, incoming events are expected
+ * to have the display id set, the event input type is represented by the value passed in
+ * `-customEvent` flag (see {@link EventAction} for the available actions).
+ */
+final class CustomInputEventListener {
+
+    private static final String TAG = CustomInputEventListener.class.getSimpleName();
+    private static final boolean DEBUG = Log.isLoggable(TAG, Log.DEBUG);
+
+    private final CustomInputTestService mService;
+    private final Context mContext;
+
+    /** List of defined actions for this reference service implementation */
+    @IntDef({EventAction.LAUNCH_MAPS_ACTION,
+            EventAction.ACCEPT_INCOMING_CALL_ACTION, EventAction.REJECT_INCOMING_CALL_ACTION,
+            EventAction.INCREASE_SOUND_VOLUME_ACTION, EventAction.DECREASE_SOUND_VOLUME_ACTION})
+    @Retention(RetentionPolicy.SOURCE)
+    public @interface EventAction {
+
+        /** Launches Map action. */
+        int LAUNCH_MAPS_ACTION = CustomInputEvent.INPUT_CODE_F1;
+
+        /** Accepts incoming call action. */
+        int ACCEPT_INCOMING_CALL_ACTION = CustomInputEvent.INPUT_CODE_F2;
+
+        /** Rejects incoming call action. */
+        int REJECT_INCOMING_CALL_ACTION = CustomInputEvent.INPUT_CODE_F3;
+
+        /** Increases volume action. */
+        int INCREASE_SOUND_VOLUME_ACTION = CustomInputEvent.INPUT_CODE_F4;
+
+        /** Increases volume action. */
+        int DECREASE_SOUND_VOLUME_ACTION = CustomInputEvent.INPUT_CODE_F5;
+    }
+
+    CustomInputEventListener(
+            @NonNull Context context,
+            @NonNull CustomInputTestService service) {
+        mContext = context;
+        mService = service;
+    }
+
+    void handle(int targetDisplayType, CustomInputEvent event) {
+        if (!isValidTargetDisplayType(targetDisplayType)) {
+            return;
+        }
+        int targetDisplayId = getDisplayIdForDisplayType(targetDisplayType);
+        @EventAction int action = event.getInputCode();
+        switch (action) {
+            case EventAction.LAUNCH_MAPS_ACTION:
+                launchMap(targetDisplayId);
+                break;
+            case EventAction.ACCEPT_INCOMING_CALL_ACTION:
+                acceptIncomingCall(targetDisplayId);
+                break;
+            case EventAction.REJECT_INCOMING_CALL_ACTION:
+                rejectIncomingCall(targetDisplayId);
+                break;
+            case EventAction.INCREASE_SOUND_VOLUME_ACTION:
+                increaseVolume(targetDisplayId);
+                break;
+            case EventAction.DECREASE_SOUND_VOLUME_ACTION:
+                decreaseVolume(targetDisplayId);
+                break;
+            default:
+                Log.e(TAG, "Ignoring event [" + action + "]");
+        }
+    }
+
+    private int getDisplayIdForDisplayType(/* unused for now */ int targetDisplayType) {
+        // TODO(159623196): convert the displayType to displayId using OccupantZoneManager api and
+        //                  add tests. For now, we're just returning the display type.
+        return 0;  // Hardcoded to return main display id for now.
+    }
+
+    private static boolean isValidTargetDisplayType(int displayType) {
+        if (displayType == CarInputManager.TARGET_DISPLAY_TYPE_MAIN) {
+            return true;
+        }
+        Log.w(TAG,
+                "This service implementation can only handle CustomInputEvent with "
+                        + "targetDisplayType set to main display (main display type is {"
+                        + CarInputManager.TARGET_DISPLAY_TYPE_MAIN + "}), current display type is {"
+                        + displayType + "})");
+        return false;
+    }
+
+    private void launchMap(int targetDisplayId) {
+        if (DEBUG) {
+            Log.d(TAG, "Launching Maps on display {" + targetDisplayId + "}");
+        }
+        ActivityOptions options = ActivityOptions.makeBasic();
+        options.setLaunchDisplayId(targetDisplayId);
+        Intent mapsIntent = new Intent(Intent.ACTION_VIEW);
+        mapsIntent.setClassName(mContext.getString(R.string.maps_app_package),
+                mContext.getString(R.string.maps_activity_class));
+        mapsIntent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK | Intent.FLAG_ACTIVITY_CLEAR_TOP);
+        mService.startActivityAsUser(mapsIntent, options.toBundle(), UserHandle.CURRENT);
+    }
+
+    private void acceptIncomingCall(int targetDisplayId) {
+        // TODO(b/159623196): When implementing this method, avoid using
+        //     TelecomManager#acceptRingingCall deprecated method.
+        if (DEBUG) {
+            Log.d(TAG, "Accepting incoming call on display {" + targetDisplayId + "}");
+        }
+    }
+
+    private void rejectIncomingCall(int targetDisplayId) {
+        // TODO(b/159623196): When implementing this method, avoid using
+        //     TelecomManager#endCall deprecated method.
+        if (DEBUG) {
+            Log.d(TAG, "Rejecting incoming call on display {" + targetDisplayId + "}");
+        }
+    }
+
+    private void increaseVolume(int targetDisplayId) {
+        // TODO(b/159623196): Provide implementation.
+        if (DEBUG) {
+            Log.d(TAG, "Increasing volume on display {" + targetDisplayId + "}");
+        }
+    }
+
+    private void decreaseVolume(int targetDisplayId) {
+        // TODO(kanant, b/159623196): Provide implementation.
+        if (DEBUG) {
+            Log.d(TAG, "Decreasing volume on display {" + targetDisplayId + "}");
+        }
+    }
+}
diff --git a/tests/CustomInputTestService/src/com/android/car/custominput/test/CustomInputTestService.java b/tests/CustomInputTestService/src/com/android/car/custominput/test/CustomInputTestService.java
new file mode 100644
index 0000000..0c41eb2
--- /dev/null
+++ b/tests/CustomInputTestService/src/com/android/car/custominput/test/CustomInputTestService.java
@@ -0,0 +1,124 @@
+/*
+ * Copyright (C) 2020 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.android.car.custominput.test;
+
+import android.annotation.NonNull;
+import android.app.Notification;
+import android.app.NotificationChannel;
+import android.app.NotificationManager;
+import android.app.Service;
+import android.car.Car;
+import android.car.input.CarInputManager;
+import android.car.input.CustomInputEvent;
+import android.content.Intent;
+import android.os.IBinder;
+import android.util.Log;
+
+import java.util.List;
+
+/**
+ * This service is a reference implementation to be used as an example on how to define and handle
+ * HW_CUSTOM_INPUT events.
+ */
+// TODO(b/12219669): Rename this to CustomInputSampleService
+public class CustomInputTestService extends Service implements
+        CarInputManager.CarInputCaptureCallback {
+
+    private static final String TAG = CustomInputTestService.class.getSimpleName();
+    private static final boolean DEBUG = false;
+
+    private static final String CHANNEL_ID = CustomInputTestService.class.getSimpleName();
+    private static final int FOREGROUND_ID = 1;
+
+    private Car mCar;
+    private CarInputManager mCarInputManager;
+    private CustomInputEventListener mEventHandler;
+
+    @Override
+    public void onCreate() {
+        super.onCreate();
+        startForeground();
+    }
+
+    private void startForeground() {
+        // TODO(b/12219669): Start this service from carservice, then the code in below
+        //     won't be needed.
+        NotificationChannel channel = new NotificationChannel(CHANNEL_ID,
+                CHANNEL_ID,
+                NotificationManager.IMPORTANCE_DEFAULT);
+        NotificationManager notificationManager = getSystemService(NotificationManager.class);
+        notificationManager.createNotificationChannel(channel);
+        Notification notification = new Notification.Builder(getApplicationContext(), CHANNEL_ID)
+                .setContentTitle("CustomInputTestService")
+                .setContentText("Processing...")
+                .setSmallIcon(R.drawable.custom_input_ref_service)
+                .build();
+        startForeground(FOREGROUND_ID, notification);
+    }
+
+    @Override
+    public int onStartCommand(Intent intent, int flags, int startId) {
+        connectToCarService();
+        return START_STICKY;
+    }
+
+    private void connectToCarService() {
+        if (mCar != null && mCar.isConnected()) {
+            Log.w(TAG, "Ignoring request to connect against car service");
+            return;
+        }
+        Log.i(TAG, "Connecting against car service");
+        mCar = Car.createCar(this, /* handler= */ null, Car.CAR_WAIT_TIMEOUT_WAIT_FOREVER,
+                (car, ready) -> {
+                    mCar = car;
+                    if (ready) {
+                        mCarInputManager =
+                                (CarInputManager) mCar.getCarManager(Car.CAR_INPUT_SERVICE);
+                        mCarInputManager.requestInputEventCapture(this,
+                                CarInputManager.TARGET_DISPLAY_TYPE_MAIN,
+                                new int[]{CarInputManager.INPUT_TYPE_CUSTOM_INPUT_EVENT},
+                                CarInputManager.CAPTURE_REQ_FLAGS_ALLOW_DELAYED_GRANT);
+                    }
+                });
+        mEventHandler = new CustomInputEventListener(getApplicationContext(), this);
+    }
+
+    @Override
+    public void onDestroy() {
+        if (DEBUG) {
+            Log.d(TAG, "Service destroyed");
+        }
+        if (mCarInputManager != null) {
+            mCarInputManager.releaseInputEventCapture(CarInputManager.TARGET_DISPLAY_TYPE_MAIN);
+        }
+        if (mCar != null) {
+            mCar.disconnect();
+            mCar = null;
+        }
+    }
+
+    @Override
+    public IBinder onBind(Intent intent) {
+        return null;
+    }
+
+    @Override
+    public void onCustomInputEvents(int targetDisplayId, @NonNull List<CustomInputEvent> events) {
+        for (CustomInputEvent event : events) {
+            mEventHandler.handle(targetDisplayId, event);
+        }
+    }
+}
diff --git a/tests/CustomInputTestService/tests/AndroidManifest.xml b/tests/CustomInputTestService/tests/AndroidManifest.xml
new file mode 100644
index 0000000..d32e506
--- /dev/null
+++ b/tests/CustomInputTestService/tests/AndroidManifest.xml
@@ -0,0 +1,28 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!--
+  ~ Copyright (C) 2020 The Android Open Source Project
+  ~
+  ~ Licensed under the Apache License, Version 2.0 (the "License");
+  ~ you may not use this file except in compliance with the License.
+  ~ You may obtain a copy of the License at
+  ~
+  ~      http://www.apache.org/licenses/LICENSE-2.0
+  ~
+  ~ Unless required by applicable law or agreed to in writing, software
+  ~ distributed under the License is distributed on an "AS IS" BASIS,
+  ~ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+  ~ See the License for the specific language governing permissions and
+  ~ limitations under the License.
+  -->
+
+<manifest xmlns:android="http://schemas.android.com/apk/res/android"
+          package="com.android.car.custominput.test" >
+
+    <instrumentation android:name="androidx.test.runner.AndroidJUnitRunner"
+                     android:targetPackage="com.android.car.custominput.test"
+                     android:label="Unit Tests for CustomInputTestService"/>
+
+    <application android:debuggable="true">
+        <uses-library android:name="android.test.runner"/>
+    </application>
+</manifest>
\ No newline at end of file
diff --git a/tests/CustomInputTestService/tests/src/com/android/car/custominput/test/CustomInputEventListenerTest.java b/tests/CustomInputTestService/tests/src/com/android/car/custominput/test/CustomInputEventListenerTest.java
new file mode 100644
index 0000000..b4e1e4f
--- /dev/null
+++ b/tests/CustomInputTestService/tests/src/com/android/car/custominput/test/CustomInputEventListenerTest.java
@@ -0,0 +1,114 @@
+/*
+ * Copyright (C) 2020 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.android.car.custominput.test;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+import android.car.input.CarInputManager;
+import android.car.input.CustomInputEvent;
+import android.content.ComponentName;
+import android.content.Context;
+import android.content.Intent;
+import android.os.Bundle;
+import android.os.UserHandle;
+
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.mockito.ArgumentCaptor;
+import org.mockito.Mock;
+import org.mockito.junit.MockitoJUnitRunner;
+
+@RunWith(MockitoJUnitRunner.class)
+public class CustomInputEventListenerTest {
+
+    private CustomInputEventListener mEventHandler;
+
+    @Mock
+    private Context mContext;
+
+    @Mock
+    private CustomInputTestService mService;
+
+    @Before
+    public void setUp() {
+        when(mContext.getString(R.string.maps_app_package)).thenReturn(
+                "com.google.android.apps.maps");
+        when(mContext.getString(R.string.maps_activity_class)).thenReturn(
+                "com.google.android.maps.MapsActivity");
+
+        mEventHandler = new CustomInputEventListener(mContext, mService);
+    }
+
+    @Test
+    public void testHandleEvent_launchingMaps() {
+        // Arrange
+        int anyDisplayId = CarInputManager.TARGET_DISPLAY_TYPE_MAIN;
+        CustomInputEvent event = new CustomInputEvent(
+                // In this implementation, INPUT_TYPE_CUSTOM_EVENT_F1 represents the action of
+                // launching maps.
+                /* inputCode= */ CustomInputEvent.INPUT_CODE_F1,
+                /* targetDisplayType= */ anyDisplayId,
+                /* repeatCounter= */ 1);
+
+        // Act
+        mEventHandler.handle(anyDisplayId, event);
+
+        // Assert
+        ArgumentCaptor<Intent> intentCaptor = ArgumentCaptor.forClass(Intent.class);
+        ArgumentCaptor<Bundle> bundleCaptor = ArgumentCaptor.forClass(Bundle.class);
+        ArgumentCaptor<UserHandle> userHandleCaptor = ArgumentCaptor.forClass(UserHandle.class);
+        verify(mService).startActivityAsUser(intentCaptor.capture(),
+                bundleCaptor.capture(), userHandleCaptor.capture());
+
+        // Assert intent parameter
+        Intent actualIntent = intentCaptor.getValue();
+        assertThat(actualIntent.getAction()).isEqualTo(Intent.ACTION_VIEW);
+        assertThat(actualIntent.getFlags()).isEqualTo(
+                Intent.FLAG_ACTIVITY_NEW_TASK | Intent.FLAG_ACTIVITY_CLEAR_TOP);
+        assertThat(actualIntent.getComponent()).isEqualTo(
+                new ComponentName("com.google.android.apps.maps",
+                        "com.google.android.maps.MapsActivity"));
+
+        // Assert bundle and user parameters
+        assertThat(bundleCaptor.getValue().getInt("android.activity.launchDisplayId")).isEqualTo(
+                /* displayId= */
+                0);  // TODO(b/159623196): displayId is currently hardcoded to 0, see missing
+                               // targetDisplayTarget to targetDisplayId logic in
+                               // CustomInputEventListener
+        assertThat(userHandleCaptor.getValue()).isEqualTo(UserHandle.CURRENT);
+    }
+
+    @Test
+    public void testHandleEvent_ignoringEventsForNonMainDisplay() {
+        int invalidDisplayId = -1;
+        CustomInputEvent event = new CustomInputEvent(CustomInputEvent.INPUT_CODE_F1,
+                invalidDisplayId,
+                /* repeatCounter= */ 1);
+
+        // Act
+        mEventHandler.handle(invalidDisplayId, event);
+
+        // Assert
+        verify(mService, never()).startActivityAsUser(any(Intent.class), any(Bundle.class),
+                any(UserHandle.class));
+    }
+}
diff --git a/tests/android_car_api_test/src/android/car/apitest/VehiclePropertyIdsTest.java b/tests/android_car_api_test/src/android/car/apitest/VehiclePropertyIdsTest.java
index 98451fb..14a38fe 100644
--- a/tests/android_car_api_test/src/android/car/apitest/VehiclePropertyIdsTest.java
+++ b/tests/android_car_api_test/src/android/car/apitest/VehiclePropertyIdsTest.java
@@ -38,12 +38,14 @@
             new ArrayList<>(
                 Arrays.asList(
                     "DISABLED_OPTIONAL_FEATURES",
+                    "HW_CUSTOM_INPUT",
                     "HW_ROTARY_INPUT",
                     "SUPPORT_CUSTOMIZE_VENDOR_PERMISSION"));
     private static final List<Integer> MISSING_VEHICLE_PROPERTY_ID_VALUES =
             new ArrayList<>(
                 Arrays.asList(
                     /*DISABLED_OPTIONAL_FEATURES=*/286265094,
+                    /*HW_CUSTOM_INPUT=*/289475120,
                     /*HW_ROTARY_INPUT=*/289475104,
                     /*SUPPORT_CUSTOMIZE_VENDOR_PERMISSION=*/287313669));
 
diff --git a/tests/carservice_test/src/com/android/car/input/CarInputManagerTest.java b/tests/carservice_test/src/com/android/car/input/CarInputManagerTest.java
index 907bdc2..b654fe7 100644
--- a/tests/carservice_test/src/com/android/car/input/CarInputManagerTest.java
+++ b/tests/carservice_test/src/com/android/car/input/CarInputManagerTest.java
@@ -23,6 +23,7 @@
 import android.annotation.NonNull;
 import android.car.Car;
 import android.car.input.CarInputManager;
+import android.car.input.CustomInputEvent;
 import android.car.input.RotaryEvent;
 import android.hardware.automotive.vehicle.V2_0.VehicleProperty;
 import android.util.Log;
@@ -47,6 +48,7 @@
 import java.util.concurrent.TimeUnit;
 
 
+// TODO(b/159623196): Enhance this class to test HW_CUSTOM_INPUT
 @RunWith(AndroidJUnit4.class)
 @MediumTest
 public final class CarInputManagerTest extends MockedCarTestBase {
@@ -72,6 +74,11 @@
         private final LinkedList<Pair<Integer, List<RotaryEvent>>> mRotaryEvents =
                 new LinkedList<>();
 
+        // Stores passed events. Last one in front
+        @GuardedBy("mLock")
+        private final LinkedList<Pair<Integer, List<CustomInputEvent>>> mCustomInputEvents =
+                new LinkedList<>();
+
         // Stores passed state changes. Last one in front
         @GuardedBy("mLock")
         private final LinkedList<Pair<Integer, int[]>> mStateChanges = new LinkedList<>();
@@ -79,6 +86,7 @@
         private final Semaphore mKeyEventWait = new Semaphore(0);
         private final Semaphore mRotaryEventWait = new Semaphore(0);
         private final Semaphore mStateChangeWait = new Semaphore(0);
+        private final Semaphore mCustomInputEventWait = new Semaphore(0);
 
         @Override
         public void onKeyEvents(int targetDisplayId, List<KeyEvent> keyEvents) {
@@ -100,6 +108,16 @@
         }
 
         @Override
+        public void onCustomInputEvents(int targetDisplayId, List<CustomInputEvent> events) {
+            Log.i(TAG, "onCustomInputEvents event:" + events.get(0) + " this:" + this);
+            synchronized (mLock) {
+                mCustomInputEvents.addFirst(new Pair<Integer, List<CustomInputEvent>>(
+                        targetDisplayId, events));
+            }
+            mCustomInputEventWait.release();
+        }
+
+        @Override
         public void onCaptureStateChanged(int targetDisplayId,
                 @NonNull @CarInputManager.InputTypeEnum int[] activeInputTypes) {
             Log.i(TAG, "onCaptureStateChanged types:" + Arrays.toString(activeInputTypes)
@@ -128,6 +146,10 @@
             mRotaryEventWait.tryAcquire(EVENT_WAIT_TIME, TimeUnit.MILLISECONDS);
         }
 
+        private void waitForCustomInputEvent() throws Exception {
+            mCustomInputEventWait.tryAcquire(EVENT_WAIT_TIME, TimeUnit.MILLISECONDS);
+        }
+
         private LinkedList<Pair<Integer, List<KeyEvent>>> getkeyEvents() {
             synchronized (mLock) {
                 LinkedList<Pair<Integer, List<KeyEvent>>> r =
@@ -155,6 +177,7 @@
 
     private final CaptureCallback mCallback0 = new CaptureCallback();
     private final CaptureCallback mCallback1 = new CaptureCallback();
+    private final CaptureCallback mCallback2 = new CaptureCallback();
 
     @Override
     protected synchronized void configureMockedHal() {
@@ -166,6 +189,10 @@
                 VehiclePropValueBuilder.newBuilder(VehicleProperty.HW_ROTARY_INPUT)
                         .addIntValue(0, 1, 0)
                         .build());
+        addProperty(VehicleProperty.HW_CUSTOM_INPUT,
+                VehiclePropValueBuilder.newBuilder(VehicleProperty.HW_CUSTOM_INPUT)
+                        .addIntValue(0)
+                        .build());
     }
 
     @Override
@@ -337,6 +364,11 @@
                 CarInputManager.TARGET_DISPLAY_TYPE_MAIN,
                 new int[]{CarInputManager.INPUT_TYPE_ROTARY_NAVIGATION}, 0);
         assertThat(r).isEqualTo(CarInputManager.INPUT_CAPTURE_RESPONSE_SUCCEEDED);
+
+        r = mCarInputManager.requestInputEventCapture(mCallback2,
+                CarInputManager.TARGET_DISPLAY_TYPE_MAIN,
+                new int[]{CarInputManager.INPUT_TYPE_CUSTOM_INPUT_EVENT}, 0);
+        assertThat(r).isEqualTo(CarInputManager.INPUT_CAPTURE_RESPONSE_SUCCEEDED);
     }
 
     @Test
diff --git a/tests/carservice_unit_test/src/com/android/car/hal/InputHalServiceTest.java b/tests/carservice_unit_test/src/com/android/car/hal/InputHalServiceTest.java
index 09c3d4a..0bbb23d 100644
--- a/tests/carservice_unit_test/src/com/android/car/hal/InputHalServiceTest.java
+++ b/tests/carservice_unit_test/src/com/android/car/hal/InputHalServiceTest.java
@@ -15,6 +15,8 @@
  */
 package com.android.car.hal;
 
+import static android.hardware.automotive.vehicle.V2_0.CustomInputType.CUSTOM_EVENT_F1;
+
 import static com.google.common.truth.Truth.assertThat;
 
 import static org.mockito.ArgumentMatchers.any;
@@ -27,6 +29,7 @@
 import static org.mockito.Mockito.verify;
 import static org.mockito.Mockito.when;
 
+import android.car.input.CustomInputEvent;
 import android.hardware.automotive.vehicle.V2_0.VehicleHwKeyInputAction;
 import android.hardware.automotive.vehicle.V2_0.VehiclePropConfig;
 import android.hardware.automotive.vehicle.V2_0.VehiclePropValue;
@@ -63,6 +66,8 @@
             VehiclePropConfigBuilder.newBuilder(VehicleProperty.HW_KEY_INPUT).build();
     private static final VehiclePropConfig HW_ROTARY_INPUT_CONFIG =
             VehiclePropConfigBuilder.newBuilder(VehicleProperty.HW_ROTARY_INPUT).build();
+    private static final VehiclePropConfig HW_CUSTOM_INPUT_CONFIG =
+            VehiclePropConfigBuilder.newBuilder(VehicleProperty.HW_CUSTOM_INPUT).build();
     private static final int DISPLAY = 42;
 
     private enum Key { DOWN, UP }
@@ -104,6 +109,7 @@
 
         assertThat(mInputHalService.isKeyInputSupported()).isTrue();
         assertThat(mInputHalService.isRotaryInputSupported()).isFalse();
+        assertThat(mInputHalService.isCustomInputSupported()).isFalse();
     }
 
     @Test
@@ -117,20 +123,37 @@
 
         assertThat(mInputHalService.isRotaryInputSupported()).isTrue();
         assertThat(mInputHalService.isKeyInputSupported()).isFalse();
+        assertThat(mInputHalService.isCustomInputSupported()).isFalse();
     }
 
     @Test
-    public void takesKeyAndRotaryInputProperty() {
+    public void takesCustomInputProperty() {
+        Set<VehiclePropConfig> offeredProps = ImmutableSet.of(
+                VehiclePropConfigBuilder.newBuilder(VehicleProperty.ABS_ACTIVE).build(),
+                HW_CUSTOM_INPUT_CONFIG,
+                VehiclePropConfigBuilder.newBuilder(VehicleProperty.CURRENT_GEAR).build());
+
+        mInputHalService.takeProperties(offeredProps);
+
+        assertThat(mInputHalService.isRotaryInputSupported()).isFalse();
+        assertThat(mInputHalService.isKeyInputSupported()).isFalse();
+        assertThat(mInputHalService.isCustomInputSupported()).isTrue();
+    }
+
+    @Test
+    public void takesKeyAndRotaryAndCustomInputProperty() {
         Set<VehiclePropConfig> offeredProps = ImmutableSet.of(
                 VehiclePropConfigBuilder.newBuilder(VehicleProperty.ABS_ACTIVE).build(),
                 HW_KEY_INPUT_CONFIG,
                 HW_ROTARY_INPUT_CONFIG,
+                HW_CUSTOM_INPUT_CONFIG,
                 VehiclePropConfigBuilder.newBuilder(VehicleProperty.CURRENT_GEAR).build());
 
         mInputHalService.takeProperties(offeredProps);
 
         assertThat(mInputHalService.isKeyInputSupported()).isTrue();
         assertThat(mInputHalService.isRotaryInputSupported()).isTrue();
+        assertThat(mInputHalService.isCustomInputSupported()).isTrue();
     }
 
     @Test
@@ -285,7 +308,8 @@
         assertThat(upEvent.getAction()).isEqualTo(KeyEvent.ACTION_UP);
         assertThat(upEvent.getEventTime()).isEqualTo(timestampMillis);
 
-        events.forEach(KeyEvent::recycle);*/
+        events.forEach(KeyEvent::recycle);
+        */
     }
 
     @Test
@@ -325,6 +349,32 @@
         events.forEach(KeyEvent::recycle);*/
     }
 
+    @Test
+    public void dispatchesCustomInputEvent() {
+        // Arrange mInputListener to capture incoming CustomInputEvent
+        subscribeListener();
+
+        List<CustomInputEvent> events = new ArrayList<>();
+        doAnswer(invocation -> {
+            CustomInputEvent event = invocation.getArgument(0);
+            events.add(event);
+            return null;
+        }).when(mInputListener).onCustomInputEvent(any());
+
+        // Arrange
+        int targetDisplayType = InputHalService.DISPLAY_INSTRUMENT_CLUSTER;
+        int repeatCounter = 1;
+        VehiclePropValue customInputPropValue = makeCustomInputPropValue(
+                CUSTOM_EVENT_F1, targetDisplayType, repeatCounter);
+
+        // Act
+        mInputHalService.onHalEvents(ImmutableList.of(customInputPropValue));
+
+        // Assert
+        assertThat(events).containsExactly(new CustomInputEvent(
+                CustomInputEvent.INPUT_CODE_F1, targetDisplayType, repeatCounter));
+    }
+
     private void subscribeListener() {
         mInputHalService.takeProperties(ImmutableSet.of(HW_KEY_INPUT_CONFIG));
         assertThat(mInputHalService.isKeyInputSupported()).isTrue();
@@ -389,4 +439,14 @@
         v.timestamp = timestamp;
         return v;
     }
+
+    private VehiclePropValue makeCustomInputPropValue(int inputCode, int targetDisplayType,
+            int repeatCounter) {
+        VehiclePropValue v = new VehiclePropValue();
+        v.prop = VehicleProperty.HW_CUSTOM_INPUT;
+        v.value.int32Values.add(inputCode);
+        v.value.int32Values.add(targetDisplayType);
+        v.value.int32Values.add(repeatCounter);
+        return v;
+    }
 }
\ No newline at end of file