/*
* 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
import android.annotation.SuppressLint
import android.app.PendingIntent.CanceledException
import android.content.ComponentName
import android.content.Context
import android.content.Intent
import android.graphics.Rect
import android.util.Log
import androidx.annotation.Px
import androidx.annotation.RestrictTo
import androidx.annotation.UiThread
import androidx.annotation.VisibleForTesting
import androidx.annotation.VisibleForTesting.Companion.PACKAGE_PRIVATE
import androidx.annotation.WorkerThread
import androidx.wear.watchface.complications.ComplicationSlotBounds
import androidx.wear.watchface.complications.data.ComplicationData
import androidx.wear.watchface.complications.data.ComplicationExperimental
import androidx.wear.watchface.complications.data.ComplicationType
import androidx.wear.watchface.control.data.IdTypeAndDefaultProviderPolicyWireFormat
import androidx.wear.watchface.data.ComplicationStateWireFormat
import androidx.wear.watchface.data.IdAndComplicationStateWireFormat
import androidx.wear.watchface.style.CurrentUserStyleRepository
import androidx.wear.watchface.style.UserStyleSetting.ComplicationSlotsUserStyleSetting
import androidx.wear.watchface.style.UserStyleSetting.ComplicationSlotsUserStyleSetting.ComplicationSlotsOption
import androidx.wear.watchface.utility.TraceEvent
import java.time.Instant
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
private fun getComponentName(context: Context) =
ComponentName(context.packageName, context.javaClass.name)
/**
* The [ComplicationSlot]s associated with the [WatchFace]. Dynamic creation of ComplicationSlots
* isn't supported, however complicationSlots can be enabled and disabled by
* [ComplicationSlotsUserStyleSetting].
*
* @param complicationSlotCollection The [ComplicationSlot]s associated with the watch face, may be
* empty.
* @param currentUserStyleRepository The [CurrentUserStyleRepository] used to listen for
* [ComplicationSlotsUserStyleSetting] changes and apply them.
*/
public class ComplicationSlotsManager(
complicationSlotCollection: Collection<ComplicationSlot>,
private val currentUserStyleRepository: CurrentUserStyleRepository
) {
internal companion object {
internal const val TAG = "ComplicationSlotsManager"
}
/**
* Interface used to report user taps on the [ComplicationSlot]. See [addTapListener] and
* [removeTapListener].
*/
public interface TapCallback {
/**
* Called when the user single taps on a complication.
*
* @param complicationSlotId The id for the [ComplicationSlot] that was tapped
*/
public fun onComplicationSlotTapped(complicationSlotId: Int) {}
}
/**
* The [WatchState] of the associated watch face. This is only initialized after
* [WatchFaceService.createComplicationSlotsManager] has completed.
*
* @hide
*/
@VisibleForTesting(otherwise = PACKAGE_PRIVATE)
@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP)
public lateinit var watchState: WatchState
internal lateinit var watchFaceHostApi: WatchFaceHostApi
internal lateinit var renderer: Renderer
/** A map of complication IDs to complicationSlots. */
public val complicationSlots: Map<Int, ComplicationSlot> =
complicationSlotCollection.associateBy(ComplicationSlot::id)
/**
* Map of [ComplicationSlot] id to the latest [TapType.DOWN] [TapEvent] that the
* ComplicationSlot received, if any.
*/
public val lastComplicationTapDownEvents: Map<Int, TapEvent> = HashMap()
private class InitialComplicationConfig(
val complicationSlotBounds: ComplicationSlotBounds,
val enabled: Boolean,
val accessibilityTraversalIndex: Int,
val nameResourceId: Int?,
val screenReaderNameResourceId: Int?
)
// Copy of the original complication configs. This is necessary because the semantics of
// [ComplicationSlotsUserStyleSetting] are defined in terms of an override applied to the
// initial config.
private val initialComplicationConfigs: Map<Int, InitialComplicationConfig> =
complicationSlotCollection.associateBy(
{ it.id },
{
InitialComplicationConfig(
it.complicationSlotBounds,
it.enabled,
it.accessibilityTraversalIndex,
it.nameResourceId,
it.screenReaderNameResourceId
)
}
)
private val complicationListeners = HashSet<TapCallback>()
/** @hide */
@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP)
public var configExtrasChangeCallback: WatchFace.ComplicationSlotConfigExtrasChangeCallback? =
null
@VisibleForTesting
internal constructor(
complicationSlotCollection: Collection<ComplicationSlot>,
currentUserStyleRepository: CurrentUserStyleRepository,
renderer: Renderer
) : this(complicationSlotCollection, currentUserStyleRepository) {
this.renderer = renderer
}
init {
for ((_, complication) in complicationSlots) {
complication.complicationSlotsManager = this
}
}
private fun applyInitialComplicationConfig() {
for ((id, complication) in complicationSlots) {
val initialConfig = initialComplicationConfigs[id]!!
complication.complicationSlotBounds = initialConfig.complicationSlotBounds
complication.enabled = initialConfig.enabled
complication.accessibilityTraversalIndex = initialConfig.accessibilityTraversalIndex
complication.nameResourceId = initialConfig.nameResourceId
complication.screenReaderNameResourceId = initialConfig.screenReaderNameResourceId
}
onComplicationsUpdated()
}
/** @hide */
@WorkerThread
@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP)
public fun listenForStyleChanges(coroutineScope: CoroutineScope) {
var previousOption =
currentUserStyleRepository.schema.findComplicationSlotsOptionForUserStyle(
currentUserStyleRepository.userStyle.value
)
// Apply the initial settings on the worker thread.
previousOption?.let { applyComplicationSlotsStyleCategoryOption(it) }
// Add a listener so we can track changes and automatically apply them on the UIThread
coroutineScope.launch {
currentUserStyleRepository.userStyle.collect {
val newlySelectedOption =
currentUserStyleRepository.schema.findComplicationSlotsOptionForUserStyle(
currentUserStyleRepository.userStyle.value
)
if (previousOption != newlySelectedOption) {
previousOption = newlySelectedOption
if (newlySelectedOption == null) {
applyInitialComplicationConfig()
} else {
applyComplicationSlotsStyleCategoryOption(newlySelectedOption)
}
}
}
}
}
/** Finish initialization. */
@WorkerThread
internal fun init(
renderer: Renderer,
complicationSlotInvalidateListener: ComplicationSlot.InvalidateListener
) =
TraceEvent("ComplicationSlotsManager.init").use {
this.renderer = renderer
for ((_, complication) in complicationSlots) {
complication.init(
complicationSlotInvalidateListener,
renderer.watchState.isHeadless
)
// Force lazy construction of renderers.
complication.renderer.onRendererCreated(renderer)
}
require(
complicationSlots.values.distinctBy { it.renderer }.size ==
complicationSlots.values.size
) {
"Complication renderer instances are not sharable."
}
// Activate complicationSlots.
onComplicationsUpdated()
}
internal fun applyComplicationSlotsStyleCategoryOption(styleOption: ComplicationSlotsOption) {
for ((id, complication) in complicationSlots) {
val override = styleOption.complicationSlotOverlays.find { it.complicationSlotId == id }
val initialConfig = initialComplicationConfigs[id]!!
// Apply styleOption overrides.
complication.complicationSlotBounds =
override?.complicationSlotBounds ?: initialConfig.complicationSlotBounds
complication.enabled = override?.enabled ?: initialConfig.enabled
complication.accessibilityTraversalIndex =
override?.accessibilityTraversalIndex ?: initialConfig.accessibilityTraversalIndex
complication.nameResourceId = override?.nameResourceId ?: initialConfig.nameResourceId
complication.screenReaderNameResourceId =
override?.screenReaderNameResourceId ?: initialConfig.screenReaderNameResourceId
}
onComplicationsUpdated()
}
/** Returns the [ComplicationSlot] corresponding to [id], if there is one, or `null`. */
public operator fun get(id: Int): ComplicationSlot? = complicationSlots[id]
@UiThread
internal fun onComplicationsUpdated() =
TraceEvent("ComplicationSlotsManager.updateComplications").use {
if (!this::watchFaceHostApi.isInitialized) {
return
}
val activeKeys = mutableListOf<Int>()
// Work out what's changed using the dirty flags and issue appropriate watchFaceHostApi
// calls.
var enabledDirty = false
var labelsDirty = false
for ((id, complication) in complicationSlots) {
enabledDirty = enabledDirty || complication.enabledDirty
labelsDirty = labelsDirty || complication.enabledDirty
if (complication.enabled) {
activeKeys.add(id)
labelsDirty =
labelsDirty ||
complication.dataDirty ||
complication.complicationBoundsDirty ||
complication.accessibilityTraversalIndexDirty ||
complication.nameResourceIdDirty ||
complication.screenReaderNameResourceIdDirty
if (
complication.defaultDataSourcePolicyDirty ||
complication.defaultDataSourceTypeDirty
) {
// Note this is a NOP in the androidx flow.
watchFaceHostApi.setDefaultComplicationDataSourceWithFallbacks(
complication.id,
complication.defaultDataSourcePolicy.dataSourcesAsList(),
complication.defaultDataSourcePolicy.systemDataSourceFallback,
complication.defaultDataSourcePolicy.systemDataSourceFallbackDefaultType
.toWireComplicationType()
)
}
complication.dataDirty = false
complication.complicationBoundsDirty = false
complication.defaultDataSourcePolicyDirty = false
complication.defaultDataSourceTypeDirty = false
complication.accessibilityTraversalIndexDirty = false
complication.nameResourceIdDirty = false
complication.screenReaderNameResourceIdDirty = false
}
complication.enabledDirty = false
}
if (enabledDirty) {
watchFaceHostApi.setActiveComplicationSlots(activeKeys.toIntArray())
}
if (labelsDirty) {
watchFaceHostApi.updateContentDescriptionLabels()
}
}
/**
* Called when new complication data is received.
*
* @param complicationSlotId The id of the complication that the data relates to. If this id is
* unrecognized the call will be a NOP, the only circumstance when that happens is if the
* watch face changes it's complication config between runs e.g. during development.
* @param data The [ComplicationData] that should be displayed in the complication.
*/
@UiThread
internal fun onComplicationDataUpdate(
complicationSlotId: Int,
data: ComplicationData,
instant: Instant
) {
val complication = complicationSlots[complicationSlotId]
if (complication == null) {
Log.e(
TAG,
"onComplicationDataUpdate failed due to invalid complicationSlotId=" +
"$complicationSlotId with data=$data"
)
return
}
complication.dataDirty = complication.dataDirty || (complication.renderer.getData() != data)
complication.setComplicationData(data, true, instant)
}
/**
* For use by screen shot code which will reset the data afterwards, hence dirty bit not set.
*/
@UiThread
internal fun setComplicationDataUpdateSync(
complicationSlotId: Int,
data: ComplicationData,
instant: Instant
) {
val complication = complicationSlots[complicationSlotId]
if (complication == null) {
Log.e(
TAG,
"setComplicationDataUpdateSync failed due to invalid complicationSlotId=" +
"$complicationSlotId with data=$data"
)
return
}
complication.setComplicationData(data, false, instant)
}
/**
* For each slot, if the ComplicationData is timeline complication data then the correct
* override is selected for [instant].
*/
@UiThread
internal fun selectComplicationDataForInstant(instant: Instant) {
for ((_, complication) in complicationSlots) {
complication.selectComplicationDataForInstant(
instant,
loadDrawablesAsynchronous = true,
forceUpdate = false
)
}
}
/**
* Returns the id of the complication slot at coordinates x, y or `null` if there isn't one.
* Initially checks slots without margins (should be no overlaps) then then if there was no hit
* it tries again this time with margins (overlaps are possible) reporting the first hit if any.
*
* @param x The x coordinate of the point to perform a hit test
* @param y The y coordinate of the point to perform a hit test
* @return The [ComplicationSlot] at coordinates x, y or {@code null} if there isn't one
*/
public fun getComplicationSlotAt(@Px x: Int, @Px y: Int): ComplicationSlot? =
findLowestIdMatchingComplicationOrNull { complication ->
complication.enabled &&
complication.tapFilter.hitTest(
complication,
renderer.screenBounds,
x,
y,
includeMargins = false
)
}
?: findLowestIdMatchingComplicationOrNull { complication ->
complication.enabled &&
complication.tapFilter.hitTest(
complication,
renderer.screenBounds,
x,
y,
includeMargins = true
)
}
/**
* Finds the [ComplicationSlot] with the lowest id for which [predicate] returns true, returns
* `null` otherwise.
*/
private fun findLowestIdMatchingComplicationOrNull(
predicate: (complication: ComplicationSlot) -> Boolean
): ComplicationSlot? {
var bestComplication: ComplicationSlot? = null
var bestId = 0
for ((id, complication) in complicationSlots) {
if (predicate.invoke(complication) && (bestComplication == null || bestId > id)) {
bestComplication = complication
bestId = id
}
}
return bestComplication
}
/**
* Returns the background [ComplicationSlot] if there is one or `null` otherwise.
*
* @return The background [ComplicationSlot] if there is one or `null` otherwise
*/
public fun getBackgroundComplicationSlot(): ComplicationSlot? =
complicationSlots.entries
.firstOrNull { it.value.boundsType == ComplicationSlotBoundsType.BACKGROUND }
?.value
/**
* Called when the user single taps on a [ComplicationSlot], invokes the permission request
* helper if needed, otherwise returns the tap action.
*
* @param complicationSlotId The ID for the [ComplicationSlot] that was single tapped
*/
@SuppressWarnings("SyntheticAccessor")
@UiThread
internal fun onComplicationSlotSingleTapped(complicationSlotId: Int) {
// Check if the complication is missing permissions.
val data = complicationSlots[complicationSlotId]?.renderer?.getData() ?: return
if (data.type == ComplicationType.NO_PERMISSION) {
watchFaceHostApi
.getContext()
.startActivity(
ComplicationHelperActivity.createPermissionRequestHelperIntent(
watchFaceHostApi.getContext(),
getComponentName(watchFaceHostApi.getContext()),
watchFaceHostApi.getComplicationDeniedIntent(),
watchFaceHostApi.getComplicationRationaleIntent()
)
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
)
return
}
try {
data.tapAction?.send()
} catch (e: CanceledException) {
// In case the PendingIntent is no longer able to execute the request.
// We don't need to do anything here.
}
for (complicationListener in complicationListeners) {
complicationListener.onComplicationSlotTapped(complicationSlotId)
}
}
/**
* Note getComplicationState may be called before [init], this is why it requires
* [screenBounds].
*/
@OptIn(ComplicationExperimental::class)
@UiThread
internal fun getComplicationsState(screenBounds: Rect) =
complicationSlots.map {
val systemDataSourceFallbackDefaultType =
it.value.defaultDataSourcePolicy.systemDataSourceFallbackDefaultType
.toWireComplicationType()
IdAndComplicationStateWireFormat(
it.key,
ComplicationStateWireFormat(
it.value.computeBounds(screenBounds, applyMargins = false),
it.value.computeBounds(screenBounds, applyMargins = true),
it.value.boundsType,
ComplicationType.toWireTypes(it.value.supportedTypes),
it.value.defaultDataSourcePolicy.dataSourcesAsList(),
it.value.defaultDataSourcePolicy.systemDataSourceFallback,
systemDataSourceFallbackDefaultType,
it.value.defaultDataSourcePolicy.primaryDataSourceDefaultType
?.toWireComplicationType()
?: systemDataSourceFallbackDefaultType,
it.value.defaultDataSourcePolicy.secondaryDataSourceDefaultType
?.toWireComplicationType()
?: systemDataSourceFallbackDefaultType,
it.value.enabled,
it.value.initiallyEnabled,
it.value.renderer.getData().type.toWireComplicationType(),
it.value.fixedComplicationDataSource,
it.value.configExtras,
it.value.nameResourceId,
it.value.screenReaderNameResourceId,
it.value.boundingArc?.toWireFormat()
)
)
}
@UiThread
internal fun onTapDown(complicationSlotId: Int, tapEvent: TapEvent) {
(lastComplicationTapDownEvents as HashMap)[complicationSlotId] = tapEvent
}
/**
* Adds a [TapCallback] which is called whenever the user interacts with a complication slot.
*/
@UiThread
@SuppressLint("ExecutorRegistration")
public fun addTapListener(tapCallback: TapCallback) {
complicationListeners.add(tapCallback)
}
/** Removes a [TapCallback] previously added by [addTapListener]. */
@UiThread
public fun removeTapListener(tapCallback: TapCallback) {
complicationListeners.remove(tapCallback)
}
@UiThread
internal fun dump(writer: IndentingPrintWriter) {
writer.println("ComplicationSlotsManager:")
writer.println(
"lastComplicationTapDownEvents=" +
lastComplicationTapDownEvents
.map { it.key.toString() + "->" + it.value }
.joinToString(", ")
)
writer.increaseIndent()
for ((_, complication) in complicationSlots) {
complication.dump(writer)
}
writer.decreaseIndent()
}
/**
* This will be called from a binder thread. That's OK because we don't expect this
* ComplicationSlotsManager to be accessed at all from the UiThread in that scenario. See
* [androidx.wear.watchface.control.IWatchFaceInstanceServiceStub.getDefaultProviderPolicies].
*/
@WorkerThread
internal fun getDefaultProviderPolicies(): Array<IdTypeAndDefaultProviderPolicyWireFormat> =
complicationSlots
.map {
IdTypeAndDefaultProviderPolicyWireFormat(
it.key,
it.value.defaultDataSourcePolicy.dataSourcesAsList(),
it.value.defaultDataSourcePolicy.systemDataSourceFallback,
it.value.defaultDataSourcePolicy.systemDataSourceFallbackDefaultType
.toWireComplicationType()
)
}
.toTypedArray()
/**
* Returns the earliest [Instant] after [afterInstant] at which any complication field in any
* enabled complication may change.
*/
internal fun getNextChangeInstant(afterInstant: Instant): Instant {
var minInstant = Instant.MAX
for ((_, complication) in complicationSlots) {
if (!complication.enabled) {
continue
}
val instant = complication.complicationData.value.getNextChangeInstant(afterInstant)
if (instant.isBefore(minInstant)) {
minInstant = instant
}
}
return minInstant
}
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (javaClass != other?.javaClass) return false
other as ComplicationSlotsManager
return complicationSlots == other.complicationSlots
}
override fun hashCode(): Int {
return complicationSlots.hashCode()
}
}