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
This commit is contained in:
Yeabkal Wubshit
2023-07-18 02:01:58 -07:00
parent 64cec1788c
commit 34cbf02d36
4 changed files with 506 additions and 0 deletions

View File

@@ -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>

View File

@@ -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>

View File

@@ -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);
}
}
}

View File

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