XmlVectorParser.android.kt

/*
 * Copyright 2019 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 androidx.compose.ui.graphics.vector.compat

import android.content.res.ColorStateList
import android.content.res.Resources
import android.content.res.TypedArray
import android.util.AttributeSet
import android.util.TypedValue
import androidx.annotation.ColorInt
import androidx.annotation.StyleableRes
import androidx.compose.ui.graphics.BlendMode
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.PathFillType
import androidx.compose.ui.graphics.ShaderBrush
import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.graphics.StrokeCap
import androidx.compose.ui.graphics.StrokeJoin
import androidx.compose.ui.graphics.vector.DefaultPivotX
import androidx.compose.ui.graphics.vector.DefaultPivotY
import androidx.compose.ui.graphics.vector.DefaultRotation
import androidx.compose.ui.graphics.vector.DefaultScaleX
import androidx.compose.ui.graphics.vector.DefaultScaleY
import androidx.compose.ui.graphics.vector.DefaultTranslationX
import androidx.compose.ui.graphics.vector.DefaultTranslationY
import androidx.compose.ui.graphics.vector.EmptyPath
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.graphics.vector.PathNode
import androidx.compose.ui.graphics.vector.addPathNodes
import androidx.compose.ui.unit.dp
import androidx.core.content.res.ComplexColorCompat
import androidx.core.content.res.TypedArrayUtils
import org.xmlpull.v1.XmlPullParser
import org.xmlpull.v1.XmlPullParserException

private const val LINECAP_BUTT = 0
private const val LINECAP_ROUND = 1
private const val LINECAP_SQUARE = 2

private const val LINEJOIN_MITER = 0
private const val LINEJOIN_ROUND = 1
private const val LINEJOIN_BEVEL = 2

private val FILL_TYPE_WINDING = 0

private const val SHAPE_CLIP_PATH = "clip-path"
private const val SHAPE_GROUP = "group"
private const val SHAPE_PATH = "path"

private fun getStrokeLineCap(id: Int, defValue: StrokeCap = StrokeCap.Butt): StrokeCap =
    when (id) {
        LINECAP_BUTT -> StrokeCap.Butt
        LINECAP_ROUND -> StrokeCap.Round
        LINECAP_SQUARE -> StrokeCap.Square
        else -> defValue
    }

private fun getStrokeLineJoin(id: Int, defValue: StrokeJoin = StrokeJoin.Miter): StrokeJoin =
    when (id) {
        LINEJOIN_MITER -> StrokeJoin.Miter
        LINEJOIN_ROUND -> StrokeJoin.Round
        LINEJOIN_BEVEL -> StrokeJoin.Bevel
        else -> defValue
    }

internal fun XmlPullParser.isAtEnd(): Boolean =
    eventType == XmlPullParser.END_DOCUMENT ||
        (depth < 1 && eventType == XmlPullParser.END_TAG)

/**
 * @param nestedGroups The number of additionally nested VectorGroups to represent clip paths.
 * @return The number of nested VectorGroups that are not `<group>` in XML, but represented as
 * VectorGroup in the [builder]. These are also popped when this function sees `</group>`.
 */
internal fun AndroidVectorParser.parseCurrentVectorNode(
    res: Resources,
    attrs: AttributeSet,
    theme: Resources.Theme? = null,
    builder: ImageVector.Builder,
    nestedGroups: Int
): Int {
    when (xmlParser.eventType) {
        XmlPullParser.START_TAG -> {
            when (xmlParser.name) {
                SHAPE_PATH -> {
                    parsePath(res, theme, attrs, builder)
                }
                SHAPE_CLIP_PATH -> {
                    parseClipPath(res, theme, attrs, builder)
                    return nestedGroups + 1
                }
                SHAPE_GROUP -> {
                    parseGroup(res, theme, attrs, builder)
                }
            }
        }
        XmlPullParser.END_TAG -> {
            if (SHAPE_GROUP == xmlParser.name) {
                repeat(nestedGroups + 1) {
                    builder.clearGroup()
                }
                return 0
            }
        }
    }
    return nestedGroups
}

/**
 * Helper method to seek to the first tag within the VectorDrawable xml asset
 */
@Throws(XmlPullParserException::class)
internal fun XmlPullParser.seekToStartTag(): XmlPullParser {
    var type = next()
    while (type != XmlPullParser.START_TAG && type != XmlPullParser.END_DOCUMENT) {
        // Empty loop
        type = next()
    }
    if (type != XmlPullParser.START_TAG) {
        throw XmlPullParserException("No start tag found")
    }
    return this
}

@SuppressWarnings("RestrictedApi")
internal fun AndroidVectorParser.createVectorImageBuilder(
    res: Resources,
    theme: Resources.Theme?,
    attrs: AttributeSet
): ImageVector.Builder {
    val vectorAttrs = obtainAttributes(
        res,
        theme,
        attrs,
        AndroidVectorResources.STYLEABLE_VECTOR_DRAWABLE_TYPE_ARRAY
    )

    // TODO (njawad) handle mirroring here
//        state.mAutoMirrored = TypedArrayUtils.getNamedBoolean(a, parser, "autoMirrored",
//                AndroidVectorResources.STYLEABLE_VECTOR_DRAWABLE_AUTO_MIRRORED, state.mAutoMirrored)

    val viewportWidth = getNamedFloat(
        vectorAttrs,
        "viewportWidth",
        AndroidVectorResources.STYLEABLE_VECTOR_DRAWABLE_VIEWPORT_WIDTH,
        0.0f
    )

    val viewportHeight = getNamedFloat(
        vectorAttrs,
        "viewportHeight",
        AndroidVectorResources.STYLEABLE_VECTOR_DRAWABLE_VIEWPORT_HEIGHT,
        0.0f
    )

    if (viewportWidth <= 0) {
        throw XmlPullParserException(
            vectorAttrs.positionDescription + "<VectorGraphic> tag requires viewportWidth > 0"
        )
    } else if (viewportHeight <= 0) {
        throw XmlPullParserException(
            vectorAttrs.positionDescription + "<VectorGraphic> tag requires viewportHeight > 0"
        )
    }

    val defaultWidth = getDimension(
        vectorAttrs,
        AndroidVectorResources.STYLEABLE_VECTOR_DRAWABLE_WIDTH, 0.0f
    )
    val defaultHeight = getDimension(
        vectorAttrs,
        AndroidVectorResources.STYLEABLE_VECTOR_DRAWABLE_HEIGHT, 0.0f
    )

    val tintColor = if (
        vectorAttrs.hasValue(AndroidVectorResources.STYLEABLE_VECTOR_DRAWABLE_TINT)
    ) {
        val value = TypedValue()
        vectorAttrs.getValue(AndroidVectorResources.STYLEABLE_VECTOR_DRAWABLE_TINT, value)
        // Unable to parse theme attributes outside of the framework here.
        // This is a similar limitation to VectorDrawableCompat's parsing logic within
        // updateStateFromTypedArray as TypedArray#extractThemeAttrs is not a public API
        // ignore tint colors provided from the theme itself.
        if (value.type == TypedValue.TYPE_ATTRIBUTE) {
            Color.Unspecified
        } else {
            val tintColorStateList = getNamedColorStateList(
                vectorAttrs, theme, "tint",
                AndroidVectorResources.STYLEABLE_VECTOR_DRAWABLE_TINT
            )
            if (tintColorStateList != null) {
                Color(tintColorStateList.defaultColor)
            } else {
                Color.Unspecified
            }
        }
    } else {
        Color.Unspecified
    }

    val blendModeValue = getInt(
        vectorAttrs,
        AndroidVectorResources.STYLEABLE_VECTOR_DRAWABLE_TINT_MODE, -1
    )
    val tintBlendMode = if (blendModeValue != -1) {
        when (blendModeValue) {
            3 -> BlendMode.SrcOver
            5 -> BlendMode.SrcIn
            9 -> BlendMode.SrcAtop
            // b/73224934 PorterDuff Multiply maps to Skia Modulate so actually
            // return BlendMode.MODULATE here
            14 -> BlendMode.Modulate
            15 -> BlendMode.Screen
            16 -> BlendMode.Plus
            else -> BlendMode.SrcIn
        }
    } else {
        BlendMode.SrcIn
    }

    val defaultWidthDp = (defaultWidth / res.displayMetrics.density).dp
    val defaultHeightDp = (defaultHeight / res.displayMetrics.density).dp

    vectorAttrs.recycle()

    return ImageVector.Builder(
        defaultWidth = defaultWidthDp,
        defaultHeight = defaultHeightDp,
        viewportWidth = viewportWidth,
        viewportHeight = viewportHeight,
        tintColor = tintColor,
        tintBlendMode = tintBlendMode
    )
}

@Throws(IllegalArgumentException::class)
@SuppressWarnings("RestrictedApi")
internal fun AndroidVectorParser.parsePath(
    res: Resources,
    theme: Resources.Theme?,
    attrs: AttributeSet,
    builder: ImageVector.Builder
) {
    val a = obtainAttributes(
        res,
        theme,
        attrs,
        AndroidVectorResources.STYLEABLE_VECTOR_DRAWABLE_PATH
    )

    val hasPathData = TypedArrayUtils.hasAttribute(xmlParser, "pathData")
    if (!hasPathData) {
        // If there is no pathData in the VPath tag, then this is an empty VPath,
        // nothing need to be drawn.
        throw IllegalArgumentException("No path data available")
    }

    val name: String = getString(
        a, AndroidVectorResources.STYLEABLE_VECTOR_DRAWABLE_PATH_NAME
    ) ?: ""

    val pathStr = getString(a, AndroidVectorResources.STYLEABLE_VECTOR_DRAWABLE_PATH_PATH_DATA)

    val pathData: List<PathNode> = addPathNodes(pathStr)

    val fillColor = getNamedComplexColor(
        a,
        theme,
        "fillColor",
        AndroidVectorResources.STYLEABLE_VECTOR_DRAWABLE_PATH_FILL_COLOR, 0
    )
    val fillAlpha = getNamedFloat(
        a,
        "fillAlpha",
        AndroidVectorResources.STYLEABLE_VECTOR_DRAWABLE_PATH_FILL_ALPHA, 1.0f
    )
    val lineCap = getNamedInt(
        a,
        "strokeLineCap",
        AndroidVectorResources.STYLEABLE_VECTOR_DRAWABLE_PATH_STROKE_LINE_CAP, -1
    )
    val strokeLineCap = getStrokeLineCap(lineCap, StrokeCap.Butt)
    val lineJoin = getNamedInt(
        a,
        "strokeLineJoin",
        AndroidVectorResources.STYLEABLE_VECTOR_DRAWABLE_PATH_STROKE_LINE_JOIN, -1
    )
    val strokeLineJoin =
        getStrokeLineJoin(lineJoin, StrokeJoin.Bevel)
    val strokeMiterLimit = getNamedFloat(
        a,
        "strokeMiterLimit",
        AndroidVectorResources.STYLEABLE_VECTOR_DRAWABLE_PATH_STROKE_MITER_LIMIT,
        1.0f
    )
    val strokeColor = getNamedComplexColor(
        a,
        theme,
        "strokeColor",
        AndroidVectorResources.STYLEABLE_VECTOR_DRAWABLE_PATH_STROKE_COLOR, 0
    )
    val strokeAlpha = getNamedFloat(
        a,
        "strokeAlpha",
        AndroidVectorResources.STYLEABLE_VECTOR_DRAWABLE_PATH_STROKE_ALPHA, 1.0f
    )
    val strokeLineWidth = getNamedFloat(
        a,
        "strokeWidth",
        AndroidVectorResources.STYLEABLE_VECTOR_DRAWABLE_PATH_STROKE_WIDTH, 1.0f
    )

    val trimPathEnd = getNamedFloat(
        a,
        "trimPathEnd",
        AndroidVectorResources.STYLEABLE_VECTOR_DRAWABLE_PATH_TRIM_PATH_END, 1.0f
    )
    val trimPathOffset = getNamedFloat(
        a,
        "trimPathOffset",
        AndroidVectorResources.STYLEABLE_VECTOR_DRAWABLE_PATH_TRIM_PATH_OFFSET,
        0.0f
    )
    val trimPathStart = getNamedFloat(
        a,
        "trimPathStart",
        AndroidVectorResources.STYLEABLE_VECTOR_DRAWABLE_PATH_TRIM_PATH_START,
        0.0f
    )

    val fillRule = getNamedInt(
        a,
        "fillType",
        AndroidVectorResources.STYLEABLE_VECTOR_DRAWABLE_PATH_TRIM_PATH_FILLTYPE,
        FILL_TYPE_WINDING
    )

    a.recycle()

    val fillBrush = obtainBrushFromComplexColor(fillColor)
    val strokeBrush = obtainBrushFromComplexColor(strokeColor)
    val fillPathType = if (fillRule == 0) PathFillType.NonZero else PathFillType.EvenOdd

    builder.addPath(
        pathData,
        fillPathType,
        name,
        fillBrush,
        fillAlpha,
        strokeBrush,
        strokeAlpha,
        strokeLineWidth,
        strokeLineCap,
        strokeLineJoin,
        strokeMiterLimit,
        trimPathStart,
        trimPathEnd,
        trimPathOffset
    )
}

@SuppressWarnings("RestrictedApi")
private fun obtainBrushFromComplexColor(complexColor: ComplexColorCompat): Brush? =
    if (complexColor.willDraw()) {
        val shader = complexColor.shader
        if (shader != null) {
            ShaderBrush(shader)
        } else {
            SolidColor(Color(complexColor.color))
        }
    } else {
        null
    }

internal fun AndroidVectorParser.parseClipPath(
    res: Resources,
    theme: Resources.Theme?,
    attrs: AttributeSet,
    builder: ImageVector.Builder
) {
    val a = obtainAttributes(
        res,
        theme,
        attrs,
        AndroidVectorResources.STYLEABLE_VECTOR_DRAWABLE_CLIP_PATH,
    )

    val name: String = getString(
        a,
        AndroidVectorResources.STYLEABLE_VECTOR_DRAWABLE_CLIP_PATH_NAME
    ) ?: ""
    val pathData = addPathNodes(
        getString(
            a,
            AndroidVectorResources.STYLEABLE_VECTOR_DRAWABLE_CLIP_PATH_PATH_DATA
        )
    )
    a.recycle()

    // <clip-path> is parsed out as an additional VectorGroup.
    // This allows us to replicate the behavior of VectorDrawable where <clip-path> only affects
    // <path> that comes after it in <group>.
    builder.addGroup(
        name = name,
        clipPathData = pathData
    )
}

@SuppressWarnings("RestrictedApi")
internal fun AndroidVectorParser.parseGroup(
    res: Resources,
    theme: Resources.Theme?,
    attrs: AttributeSet,
    builder: ImageVector.Builder
) {
    val a = obtainAttributes(
        res,
        theme,
        attrs,
        AndroidVectorResources.STYLEABLE_VECTOR_DRAWABLE_GROUP
    )

    // Account for any configuration changes.
    // mChangingConfigurations |= Utils.getChangingConfigurations(a);

    // Extract the theme attributes, if any.
    // mThemeAttrs = null // TODO TINT THEME Not supported yet a.extractThemeAttrs();

    // This is added in API 11
    val rotate = getNamedFloat(
        a,
        "rotation",
        AndroidVectorResources.STYLEABLE_VECTOR_DRAWABLE_GROUP_ROTATION,
        DefaultRotation
    )

    val pivotX = getFloat(
        a,
        AndroidVectorResources.STYLEABLE_VECTOR_DRAWABLE_GROUP_PIVOT_X,
        DefaultPivotX
    )
    val pivotY = getFloat(
        a,
        AndroidVectorResources.STYLEABLE_VECTOR_DRAWABLE_GROUP_PIVOT_Y,
        DefaultPivotY
    )

    // This is added in API 11
    val scaleX = getNamedFloat(
        a,
        "scaleX",
        AndroidVectorResources.STYLEABLE_VECTOR_DRAWABLE_GROUP_SCALE_X,
        DefaultScaleX
    )

    // This is added in API 11
    val scaleY = getNamedFloat(
        a,
        "scaleY",
        AndroidVectorResources.STYLEABLE_VECTOR_DRAWABLE_GROUP_SCALE_Y,
        DefaultScaleY
    )

    val translateX = getNamedFloat(
        a,
        "translateX",
        AndroidVectorResources.STYLEABLE_VECTOR_DRAWABLE_GROUP_TRANSLATE_X,
        DefaultTranslationX
    )
    val translateY = getNamedFloat(
        a,
        "translateY",
        AndroidVectorResources.STYLEABLE_VECTOR_DRAWABLE_GROUP_TRANSLATE_Y,
        DefaultTranslationY
    )

    val name: String = getString(
        a,
        AndroidVectorResources.STYLEABLE_VECTOR_DRAWABLE_GROUP_NAME
    ) ?: ""

    a.recycle()

    builder.addGroup(
        name,
        rotate,
        pivotX,
        pivotY,
        scaleX,
        scaleY,
        translateX,
        translateY,
        EmptyPath
    )
}

/**
 * Class responsible for parsing vector graphics attributes and keeping track of
 * which attributes depend on a configuration parameter.
 * This is used to determine which cached vector graphics objects can be pruned
 * during a configuration change as the vector graphic would need to be reloaded
 * if a corresponding configuration parameter changed.
 *
 * For example, if the fill color for a path was dependent on the orientation of the device
 * the config flag would include the value [android.content.pm.ActivityInfo.CONFIG_ORIENTATION]
 */
internal data class AndroidVectorParser(
    val xmlParser: XmlPullParser,
    var config: Int = 0
) {

    private fun updateConfig(resConfig: Int) {
        config = config or resConfig
    }

    /**
     * Helper method to parse the attributre set update the configuration flags this
     * that these attributes may depend on
     */
    fun obtainAttributes(
        res: Resources,
        theme: Resources.Theme?,
        set: AttributeSet,
        attrs: IntArray
    ): TypedArray {
        val typedArray = TypedArrayUtils.obtainAttributes(
            res,
            theme,
            set,
            attrs
        )
        updateConfig(typedArray.changingConfigurations)
        return typedArray
    }

    /**
     * Helper method to parse an int with the given resource identifier and
     * attribute name as well as update the configuration flags this
     * int may depend on.
     */
    fun getNamedInt(
        typedArray: TypedArray,
        attrName: String,
        @StyleableRes resId: Int,
        defaultValue: Int
    ): Int {
        with(typedArray) {
            val result = TypedArrayUtils.getNamedInt(
                this,
                xmlParser,
                attrName,
                resId,
                defaultValue
            )
            updateConfig(changingConfigurations)
            return result
        }
    }

    /**
     * Helper method to parse a float with the given resource identifier and
     * attribute name as well as update the configuration flags this
     * float may depend on.
     */
    fun getNamedFloat(
        typedArray: TypedArray,
        attrName: String,
        @StyleableRes resId: Int,
        defaultValue: Float
    ): Float {
        with(typedArray) {
            val result = TypedArrayUtils.getNamedFloat(
                this,
                xmlParser,
                attrName,
                resId,
                defaultValue
            )
            updateConfig(changingConfigurations)
            return result
        }
    }

    /**
     * Helper method to parse a float with the given resource identifier
     * and update the configuration flags this float may depend on.
     */
    fun getFloat(typedArray: TypedArray, index: Int, defaultValue: Float): Float {
        with(typedArray) {
            val result = getFloat(
                index,
                defaultValue
            )
            updateConfig(changingConfigurations)
            return result
        }
    }

    /**
     * Helper method to parse an int with the given resource identifier
     * and update the configuration flags this int may depend on.
     */
    fun getInt(typedArray: TypedArray, index: Int, defaultValue: Int): Int {
        with(typedArray) {
            val result = getInt(index, defaultValue)
            updateConfig(changingConfigurations)
            return result
        }
    }

    /**
     * Helper method to parse a String with the given resource identifier
     * and update the configuration flags this String may depend on.
     */
    fun getString(typedArray: TypedArray, index: Int): String? {
        with(typedArray) {
            val result = getString(index)
            updateConfig(changingConfigurations)
            return result
        }
    }

    /**
     * Helper method to parse a dimension with the given resource identifier
     * and update the configuration flags this dimension may depend on.
     */
    fun getDimension(typedArray: TypedArray, index: Int, defValue: Float): Float {
        with(typedArray) {
            val result = getDimension(index, defValue)
            updateConfig(changingConfigurations)
            return result
        }
    }

    /**
     * Helper method to parse a ComplexColor with the given resource identifier
     * and name as well as update the configuration flags this
     * ComplexColor may depend on.
     */
    fun getNamedComplexColor(
        typedArray: TypedArray,
        theme: Resources.Theme?,
        attrName: String,
        @StyleableRes resId: Int,
        @ColorInt defaultValue: Int
    ): ComplexColorCompat {
        with(typedArray) {
            val result = TypedArrayUtils.getNamedComplexColor(
                this,
                xmlParser,
                theme,
                attrName,
                resId, defaultValue
            )
            updateConfig(changingConfigurations)
            return result
        }
    }

    /**
     * Helper method to parse a ColorStateList with the given resource identifier
     * and name as well as update the configuration flags this
     * ColorStateList may depend on.
     */
    fun getNamedColorStateList(
        typedArray: TypedArray,
        theme: Resources.Theme?,
        attrName: String,
        @StyleableRes resId: Int
    ): ColorStateList? {
        with(typedArray) {
            val result = TypedArrayUtils.getNamedColorStateList(
                typedArray,
                xmlParser,
                theme,
                attrName,
                resId
            )
            updateConfig(changingConfigurations)
            return result
        }
    }
}