CurrentUserStyleRepository.kt

/*
 * Copyright 2020 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.wear.watchface.style

import android.annotation.SuppressLint
import androidx.annotation.RestrictTo
import androidx.annotation.UiThread
import androidx.wear.watchface.style.data.UserStyleSchemaWireFormat
import androidx.wear.watchface.style.data.UserStyleWireFormat

/**
 * The users style choices represented as a map of [UserStyleSetting] to
 * [UserStyleSetting.Option]. This is intended for use by the WatchFace and the [selectedOptions]
 * map keys are the same objects as in the [UserStyleSchema]. This means you can't serialize a
 * UserStyle directly, instead you need to use a [UserStyleData] (see [toUserStyleData]).
 *
 * @param selectedOptions The [UserStyleSetting.Option] selected for each [UserStyleSetting]
 */
public class UserStyle(
    public val selectedOptions: Map<UserStyleSetting, UserStyleSetting.Option>
) {
    /**
     * Constructs a UserStyle with a deep copy of the [selectedOptions].
     *
     * @param userStyle The [UserStyle] to copy.
     */
    public constructor(userStyle: UserStyle) : this(HashMap(userStyle.selectedOptions))

    /**
     * Constructs a [UserStyle] from a [UserStyleData] and the [UserStyleSchema]. Unrecognized
     * style settings will be ignored. Unlisted style settings will be initialized with that
     * settings default option.
     *
     * @param userStyle The [UserStyle] represented as a [UserStyleData].
     * @param styleSchema The [UserStyleSchema] for this UserStyle, describes how we interpret
     * [userStyle].
     */
    public constructor(
        userStyle: UserStyleData,
        styleSchema: UserStyleSchema
    ) : this(
        HashMap<UserStyleSetting, UserStyleSetting.Option>().apply {
            for (styleSetting in styleSchema.userStyleSettings) {
                val option = userStyle.userStyleMap[styleSetting.id.value]
                if (option != null) {
                    this[styleSetting] = styleSetting.getSettingOptionForId(option)
                } else {
                    this[styleSetting] = styleSetting.defaultOption
                }
            }
        }
    )

    /** @hide */
    @RestrictTo(RestrictTo.Scope.LIBRARY_GROUP_PREFIX)
    public fun toWireFormat(): UserStyleWireFormat = UserStyleWireFormat(toMap())

    /** Returns the style as a [UserStyleData]. */
    public fun toUserStyleData(): UserStyleData = UserStyleData(toMap())

    /** Returns the style as a [Map]<[String], [ByteArray]>. */
    private fun toMap(): Map<String, ByteArray> =
        selectedOptions.entries.associate { it.key.id.value to it.value.id.value }

    /** Returns the [UserStyleSetting.Option] for [setting] if there is one or `null` otherwise. */
    public operator fun get(setting: UserStyleSetting): UserStyleSetting.Option? =
        selectedOptions[setting]

    override fun toString(): String =
        "[" + selectedOptions.entries.joinToString(
            transform = { "${it.key.id} -> ${it.value}" }
        ) + "]"
}

/**
 * A form of [UserStyle] which is easy to serialize. This is intended for use by the watch face
 * clients and the editor where we can't practically use [UserStyle] due to it's limitations.
 */
public class UserStyleData(
    public val userStyleMap: Map<String, ByteArray>
) {
    /** @hide */
    @RestrictTo(RestrictTo.Scope.LIBRARY_GROUP_PREFIX)
    public constructor(
        userStyle: UserStyleWireFormat
    ) : this(userStyle.mUserStyle)

    override fun toString(): String = "{" + userStyleMap.entries.joinToString(
        transform = {
            try {
                it.key + "=" + it.value.decodeToString()
            } catch (e: Exception) {
                it.key + "=" + it.value
            }
        }
    ) + "}"

    /** @hide */
    @RestrictTo(RestrictTo.Scope.LIBRARY_GROUP_PREFIX)
    public fun toWireFormat(): UserStyleWireFormat = UserStyleWireFormat(userStyleMap)

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

        other as UserStyleData

        // Check if references are the same.
        if (userStyleMap == other.userStyleMap) return true

        // Check if contents are the same.
        if (userStyleMap.size != other.userStyleMap.size) return false

        for ((key, value) in userStyleMap) {
            val otherValue = other.userStyleMap[key] ?: return false
            if (!otherValue.contentEquals(value)) return false
        }

        return true
    }

    override fun hashCode(): Int {
        return userStyleMap.hashCode()
    }
}

/**
 * Describes the list of [UserStyleSetting]s the user can configure.
 *
 * @param userStyleSettings The user configurable style categories associated with this watch face.
 * Empty if the watch face doesn't support user styling. Note we allow at most one
 * [UserStyleSetting.ComplicationsUserStyleSetting] and one
 * [UserStyleSetting.CustomValueUserStyleSetting]
 * in the list.
 */
public class UserStyleSchema(
    public val userStyleSettings: List<UserStyleSetting>
) {
    init {
        var complicationsUserStyleSettingCount = 0
        var customValueUserStyleSettingCount = 0
        for (setting in userStyleSettings) {
            when (setting) {
                is UserStyleSetting.ComplicationsUserStyleSetting ->
                    complicationsUserStyleSettingCount++

                is UserStyleSetting.CustomValueUserStyleSetting ->
                    customValueUserStyleSettingCount++
            }
        }

        // This requirement makes it easier to implement companion editors.
        require(complicationsUserStyleSettingCount <= 1) {
            "At most only one ComplicationsUserStyleSetting is allowed"
        }

        // There's a hard limit to how big Schema + UserStyle can be and since this data is sent
        // over bluetooth to the companion there will be performance issues well before we hit
        // that the limit. As a result we want the total size of custom data to be kept small and
        // we are initially restricting there to be at most one CustomValueUserStyleSetting.
        require(customValueUserStyleSettingCount <= 1) {
            "At most only one CustomValueUserStyleSetting is allowed"
        }
    }

    /** @hide */
    @RestrictTo(RestrictTo.Scope.LIBRARY_GROUP_PREFIX)
    public constructor(wireFormat: UserStyleSchemaWireFormat) : this(
        wireFormat.mSchema.map { UserStyleSetting.createFromWireFormat(it) }
    )

    /** @hide */
    @RestrictTo(RestrictTo.Scope.LIBRARY_GROUP_PREFIX)
    public fun toWireFormat(): UserStyleSchemaWireFormat =
        UserStyleSchemaWireFormat(userStyleSettings.map { it.toWireFormat() })

    override fun toString(): String = "[" + userStyleSettings.joinToString() + "]"
}

/**
 * In memory storage for the current user style choices represented as [UserStyle], listeners can be
 * registered to observe style changes. The CurrentUserStyleRepository is initialized with a
 * [UserStyleSchema].
 *
 * @param schema The [UserStyleSchema] for this CurrentUserStyleRepository which describes the
 * available style categories.
 */
public class CurrentUserStyleRepository(
    public val schema: UserStyleSchema
) {
    /** A listener for observing [UserStyle] changes. */
    public interface UserStyleChangeListener {
        /** Called whenever the [UserStyle] changes. */
        @UiThread
        public fun onUserStyleChanged(userStyle: UserStyle)
    }

    private val styleListeners = HashSet<UserStyleChangeListener>()

    private val idToStyleSetting = schema.userStyleSettings.associateBy { it.id.value }

    /**
     * The current [UserStyle]. Assigning to this property triggers immediate
     * [UserStyleChangeListener] callbacks if if any options have changed.
     */
    public var userStyle: UserStyle = UserStyle(
        HashMap<UserStyleSetting, UserStyleSetting.Option>().apply {
            for (setting in schema.userStyleSettings) {
                this[setting] = setting.defaultOption
            }
        }
    )
        @UiThread
        get
        @UiThread
        set(style) {
            var changed = false
            val hashmap =
                field.selectedOptions as HashMap<UserStyleSetting, UserStyleSetting.Option>
            for ((setting, option) in style.selectedOptions) {
                // Ignore an unrecognized setting.
                val localSetting = idToStyleSetting[setting.id.value] ?: continue
                val styleSetting = field.selectedOptions[localSetting] ?: continue
                if (styleSetting.id.value != option.id.value) {
                    changed = true
                }
                hashmap[localSetting] = option
            }

            if (!changed) {
                return
            }

            for (styleListener in styleListeners) {
                styleListener.onUserStyleChanged(field)
            }
        }

    /**
     * Adds a [UserStyleChangeListener] which is called immediately and whenever the style changes.
     */
    @UiThread
    @SuppressLint("ExecutorRegistration")
    public fun addUserStyleChangeListener(userStyleChangeListener: UserStyleChangeListener) {
        styleListeners.add(userStyleChangeListener)
        userStyleChangeListener.onUserStyleChanged(userStyle)
    }

    /** Removes a [UserStyleChangeListener] previously added by [addUserStyleChangeListener]. */
    @UiThread
    @SuppressLint("ExecutorRegistration")
    public fun removeUserStyleChangeListener(userStyleChangeListener: UserStyleChangeListener) {
        styleListeners.remove(userStyleChangeListener)
    }
}