Donate to e Foundation | Murena handsets with /e/OS | Own a part of Murena! Learn more

Commit 34cbf02d authored by Yeabkal Wubshit's avatar Yeabkal Wubshit
Browse files

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
parent 64cec178
Loading
Loading
Loading
Loading
+8 −0
Original line number Diff line number Diff line
@@ -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>
+1 −0
Original line number Diff line number Diff line
@@ -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>
+202 −0
Original line number Diff line number Diff line
/*
 * 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);
        }
    }
}
+295 −0
Original line number Diff line number Diff line
/*
 * 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;
    }
}