Create parser for custom haptic feedback vibration XMLs

This change adds a parser for custom haptic feedback vibration XML
parsing. This allows devices to override the default VibrationEffects
used for haptic feedback constants.

Bug: 291128479
Test: atest HapticFeedbackVibrationCustomizationParserTest

Change-Id: I372d2c4ac6f9476e9d4c3b44b4adb107f63e72b5
diff --git a/core/res/res/values/config.xml b/core/res/res/values/config.xml
index 42a249c..ec38942 100644
--- a/core/res/res/values/config.xml
+++ b/core/res/res/values/config.xml
@@ -6542,4 +6542,12 @@
          serialization, a default vibration will be used.
          Note that, indefinitely repeating vibrations are not allowed as shutdown vibrations. -->
     <string name="config_defaultShutdownVibrationFile" />
+    <!-- The file path in which custom vibrations are provided for haptic feedbacks.
+         If the device does not specify any such file path here, if the file path specified here
+         does not exist, or if the contents of the file does not make up a valid customization
+         serialization, the system default vibrations for haptic feedback will be used.
+         If the content of the customization file is valid, the system will use the provided
+         vibrations for the customized haptic feedback IDs, and continue to use the system defaults
+         for the non-customized ones. -->
+    <string name="config_hapticFeedbackCustomizationFile" />
 </resources>
diff --git a/core/res/res/values/symbols.xml b/core/res/res/values/symbols.xml
index 0951aec..c340942 100644
--- a/core/res/res/values/symbols.xml
+++ b/core/res/res/values/symbols.xml
@@ -5176,4 +5176,5 @@
   <java-symbol type="drawable" name="focus_event_pressed_key_background" />
   <java-symbol type="string" name="config_defaultShutdownVibrationFile" />
   <java-symbol type="string" name="lockscreen_too_many_failed_attempts_countdown" />
+  <java-symbol type="string" name="config_hapticFeedbackCustomizationFile" />
 </resources>
diff --git a/services/core/java/com/android/server/vibrator/HapticFeedbackCustomization.java b/services/core/java/com/android/server/vibrator/HapticFeedbackCustomization.java
new file mode 100644
index 0000000..8be3b2d
--- /dev/null
+++ b/services/core/java/com/android/server/vibrator/HapticFeedbackCustomization.java
@@ -0,0 +1,202 @@
+/*
+ * Copyright 2023 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.vibrator;
+
+import android.annotation.Nullable;
+import android.content.res.Resources;
+import android.os.VibrationEffect;
+import android.os.vibrator.persistence.VibrationXmlParser;
+import android.text.TextUtils;
+import android.util.Slog;
+import android.util.SparseArray;
+import android.util.Xml;
+
+import com.android.internal.vibrator.persistence.XmlParserException;
+import com.android.internal.vibrator.persistence.XmlReader;
+import com.android.internal.vibrator.persistence.XmlValidator;
+import com.android.modules.utils.TypedXmlPullParser;
+
+import org.xmlpull.v1.XmlPullParser;
+import org.xmlpull.v1.XmlPullParserException;
+
+import java.io.FileNotFoundException;
+import java.io.FileReader;
+import java.io.IOException;
+
+/**
+ * Class that loads custom {@link VibrationEffect} to be performed for each
+ * {@link HapticFeedbackConstants} key.
+ *
+ * <p>The system has its default logic to get the {@link VibrationEffect} that will be played for a
+ * given haptic feedback constant. Devices may choose to override some or all of these supported
+ * haptic feedback vibrations via a customization XML.
+ *
+ * <p>The XML simply provides a mapping of a constant from {@link HapticFeedbackConstants} to its
+ * corresponding {@link VibrationEffect}. Its root tag should be `<haptic-feedback-constants>`. It
+ * should have one or more entries for customizing a haptic feedback constant. A customization is
+ * started by a `<constant id="X">` tag (where `X` is the haptic feedback constant being customized
+ * in this entry) and closed by </constant>. Between these two tags, there should be a valid XML
+ * serialization of a non-repeating {@link VibrationEffect}. Such a valid vibration serialization
+ * should be parse-able by {@link VibrationXmlParser}.
+ *
+ * The example below represents a valid customization for effect IDs 10 and 11.
+ *
+ * <pre>
+ *   {@code
+ *     <haptic-feedback-constants>
+ *          <constant id="10">
+ *              // Valid Vibration Serialization
+ *          </constant>
+ *          <constant id="11">
+ *              // Valid Vibration Serialization
+ *          </constant>
+ *     </haptic-feedback-constants>
+ *   }
+ * </pre>
+ *
+ * <p>After a successful parsing of the customization XML file, it returns a {@link SparseArray}
+ * that maps each customized haptic feedback effect ID to its respective {@link VibrationEffect}.
+ *
+ * @hide
+ */
+final class HapticFeedbackCustomization {
+    private static final String TAG = "HapticFeedbackCustomization";
+
+    /** The outer-most tag for haptic feedback customizations.  */
+    private static final String TAG_CONSTANTS = "haptic-feedback-constants";
+    /** The tag defining a customization for a single haptic feedback constant. */
+    private static final String TAG_CONSTANT = "constant";
+
+    /**
+     * Attribute for {@link TAG_CONSTANT}, specifying the haptic feedback constant to
+     * customize.
+     */
+    private static final String ATTRIBUTE_ID = "id";
+
+    /**
+     * Parses the haptic feedback vibration customization XML file for the device, and provides a
+     * mapping of the customized effect IDs to their respective {@link VibrationEffect}s.
+     *
+     * <p>This is potentially expensive, so avoid calling repeatedly. One call is enough, and the
+     * caller should process the returned mapping (if any) for further queries.
+     *
+     * @param res {@link Resources} object to be used for reading the device's resources.
+     * @return a {@link SparseArray} that maps each customized haptic feedback effect ID to its
+     *      respective {@link VibrationEffect}, or {@code null}, if the device has not configured
+     *      a file for haptic feedback constants customization.
+     * @throws {@link IOException} if an IO error occurs while parsing the customization XML.
+     * @throws {@link CustomizationParserException} for any non-IO error that occurs when parsing
+     *      the XML, like an invalid XML content or an invalid haptic feedback constant.
+     *
+     * @hide
+     */
+    @Nullable
+    static SparseArray<VibrationEffect> loadVibrations(Resources res)
+            throws CustomizationParserException, IOException {
+        try {
+            return loadVibrationsInternal(res);
+        } catch (VibrationXmlParser.VibrationXmlParserException
+                | XmlParserException
+                | XmlPullParserException e) {
+            throw new CustomizationParserException(
+                    "Error parsing haptic feedback customization file.", e);
+        }
+    }
+
+    @Nullable
+    private static SparseArray<VibrationEffect> loadVibrationsInternal(Resources res) throws
+            CustomizationParserException,
+            IOException,
+            VibrationXmlParser.VibrationXmlParserException,
+            XmlParserException,
+            XmlPullParserException {
+        String customizationFile =
+                res.getString(
+                        com.android.internal.R.string.config_hapticFeedbackCustomizationFile);
+        if (TextUtils.isEmpty(customizationFile)) {
+            Slog.d(TAG, "Customization file not configured.");
+            return null;
+        }
+
+        FileReader fileReader;
+        try {
+            fileReader = new FileReader(customizationFile);
+        } catch (FileNotFoundException e) {
+            Slog.d(TAG, "Specified customization file not found.");
+            return  null;
+        }
+
+        TypedXmlPullParser parser = Xml.newFastPullParser();
+        parser.setFeature(XmlPullParser.FEATURE_PROCESS_NAMESPACES, true);
+        parser.setInput(fileReader);
+
+        XmlReader.readDocumentStartTag(parser, TAG_CONSTANTS);
+        XmlValidator.checkTagHasNoUnexpectedAttributes(parser);
+        int rootDepth = parser.getDepth();
+
+        SparseArray<VibrationEffect> mapping = new SparseArray<>();
+        while (XmlReader.readNextTagWithin(parser, rootDepth)) {
+            XmlValidator.checkStartTag(parser, TAG_CONSTANT);
+            int customizationDepth = parser.getDepth();
+
+            // Only attribute in tag is the `id` attribute.
+            XmlValidator.checkTagHasNoUnexpectedAttributes(parser, ATTRIBUTE_ID);
+            int effectId = XmlReader.readAttributeIntNonNegative(parser, ATTRIBUTE_ID);
+            if (mapping.contains(effectId)) {
+                throw new CustomizationParserException(
+                        "Multiple customizations found for effect " + effectId);
+            }
+
+            // Move the parser one step into the `<constant>` tag.
+            XmlValidator.checkParserCondition(
+                    XmlReader.readNextTagWithin(parser, customizationDepth),
+                    "Unsupported empty customization tag");
+
+            VibrationEffect effect = VibrationXmlParser.parseTag(
+                    parser, VibrationXmlParser.FLAG_ALLOW_HIDDEN_APIS);
+            if (effect.getDuration() == Long.MAX_VALUE) {
+                throw new CustomizationParserException(String.format(
+                        "Vibration for effect ID %d is repeating, which is not allowed as a"
+                        + " haptic feedback: %s", effectId, effect));
+            }
+            mapping.put(effectId, effect);
+
+            XmlReader.readEndTag(parser, TAG_CONSTANT, customizationDepth);
+        }
+
+        // Make checks that the XML ends well.
+        XmlReader.readEndTag(parser, TAG_CONSTANTS, rootDepth);
+        XmlReader.readDocumentEndTag(parser);
+
+        return mapping;
+    }
+
+    /**
+     * Represents an error while parsing a haptic feedback customization XML.
+     *
+     * @hide
+     */
+    static final class CustomizationParserException extends Exception {
+        private CustomizationParserException(String message) {
+            super(message);
+        }
+
+        private CustomizationParserException(String message, Throwable cause) {
+            super(message, cause);
+        }
+    }
+}
diff --git a/services/tests/vibrator/src/com/android/server/vibrator/HapticFeedbackCustomizationTest.java b/services/tests/vibrator/src/com/android/server/vibrator/HapticFeedbackCustomizationTest.java
new file mode 100644
index 0000000..a81898d
--- /dev/null
+++ b/services/tests/vibrator/src/com/android/server/vibrator/HapticFeedbackCustomizationTest.java
@@ -0,0 +1,295 @@
+/*
+ * Copyright 2023 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.vibrator;
+
+
+import static android.os.VibrationEffect.Composition.PRIMITIVE_TICK;
+import static android.os.VibrationEffect.EFFECT_CLICK;
+
+import static com.android.server.vibrator.HapticFeedbackCustomization.CustomizationParserException;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import static org.junit.Assert.assertThrows;
+import static org.mockito.Mockito.when;
+
+import android.content.res.Resources;
+import android.os.VibrationEffect;
+import android.util.AtomicFile;
+import android.util.SparseArray;
+
+import androidx.test.InstrumentationRegistry;
+
+import com.android.internal.R;
+
+import org.junit.Rule;
+import org.junit.Test;
+import org.mockito.Mock;
+import org.mockito.junit.MockitoJUnit;
+import org.mockito.junit.MockitoRule;
+
+import java.io.File;
+import java.io.FileOutputStream;
+
+public class HapticFeedbackCustomizationTest {
+    @Rule public MockitoRule rule = MockitoJUnit.rule();
+
+    // Pairs of valid vibration XML along with their equivalent VibrationEffect.
+    private static final String COMPOSITION_VIBRATION_XML = "<vibration>"
+            + "<primitive-effect name=\"tick\" scale=\"0.2497\"/>"
+            + "</vibration>";
+    private static final VibrationEffect COMPOSITION_VIBRATION =
+            VibrationEffect.startComposition().addPrimitive(PRIMITIVE_TICK, 0.2497f).compose();
+
+    private static final String PREDEFINED_VIBRATION_XML =
+            "<vibration><predefined-effect name=\"click\"/></vibration>";
+    private static final VibrationEffect PREDEFINED_VIBRATION =
+            VibrationEffect.createPredefined(EFFECT_CLICK);
+
+    @Mock private Resources mResourcesMock;
+
+    @Test
+    public void testParseCustomizations_noCustomization_success() throws Exception {
+        assertParseCustomizationsSucceeds(
+                /* xml= */ "<haptic-feedback-constants></haptic-feedback-constants>",
+                /* expectedCustomizations= */ new SparseArray<>());
+    }
+
+    @Test
+    public void testParseCustomizations_oneCustomization_success() throws Exception {
+        String xml = "<haptic-feedback-constants>"
+                + "<constant id=\"10\">"
+                + COMPOSITION_VIBRATION_XML
+                + "</constant>"
+                + "</haptic-feedback-constants>";
+        SparseArray<VibrationEffect> expectedMapping = new SparseArray<>();
+        expectedMapping.put(10, COMPOSITION_VIBRATION);
+
+        assertParseCustomizationsSucceeds(xml, expectedMapping);
+    }
+
+    @Test
+    public void testParseCustomizations_multipleCustomizations_success() throws Exception {
+        String xml = "<haptic-feedback-constants>"
+                + "<constant id=\"1\">"
+                + COMPOSITION_VIBRATION_XML
+                + "</constant>"
+                + "<constant id=\"12\">"
+                + PREDEFINED_VIBRATION_XML
+                + "</constant>"
+                + "<constant id=\"150\">"
+                + PREDEFINED_VIBRATION_XML
+                + "</constant>"
+                + "</haptic-feedback-constants>";
+        SparseArray<VibrationEffect> expectedMapping = new SparseArray<>();
+        expectedMapping.put(1, COMPOSITION_VIBRATION);
+        expectedMapping.put(12, PREDEFINED_VIBRATION);
+        expectedMapping.put(150, PREDEFINED_VIBRATION);
+
+        assertParseCustomizationsSucceeds(xml, expectedMapping);
+    }
+
+    @Test
+    public void testParseCustomizations_noCustomizationFile_returnsNull() throws Exception {
+        setCustomizationFilePath("");
+
+        assertThat(HapticFeedbackCustomization.loadVibrations(mResourcesMock)).isNull();
+
+        setCustomizationFilePath(null);
+
+        assertThat(HapticFeedbackCustomization.loadVibrations(mResourcesMock)).isNull();
+
+        setCustomizationFilePath("non_existent_file.xml");
+
+        assertThat(HapticFeedbackCustomization.loadVibrations(mResourcesMock)).isNull();
+    }
+
+    @Test
+    public void testParseCustomizations_disallowedVibrationForHapticFeedback_throwsException()
+            throws Exception {
+        // The XML content is good, but the serialized vibration is not supported for haptic
+        // feedback usage (i.e. repeating vibration).
+        assertParseCustomizationsFails(
+                "<haptic-feedback-constants>"
+                + "<constant id=\"10\">"
+                + "<vibration>"
+                + "<waveform-effect>"
+                + "<repeating>"
+                + "<waveform-entry durationMs=\"10\" amplitude=\"100\"/>"
+                + "</repeating>"
+                + "</waveform-effect>"
+                + "</vibration>"
+                + "</constant>"
+                + "</haptic-feedback-constants>");
+    }
+
+    @Test
+    public void testParseCustomizations_emptyXml_throwsException() throws Exception {
+        assertParseCustomizationsFails("");
+    }
+
+    @Test
+    public void testParseCustomizations_noVibrationXml_throwsException() throws Exception {
+        assertParseCustomizationsFails(
+                "<haptic-feedback-constants>"
+                + "<constant id=\"1\">"
+                + "</constant>"
+                + "</haptic-feedback-constants>");
+    }
+
+    @Test
+    public void testParseCustomizations_badEffectId_throwsException() throws Exception {
+        // Negative id
+        assertParseCustomizationsFails(
+                "<haptic-feedback-constants>"
+                + "<constant id=\"-10\">"
+                + COMPOSITION_VIBRATION_XML
+                + "</constant>"
+                + "</haptic-feedback-constants>");
+
+        // Non-numeral id
+        assertParseCustomizationsFails(
+                "<haptic-feedback-constants>"
+                + "<constant id=\"xyz\">"
+                + COMPOSITION_VIBRATION_XML
+                + "</constant>"
+                + "</haptic-feedback-constants>");
+    }
+
+    @Test
+    public void testParseCustomizations_malformedXml_throwsException() throws Exception {
+        // No start "<constant>" tag
+        assertParseCustomizationsFails(
+                "<haptic-feedback-constants>"
+                + COMPOSITION_VIBRATION_XML
+                + "</constant>"
+                + "</haptic-feedback-constants>");
+
+        // No end "<constant>" tag
+        assertParseCustomizationsFails(
+                "<haptic-feedback-constants>"
+                + "<constant id=\"10\">"
+                + COMPOSITION_VIBRATION_XML
+                + "</haptic-feedback-constants>");
+
+        // No start "<haptic-feedback-constants>" tag
+        assertParseCustomizationsFails(
+                "<constant id=\"10\">"
+                + COMPOSITION_VIBRATION_XML
+                + "</constant>"
+                + "</haptic-feedback-constants>");
+
+        // No end "<haptic-feedback-constants>" tag
+        assertParseCustomizationsFails(
+                "<haptic-feedback-constants>"
+                + "<constant id=\"10\">"
+                + COMPOSITION_VIBRATION_XML
+                + "</constant>");
+    }
+
+    @Test
+    public void testParseCustomizations_badVibrationXml_throwsException() throws Exception {
+        assertParseCustomizationsFails(
+                "<haptic-feedback-constants>"
+                + "<constant id=\"10\">"
+                + "<bad-vibration></bad-vibration>"
+                + "</constant>"
+                + "</haptic-feedback-constants>");
+
+        assertParseCustomizationsFails(
+                "<haptic-feedback-constants>"
+                + "<constant id=\"10\">"
+                + "<vibration><predefined-effect name=\"bad-effect-name\"/></vibration>"
+                + "</constant>"
+                + "</haptic-feedback-constants>");
+    }
+
+    @Test
+    public void testParseCustomizations_badConstantAttribute_throwsException() throws Exception {
+        assertParseCustomizationsFails(
+                "<haptic-feedback-constants>"
+                + "<constant iddddd=\"10\">"
+                + COMPOSITION_VIBRATION_XML
+                + "</constant>"
+                + "</haptic-feedback-constants>");
+
+        assertParseCustomizationsFails(
+                "<haptic-feedback-constants>"
+                + "<constant id=\"10\" unwanted-attr=\"1\">"
+                + COMPOSITION_VIBRATION_XML
+                + "</constant>"
+                + "</haptic-feedback-constants>");
+    }
+
+    @Test
+    public void testParseCustomizations_duplicateEffects_throwsException() throws Exception {
+        assertParseCustomizationsFails(
+                "<haptic-feedback-constants>"
+                + "<constant id=\"10\">"
+                + COMPOSITION_VIBRATION_XML
+                + "</constant>"
+                + "<constant id=\"10\">"
+                + PREDEFINED_VIBRATION_XML
+                + "</constant>"
+                + "<constant id=\"11\">"
+                + PREDEFINED_VIBRATION_XML
+                + "</constant>"
+                + "</haptic-feedback-constants>");
+    }
+
+    private void assertParseCustomizationsSucceeds(
+            String xml, SparseArray<VibrationEffect> expectedCustomizations) throws Exception {
+        setupCustomizationFile(xml);
+        assertThat(expectedCustomizations.contentEquals(
+                HapticFeedbackCustomization.loadVibrations(mResourcesMock))).isTrue();
+    }
+
+    private void assertParseCustomizationsFails(String xml) throws Exception {
+        setupCustomizationFile(xml);
+        assertThrows("Expected haptic feedback customization to fail for " + xml,
+                CustomizationParserException.class,
+                () ->  HapticFeedbackCustomization.loadVibrations(mResourcesMock));
+    }
+
+    private void assertParseCustomizationsFails() throws Exception {
+        assertThrows("Expected haptic feedback customization to fail",
+                CustomizationParserException.class,
+                () ->  HapticFeedbackCustomization.loadVibrations(mResourcesMock));
+    }
+
+    private void setupCustomizationFile(String xml) throws Exception {
+        File file = createFile(xml);
+        setCustomizationFilePath(file.getAbsolutePath());
+    }
+
+    private void setCustomizationFilePath(String path) {
+        when(mResourcesMock.getString(R.string.config_hapticFeedbackCustomizationFile))
+                .thenReturn(path);
+    }
+
+    private static File createFile(String contents) throws Exception {
+        File file = new File(InstrumentationRegistry.getContext().getCacheDir(), "test.xml");
+        file.createNewFile();
+
+        AtomicFile testAtomicXmlFile = new AtomicFile(file);
+        FileOutputStream fos = testAtomicXmlFile.startWrite();
+        fos.write(contents.getBytes());
+        testAtomicXmlFile.finishWrite(fos);
+
+        return file;
+    }
+}