ComplicationSlotBounds.kt

/*
 * Copyright 2021 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.
 */

/** Removes the KT class from the public API */
@file:RestrictTo(RestrictTo.Scope.LIBRARY_GROUP)

package androidx.wear.watchface.complications

import android.content.res.Resources
import android.content.res.XmlResourceParser
import android.graphics.RectF
import androidx.annotation.RestrictTo
import androidx.wear.watchface.complications.data.ComplicationType
import java.io.DataOutputStream
import org.xmlpull.v1.XmlPullParser

const val NAMESPACE_APP = "http://schemas.android.com/apk/res-auto"
const val NAMESPACE_ANDROID = "http://schemas.android.com/apk/res/android"

/**
 * ComplicationSlotBounds are defined by fractional screen space coordinates in unit-square [0..1].
 * These bounds will be subsequently clamped to the unit square and converted to screen space
 * coordinates. NB 0 and 1 are included in the unit square.
 *
 * One bound is expected per [ComplicationType] to allow [androidx.wear.watchface.ComplicationSlot]s
 * to change shape depending on the type.
 */
public class ComplicationSlotBounds(
    /** Per [ComplicationType] fractional unit-square screen space complication bounds. */
    public val perComplicationTypeBounds: Map<ComplicationType, RectF>
) {
    /** @hide */
    @RestrictTo(RestrictTo.Scope.LIBRARY_GROUP)
    fun write(dos: DataOutputStream) {
        perComplicationTypeBounds.keys.toSortedSet().forEach { type ->
            val bounds = perComplicationTypeBounds[type]!!
            dos.writeInt(type.toWireComplicationType())
            dos.writeFloat(bounds.left)
            dos.writeFloat(bounds.right)
            dos.writeFloat(bounds.top)
            dos.writeFloat(bounds.bottom)
        }
    }

    override fun equals(other: Any?): Boolean {
        if (this === other) return true
        if (javaClass != other?.javaClass) return false

        other as ComplicationSlotBounds

        return perComplicationTypeBounds == other.perComplicationTypeBounds
    }

    override fun hashCode(): Int {
        return perComplicationTypeBounds.toSortedMap().hashCode()
    }

    /**
     * Constructs a ComplicationSlotBounds where all complication types have the same screen space
     * unit-square bounds.
     */
    public constructor(bounds: RectF) : this(ComplicationType.values().associateWith { bounds })

    init {
        require(perComplicationTypeBounds.size == ComplicationType.values().size) {
            "ComplicationSlotBounds must contain entries for each ComplicationType"
        }
        for (type in ComplicationType.values()) {
            require(perComplicationTypeBounds.containsKey(type)) {
                "Missing bounds for $type"
            }
        }
    }

    /** @hide */
    @RestrictTo(RestrictTo.Scope.LIBRARY_GROUP)
    companion object {
        internal const val NODE_NAME = "ComplicationSlotBounds"

        /**
         * Constructs a [ComplicationSlotBounds] from a potentially incomplete
         * Map<ComplicationType, RectF>, backfilling with empty [RectF]s. This method is necessary
         * because there can be a skew between the version of the library between the watch face and
         * the system which would otherwise be problematic if new complication types have been
         * introduced.
         * @hide
         */
        @RestrictTo(RestrictTo.Scope.LIBRARY_GROUP)
        fun createFromPartialMap(
            partialPerComplicationTypeBounds: Map<ComplicationType, RectF>
        ): ComplicationSlotBounds {
            val map = HashMap(partialPerComplicationTypeBounds)

            for (type in ComplicationType.values()) {
                map.putIfAbsent(type, RectF())
            }

            return ComplicationSlotBounds(map)
        }

        /**
         * The [parser] should be inside a node with any number of ComplicationSlotBounds child
         * nodes. No other child nodes are expected.
         */
        fun inflate(
            resources: Resources,
            parser: XmlResourceParser
        ): ComplicationSlotBounds? {
            var type = 0
            val outerDepth = parser.depth
            val perComplicationTypeBounds by lazy { HashMap<ComplicationType, RectF>() }
            do {
                if (type == XmlPullParser.START_TAG) {
                    when (parser.name) {
                        NODE_NAME -> {
                            val rect = if (parser.hasValue("left"))
                                RectF(
                                    parser.requireAndGet("left", resources),
                                    parser.requireAndGet("top", resources),
                                    parser.requireAndGet("right", resources),
                                    parser.requireAndGet("bottom", resources)
                                )
                            else if (parser.hasValue("center_x")) {
                                val halfWidth = parser.requireAndGet("size_x", resources) / 2.0f
                                val halfHeight = parser.requireAndGet("size_y", resources) / 2.0f
                                val centerX = parser.requireAndGet("center_x", resources)
                                val centerY = parser.requireAndGet("center_y", resources)
                                RectF(
                                    centerX - halfWidth,
                                    centerY - halfHeight,
                                    centerX + halfWidth,
                                    centerY + halfHeight
                                )
                            } else {
                                throw IllegalArgumentException("$NODE_NAME must " +
                                    "either define top, bottom, left, right" +
                                    "or center_x, center_y, size_x, size_y should be specified")
                            }
                            if (null != parser.getAttributeValue(
                                    NAMESPACE_APP,
                                    "complicationType"
                                )
                            ) {
                                val complicationType = ComplicationType.fromWireType(
                                    parser.getAttributeIntValue(
                                        NAMESPACE_APP,
                                        "complicationType",
                                        0
                                    )
                                )
                                require(
                                    !perComplicationTypeBounds.contains(complicationType)
                                ) {
                                    "Duplicate $complicationType"
                                }
                                perComplicationTypeBounds[complicationType] = rect
                            } else {
                                for (complicationType in ComplicationType.values()) {
                                    require(
                                        !perComplicationTypeBounds.contains(
                                            complicationType
                                        )
                                    ) {
                                        "Duplicate $complicationType"
                                    }
                                    perComplicationTypeBounds[complicationType] = rect
                                }
                            }
                        }
                        else -> throw IllegalArgumentException(
                            "Unexpected node ${parser.name} at line ${parser.lineNumber}"
                        )
                    }
                }
                type = parser.next()
            } while (type != XmlPullParser.END_DOCUMENT && parser.depth > outerDepth)

            return if (perComplicationTypeBounds.isEmpty()) {
                null
            } else {
                createFromPartialMap(perComplicationTypeBounds)
            }
        }
    }
}

internal fun XmlResourceParser.requireAndGet(
    id: String,
    resources: Resources
): Float {
    require(null != getAttributeValue(NAMESPACE_APP, id)) {
        "${ComplicationSlotBounds.NODE_NAME} must define '$id'"
    }

    val resId = getAttributeResourceValue(NAMESPACE_APP, id, 0)
    if (resId != 0) {
        return resources.getDimension(resId) / resources.displayMetrics.widthPixels
    }

    return getAttributeFloatValue(NAMESPACE_APP, id, 0f)
}

fun XmlResourceParser.hasValue(id: String): Boolean {
    return null != getAttributeValue(NAMESPACE_APP, id)
}