Merge "Create parser for custom haptic feedback vibration XMLs" into main
This commit is contained in:
committed by
Android (Google) Code Review
commit
50e3809a53
@@ -6553,4 +6553,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>
|
||||
|
||||
@@ -5177,4 +5177,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>
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user