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;
+ }
+}