/*
* 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.content.res.Resources
import android.content.res.XmlResourceParser
import android.graphics.drawable.Icon
import androidx.annotation.RestrictTo
import androidx.wear.watchface.style.UserStyleSetting.Option
import androidx.wear.watchface.style.data.UserStyleSchemaWireFormat
import androidx.wear.watchface.style.data.UserStyleWireFormat
import java.io.ByteArrayInputStream
import java.io.ByteArrayOutputStream
import java.io.DataInputStream
import java.io.DataOutputStream
import java.io.IOException
import java.io.OutputStream
import java.security.DigestOutputStream
import java.security.MessageDigest
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import org.xmlpull.v1.XmlPullParser
import org.xmlpull.v1.XmlPullParserException
/**
* An immutable representation of user style choices that maps each [UserStyleSetting] to
* [UserStyleSetting.Option].
*
* This is intended for use by the WatchFace and entries are the same as the ones specified in
* the [UserStyleSchema]. This means you can't serialize a UserStyle directly, instead you need
* to use a [UserStyleData] (see [toUserStyleData]).
*
* To modify the user style, you should call [toMutableUserStyle] and construct a new [UserStyle]
* instance with [MutableUserStyle.toUserStyle].
*
* @param selectedOptions The [UserStyleSetting.Option] selected for each [UserStyleSetting]
* @param copySelectedOptions Whether to create a copy of the provided [selectedOptions]. If
* `false`, no mutable copy of the [selectedOptions] map should be retained outside this class.
*/
public class UserStyle private constructor(
selectedOptions: Map<UserStyleSetting, UserStyleSetting.Option>,
copySelectedOptions: Boolean
) : Map<UserStyleSetting, UserStyleSetting.Option> {
private val selectedOptions =
if (copySelectedOptions) HashMap(selectedOptions) else selectedOptions
/**
* Constructs a copy of the [UserStyle]. It is backed by the same map.
*/
public constructor(userStyle: UserStyle) : this(userStyle.selectedOptions, false)
/**
* Constructs a [UserStyle] with the given selected options for each setting.
*
* A copy of the [selectedOptions] map will be created, so that changed to the map will not be
* reflected by this object.
*/
public constructor(
selectedOptions: Map<UserStyleSetting, UserStyleSetting.Option>
) : this(selectedOptions, true)
/** Constructs this UserStyle from data serialized to a [ByteArray] by [toByteArray]. */
internal constructor(
byteArray: ByteArray,
styleSchema: UserStyleSchema
) : this(
UserStyleData(
HashMap<String, ByteArray>().apply {
val bais = ByteArrayInputStream(byteArray)
val reader = DataInputStream(bais)
val numKeys = reader.readInt()
for (i in 0 until numKeys) {
val key = reader.readUTF()
val numBytes = reader.readInt()
val value = ByteArray(numBytes)
reader.read(value, 0, numBytes)
put(key, value)
}
reader.close()
bais.close()
}
),
styleSchema
)
/** The number of entries in the style. */
override val size: Int by selectedOptions::size
/**
* Constructs a [UserStyle] from a [UserStyleData] and the [UserStyleSchema]. Unrecognized
* style settings will be ignored. Unlisted style settings will be initialized with that
* setting's 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 a mutable instance initialized with the same mapping. */
public fun toMutableUserStyle(): MutableUserStyle = MutableUserStyle(this)
/** 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 style encoded as a [ByteArray]. */
internal fun toByteArray(): ByteArray {
val baos = ByteArrayOutputStream()
val writer = DataOutputStream(baos)
writer.writeInt(selectedOptions.size)
for ((key, value) in selectedOptions) {
writer.writeUTF(key.id.value)
writer.writeInt(value.id.value.size)
writer.write(value.id.value, 0, value.id.value.size)
}
writer.close()
baos.close()
val ba = baos.toByteArray()
return ba
}
/** Returns the [UserStyleSetting.Option] for [key] if there is one or `null` otherwise. */
public override operator fun get(key: UserStyleSetting): UserStyleSetting.Option? =
selectedOptions[key]
/**
* Returns the [UserStyleSetting.Option] for [settingId] if there is one or `null` otherwise.
* Note this is an O(n) operation.
*/
public operator fun get(settingId: UserStyleSetting.Id): UserStyleSetting.Option? =
selectedOptions.firstNotNullOfOrNull { if (it.key.id == settingId) it.value else null }
override fun toString(): String =
"UserStyle[" + selectedOptions.entries.joinToString(
transform = { "${it.key.id} -> ${it.value}" }
) + "]"
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (javaClass != other?.javaClass) return false
other as UserStyle
if (selectedOptions != other.selectedOptions) return false
return true
}
override fun hashCode(): Int {
return selectedOptions.hashCode()
}
internal companion object {
/**
* Merges the content of [overrides] with [base].
*
* This function merges the content of [base] by overriding any setting that is in [base]
* with the corresponding options from [overrides].
*
* Any setting in [overrides] that is not set in [base] will be ignored. Any setting that is
* not present in [overrides] but it is in [base] will be kept unmodified.
*
* Returns the merged [UserStyle] or null if the merged [UserStyle] is not different from
* [base], i.e., if applying the [overrides] does not change any of the [base] settings.
*/
@JvmStatic
internal fun merge(base: UserStyle, overrides: UserStyle): UserStyle? {
// Created only if there are changes to apply.
var merged: MutableUserStyle? = null
for ((setting, option) in overrides.selectedOptions) {
// Ignore an unrecognized setting.
val currentOption = base[setting] ?: continue
if (currentOption != option) {
merged = merged ?: base.toMutableUserStyle()
merged[setting] = option
}
}
return merged?.toUserStyle()
}
}
override val entries: Set<Map.Entry<UserStyleSetting, UserStyleSetting.Option>>
get() = selectedOptions.entries
override val keys: Set<UserStyleSetting>
get() = selectedOptions.keys
override val values: Collection<UserStyleSetting.Option>
get() = selectedOptions.values
override fun containsKey(key: UserStyleSetting): Boolean = selectedOptions.containsKey(key)
override fun containsValue(value: UserStyleSetting.Option): Boolean =
selectedOptions.containsValue(value)
override fun isEmpty(): Boolean = selectedOptions.isEmpty()
}
/**
* A mutable [UserStyle]. This must be converted back to a [UserStyle] by calling [toUserStyle].
*/
public class MutableUserStyle internal constructor(userStyle: UserStyle) :
Iterable<Map.Entry<UserStyleSetting, UserStyleSetting.Option>> {
/** The map from the available settings and the selected option. */
private val selectedOptions = HashMap<UserStyleSetting, UserStyleSetting.Option>().apply {
for ((setting, option) in userStyle) {
this[setting] = option
}
}
/** The number of entries in the style. */
val size: Int get() = selectedOptions.size
/** Iterator over the elements of the user style. */
override fun iterator(): Iterator<Map.Entry<UserStyleSetting, UserStyleSetting.Option>> =
selectedOptions.iterator()
/** Returns the [UserStyleSetting.Option] for [setting] if there is one or `null` otherwise. */
public operator fun get(setting: UserStyleSetting): UserStyleSetting.Option? =
selectedOptions[setting]
/**
* Returns the [UserStyleSetting.Option] for [settingId] if there is one or `null` otherwise.
* Note this is an O(n) operation.
*/
public operator fun get(settingId: UserStyleSetting.Id): UserStyleSetting.Option? =
selectedOptions.firstNotNullOfOrNull { if (it.key.id == settingId) it.value else null }
/**
* Sets the [UserStyleSetting.Option] for [setting] to the given [option].
*
* @param setting The [UserStyleSetting] we're setting the [option] for, must be in the schema.
* @param option the [UserStyleSetting.Option] we're setting. Must be a valid option for
* [setting].
* @throws IllegalArgumentException if [setting] is not in the schema or if [option] is invalid
* for [setting].
*/
public operator fun set(setting: UserStyleSetting, option: UserStyleSetting.Option) {
require(selectedOptions.containsKey(setting)) { "Unknown setting $setting" }
require(option.getUserStyleSettingClass() == setting::class.java) {
"The option class (${option::class.java.canonicalName}) must match the setting class " +
setting::class.java.canonicalName
}
selectedOptions[setting] = option
}
/**
* Sets the [UserStyleSetting.Option] for the setting with the given [settingId] to the given
* [option].
*
* @param settingId The [UserStyleSetting.Id] of the [UserStyleSetting] we're setting the
* [option] for, must be in the schema.
* @param option the [UserStyleSetting.Option] we're setting. Must be a valid option for
* [settingId].
* @throws IllegalArgumentException if [settingId] is not in the schema or if [option] is
* invalid for [settingId].
*/
public operator fun set(settingId: UserStyleSetting.Id, option: UserStyleSetting.Option) {
val setting = getSettingForId(settingId)
require(setting != null) { "Unknown setting $settingId" }
require(option.getUserStyleSettingClass() == setting::class.java) {
"The option must be a subclass of the setting"
}
selectedOptions[setting] = option
}
/**
* Sets the [UserStyleSetting.Option] for [setting] to the option with the given [optionId].
*
* @param setting The [UserStyleSetting] we're setting the [optionId] for, must be in the
* schema.
* @param optionId the [UserStyleSetting.Option.Id] for the [UserStyleSetting.Option] we're
* setting.
* @throws IllegalArgumentException if [setting] is not in the schema or if [optionId] is
* unrecognized.
*/
public operator fun set(setting: UserStyleSetting, optionId: UserStyleSetting.Option.Id) {
require(selectedOptions.containsKey(setting)) { "Unknown setting $setting" }
val option = getOptionForId(setting, optionId)
require(option != null) { "Unrecognized optionId $optionId" }
selectedOptions[setting] = option
}
/**
* Sets the [UserStyleSetting.Option] for the setting with the given [settingId] to the option
* with the given [optionId].
* @throws IllegalArgumentException if [settingId] is not in the schema or if [optionId] is
* unrecognized.
*/
public operator fun set(settingId: UserStyleSetting.Id, optionId: UserStyleSetting.Option.Id) {
val setting = getSettingForId(settingId)
require(setting != null) { "Unknown setting $settingId" }
val option = getOptionForId(setting, optionId)
require(option != null) { "Unrecognized optionId $optionId" }
selectedOptions[setting] = option
}
/** Converts this instance to an immutable [UserStyle] with the same mapping. */
public fun toUserStyle(): UserStyle = UserStyle(selectedOptions)
private fun getSettingForId(settingId: UserStyleSetting.Id): UserStyleSetting? {
for (setting in selectedOptions.keys) {
if (setting.id == settingId) {
return setting
}
}
return null
}
private fun getOptionForId(
setting: UserStyleSetting,
optionId: UserStyleSetting.Option.Id
): UserStyleSetting.Option? {
for (option in setting.options) {
if (option.id == optionId) {
return option
}
}
return null
}
override fun toString(): String =
"MutableUserStyle[" + 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 its 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. Note style schemas can be
* hierarchical (see [UserStyleSetting.Option.childSettings]), editors should use
* [rootUserStyleSettings] rather than [userStyleSettings] for populating the top level UI.
*
* @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.ComplicationSlotsUserStyleSetting] and one
* [UserStyleSetting.CustomValueUserStyleSetting] in the list.
*/
@OptIn(ExperimentalHierarchicalStyle::class)
public class UserStyleSchema constructor(
// TODO(b/223610314): Deprecate userStyleSettings after rootUserStyleSettings is available
public val userStyleSettings: List<UserStyleSetting>
) {
/** For use with hierarchical schemas, lists all the settings with no parent [Option]. */
@Suppress("OPT_IN_MARKER_ON_WRONG_TARGET")
@get:ExperimentalHierarchicalStyle
@ExperimentalHierarchicalStyle
public val rootUserStyleSettings by lazy {
userStyleSettings.filter { !it.hasParent }
}
/** @hide */
@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP)
companion object {
@Throws(IOException::class, XmlPullParserException::class)
fun inflate(resources: Resources, parser: XmlResourceParser): UserStyleSchema {
require(parser.name == "UserStyleSchema") {
"Expected a UserStyleSchema node"
}
val idToSetting = HashMap<String, UserStyleSetting>()
val userStyleSettings = ArrayList<UserStyleSetting>()
val outerDepth = parser.depth
var type = parser.next()
// Parse the UserStyle declaration.
do {
if (type == XmlPullParser.START_TAG) {
when (parser.name) {
"BooleanUserStyleSetting" -> userStyleSettings.add(
UserStyleSetting.BooleanUserStyleSetting.inflate(resources, parser)
)
"ComplicationSlotsUserStyleSetting" -> userStyleSettings.add(
UserStyleSetting.ComplicationSlotsUserStyleSetting.inflate(
resources,
parser
)
)
"DoubleRangeUserStyleSetting" -> userStyleSettings.add(
UserStyleSetting.DoubleRangeUserStyleSetting.inflate(resources, parser)
)
"ListUserStyleSetting" -> userStyleSettings.add(
UserStyleSetting.ListUserStyleSetting.inflate(
resources,
parser,
idToSetting
)
)
"LongRangeUserStyleSetting" -> userStyleSettings.add(
UserStyleSetting.LongRangeUserStyleSetting.inflate(resources, parser)
)
else -> throw IllegalArgumentException(
"Unexpected node ${parser.name} at line ${parser.lineNumber}"
)
}
idToSetting[userStyleSettings.last().id.value] = userStyleSettings.last()
}
type = parser.next()
} while (type != XmlPullParser.END_DOCUMENT && parser.depth > outerDepth)
return UserStyleSchema(userStyleSettings)
}
}
init {
var complicationSlotsUserStyleSettingCount = 0
var customValueUserStyleSettingCount = 0
for (setting in userStyleSettings) {
when (setting) {
is UserStyleSetting.ComplicationSlotsUserStyleSetting ->
complicationSlotsUserStyleSettingCount++
is UserStyleSetting.CustomValueUserStyleSetting ->
customValueUserStyleSettingCount++
else -> {
// Nothing
}
}
for (option in setting.options) {
for (childSetting in option.childSettings) {
require(userStyleSettings.contains(childSetting)) {
"childSettings must be in the list of settings the UserStyleSchema is " +
"constructed with"
}
}
}
}
// This requirement makes it easier to implement companion editors.
require(complicationSlotsUserStyleSettingCount <= 1) {
"At most only one ComplicationSlotsUserStyleSetting 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) }
) {
val wireUserStyleSettingsIterator = wireFormat.mSchema.iterator()
for (userStyle in userStyleSettings) {
val wireUserStyleSetting = wireUserStyleSettingsIterator.next()
wireUserStyleSetting.mOptionChildIndices?.let {
// Unfortunately due to VersionedParcelable limitations, we can not extend the
// Options wire format (extending the contents of a list is not supported!!!).
// This means we need to encode/decode the childSettings in a round about way.
val optionsIterator = userStyle.options.iterator()
var option: Option? = null
for (childIndex in it) {
if (option == null) {
option = optionsIterator.next()
}
if (childIndex == -1) {
option = null
} else {
val childSettings = option.childSettings as ArrayList
val child = userStyleSettings[childIndex]
childSettings.add(child)
child.hasParent = true
}
}
}
}
}
/** @hide */
@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP_PREFIX)
public fun toWireFormat(): UserStyleSchemaWireFormat =
UserStyleSchemaWireFormat(
userStyleSettings.map { userStyleSetting ->
val wireFormat = userStyleSetting.toWireFormat()
// Unfortunately due to VersionedParcelable limitations, we can not extend the
// Options wire format (extending the contents of a list is not supported!!!).
// This means we need to encode/decode the childSettings in a round about way.
val optionChildIndices = ArrayList<Int>()
for (option in userStyleSetting.options) {
for (child in option.childSettings) {
optionChildIndices.add(
userStyleSettings.indexOfFirst {
it == child
}
)
}
optionChildIndices.add(-1)
}
wireFormat.mOptionChildIndices = optionChildIndices
wireFormat
}
)
/** @hide */
@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP_PREFIX)
public fun getDefaultUserStyle() = UserStyle(
HashMap<UserStyleSetting, UserStyleSetting.Option>().apply {
for (setting in userStyleSettings) {
this[setting] = setting.defaultOption
}
}
)
override fun toString(): String = "[" + userStyleSettings.joinToString() + "]"
/**
* Returns the [UserStyleSetting] whose [UserStyleSetting.Id] matches [settingId] or `null` if
* none match.
*/
operator fun get(settingId: UserStyleSetting.Id): UserStyleSetting? {
// NB more than one match is not allowed, UserStyleSetting id's are required to be unique.
return userStyleSettings.firstOrNull { it.id == settingId }
}
/**
* Computes a SHA-1 [MessageDigest] hash of the [UserStyleSchema]. Note that for performance
* reasons where possible the resource id or url for [Icon]s in the schema are used rather than
* the image bytes. This means that this hash should be considered insensitive to changes to the
* contents of icons between APK versions, which the developer should account for accordingly.
*/
fun getDigestHash(): ByteArray {
val md = MessageDigest.getInstance("SHA-1")
val digestOutputStream = DigestOutputStream(NullOutputStream(), md)
@Suppress("Deprecation")
for (setting in userStyleSettings) {
setting.updateMessageDigest(digestOutputStream)
}
return md.digest()
}
private class NullOutputStream : OutputStream() {
override fun write(value: Int) {}
}
}
/**
* In memory storage for the current user style choices represented as a
* [MutableStateFlow]<[UserStyle]>.
*
* @param schema The [UserStyleSchema] for this CurrentUserStyleRepository which describes the
* available style categories.
*/
public class CurrentUserStyleRepository(public val schema: UserStyleSchema) {
// Mutable backing field for [userStyle].
private val mutableUserStyle = MutableStateFlow(schema.getDefaultUserStyle())
/**
* The current [UserStyle]. If accessed from java, consider using
* [androidx.lifecycle.FlowLiveDataConversions.asLiveData] to observe changes.
*/
public val userStyle: StateFlow<UserStyle> by CurrentUserStyleRepository::mutableUserStyle
/**
* The UserStyle options must be from the supplied [UserStyleSchema].
* @hide
*/
@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP)
public fun updateUserStyle(newUserStyle: UserStyle) {
validateUserStyle(newUserStyle)
mutableUserStyle.value = newUserStyle
}
internal fun validateUserStyle(userStyle: UserStyle) {
for ((key, value) in userStyle) {
val setting = schema.userStyleSettings.firstOrNull { it == key }
require(setting != null) {
"UserStyleSetting $key is not a reference to a UserStyleSetting within " +
"the schema."
}
require(setting::class.java == value.getUserStyleSettingClass()) {
"The option class (${value::class.java.canonicalName}) in $key must " +
"match the setting class " + setting::class.java.canonicalName
}
}
}
}