diff options
4 files changed, 506 insertions, 0 deletions
diff --git a/core/res/res/values/config.xml b/core/res/res/values/config.xml index 42a249cdc0ea..ec389423808b 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 0951aecceb94..c340942c672c 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 000000000000..8be3b2de4adf --- /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 000000000000..a81898df9235 --- /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; + } +} |