/*
* 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.content.ComponentName
import android.content.Context
import android.content.Intent
import android.icu.util.Calendar
import androidx.annotation.Px
import androidx.annotation.UiThread
import androidx.annotation.VisibleForTesting
import androidx.wear.complications.ComplicationBounds
import androidx.wear.complications.ComplicationHelperActivity
import androidx.wear.complications.data.ComplicationData
import androidx.wear.complications.data.ComplicationType
import androidx.wear.complications.data.EmptyComplicationData
import androidx.wear.watchface.data.ComplicationBoundsType
import androidx.wear.watchface.style.CurrentUserStyleRepository
import androidx.wear.watchface.style.UserStyle
import androidx.wear.watchface.style.UserStyleSetting.ComplicationsUserStyleSetting
import androidx.wear.watchface.style.UserStyleSetting.ComplicationsUserStyleSetting.ComplicationsOption
import java.lang.ref.WeakReference
private fun getComponentName(context: Context) = ComponentName(
context.packageName,
context.javaClass.name
)
/**
* The [Complication]s associated with the [WatchFace]. Dynamic creation of complications isn't
* supported, however complications can be enabled and disabled by [ComplicationsUserStyleSetting].
*
* @param complicationCollection The complications associated with the watch face, may be empty.
* @param currentUserStyleRepository The [CurrentUserStyleRepository] used to listen for
* [ComplicationsUserStyleSetting] changes and apply them.
*/
public class ComplicationsManager(
complicationCollection: Collection<Complication>,
private val currentUserStyleRepository: CurrentUserStyleRepository
) {
/**
* Interface used to report user taps on the complication. See [addTapListener] and
* [removeTapListener].
*/
public interface TapCallback {
/**
* Called when the user single taps on a complication.
*
* @param complicationId The watch face's id for the complication that was tapped
*/
public fun onComplicationTapped(complicationId: Int) {}
}
private lateinit var watchFaceHostApi: WatchFaceHostApi
private lateinit var calendar: Calendar
private lateinit var renderer: Renderer
private lateinit var pendingUpdate: CancellableUniqueTask
/** A map of complication IDs to complications. */
public val complications: Map<Int, Complication> =
complicationCollection.associateBy(Complication::id)
private class InitialComplicationConfig(
val complicationBounds: ComplicationBounds,
val enabled: Boolean,
val accessibilityTraversalIndex: Int
)
// Copy of the original complication configs. This is necessary because the semantics of
// [ComplicationsUserStyleSetting] are defined in terms of an override applied to the initial
// config.
private val initialComplicationConfigs: Map<Int, InitialComplicationConfig> =
complicationCollection.associateBy(
{ it.id },
{
InitialComplicationConfig(
it.complicationBounds,
it.enabled,
it.accessibilityTraversalIndex
)
}
)
private val complicationListeners = HashSet<TapCallback>()
@VisibleForTesting
internal constructor(
complicationCollection: Collection<Complication>,
currentUserStyleRepository: CurrentUserStyleRepository,
renderer: Renderer
) : this(complicationCollection, currentUserStyleRepository) {
this.renderer = renderer
}
init {
val complicationsStyleCategory =
currentUserStyleRepository.schema.userStyleSettings.firstOrNull {
it is ComplicationsUserStyleSetting
}
// Add a listener if we have a ComplicationsUserStyleSetting so we can track changes and
// automatically apply them.
if (complicationsStyleCategory != null) {
// Ensure we apply any initial StyleCategoryOption overlay by initializing with null.
var previousOption: ComplicationsOption? = null
currentUserStyleRepository.addUserStyleChangeListener(
object : CurrentUserStyleRepository.UserStyleChangeListener {
override fun onUserStyleChanged(userStyle: UserStyle) {
val newlySelectedOption =
userStyle[complicationsStyleCategory]!! as ComplicationsOption
if (previousOption != newlySelectedOption) {
previousOption = newlySelectedOption
applyComplicationsStyleCategoryOption(newlySelectedOption)
}
}
}
)
}
}
/** Finish initialization. */
internal fun init(
watchFaceHostApi: WatchFaceHostApi,
calendar: Calendar,
renderer: Renderer,
complicationInvalidateListener: Complication.InvalidateListener
) {
this.watchFaceHostApi = watchFaceHostApi
this.calendar = calendar
this.renderer = renderer
pendingUpdate = CancellableUniqueTask(watchFaceHostApi.getHandler())
for ((_, complication) in complications) {
complication.init(this, complicationInvalidateListener)
}
// Activate complications.
scheduleUpdate()
}
internal fun applyComplicationsStyleCategoryOption(styleOption: ComplicationsOption) {
for ((id, complication) in complications) {
val override = styleOption.complicationOverlays.find { it.complicationId == id }
val initialConfig = initialComplicationConfigs[id]!!
// Apply styleOption overrides.
complication.complicationBounds =
override?.complicationBounds ?: initialConfig.complicationBounds
complication.enabled =
override?.enabled ?: initialConfig.enabled
complication.accessibilityTraversalIndex =
override?.accessibilityTraversalIndex ?: initialConfig.accessibilityTraversalIndex
}
}
/** Returns the [Complication] corresponding to [id], if there is one, or `null`. */
public operator fun get(id: Int): Complication? = complications[id]
internal fun scheduleUpdate() {
if (!pendingUpdate.isPending()) {
pendingUpdate.postUnique(this::updateComplications)
}
}
private fun updateComplications() {
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 complications) {
enabledDirty = enabledDirty || complication.enabledDirty
labelsDirty = labelsDirty || complication.enabledDirty
if (complication.enabled) {
activeKeys.add(id)
labelsDirty =
labelsDirty || complication.dataDirty || complication.complicationBoundsDirty ||
complication.accessibilityTraversalIndexDirty
if (complication.defaultProviderPolicyDirty ||
complication.defaultProviderTypeDirty
) {
watchFaceHostApi.setDefaultComplicationProviderWithFallbacks(
complication.id,
complication.defaultProviderPolicy.providersAsList(),
complication.defaultProviderPolicy.systemProviderFallback,
complication.defaultProviderType.toWireComplicationType()
)
}
complication.dataDirty = false
complication.complicationBoundsDirty = false
complication.supportedTypesDirty = false
complication.defaultProviderPolicyDirty = false
complication.defaultProviderTypeDirty = false
}
complication.enabledDirty = false
}
if (enabledDirty) {
watchFaceHostApi.setActiveComplications(activeKeys.toIntArray())
}
if (labelsDirty) {
watchFaceHostApi.updateContentDescriptionLabels()
}
}
/**
* Called when new complication data is received.
*
* @param watchFaceComplicationId 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(watchFaceComplicationId: Int, data: ComplicationData) {
val complication = complications[watchFaceComplicationId] ?: return
complication.dataDirty = complication.dataDirty ||
(complication.renderer.getData() != data)
complication.renderer.loadData(data, true)
(complication.complicationData as MutableObservableWatchData<ComplicationData>).value =
data
}
@UiThread
internal fun clearComplicationData() {
for ((_, complication) in complications) {
complication.renderer.loadData(null, false)
(complication.complicationData as MutableObservableWatchData).value =
EmptyComplicationData()
}
}
/**
* Starts a short animation, briefly highlighting the complication to provide visual feedback
* when the user has tapped on it.
*
* @param complicationId The watch face's ID of the complication to briefly highlight
*/
@UiThread
public fun displayPressedAnimation(complicationId: Int) {
val complication = requireNotNull(complications[complicationId]) {
"No complication found with ID $complicationId"
}
complication.setIsHighlighted(true)
val weakRef = WeakReference(this)
watchFaceHostApi.getHandler().postDelayed(
{
// The watch face might go away before this can run.
if (weakRef.get() != null) {
complication.setIsHighlighted(false)
}
},
WatchFaceImpl.CANCEL_COMPLICATION_HIGHLIGHTED_DELAY_MS
)
}
/**
* Returns the id of the complication at coordinates x, y or `null` if there isn't one.
*
* @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 complication at coordinates x, y or {@code null} if there isn't one
*/
public fun getComplicationAt(@Px x: Int, @Px y: Int): Complication? =
complications.values.firstOrNull { complication ->
complication.enabled && complication.tapFilter.hitTest(
complication,
renderer.screenBounds,
x,
y
)
}
/**
* Returns the background complication if there is one or `null` otherwise.
*
* @return The background complication if there is one or `null` otherwise
*/
public fun getBackgroundComplication(): Complication? =
complications.entries.firstOrNull {
it.value.boundsType == ComplicationBoundsType.BACKGROUND
}?.value
/**
* Called when the user single taps on a complication, invokes the permission request helper
* if needed, otherwise s the tap action.
*
* @param complicationId The watch face's id for the complication single tapped
*/
@SuppressWarnings("SyntheticAccessor")
@UiThread
internal fun onComplicationSingleTapped(complicationId: Int) {
// Check if the complication is missing permissions.
val data = complications[complicationId]?.renderer?.getData() ?: return
if (data.type == ComplicationType.NO_PERMISSION) {
watchFaceHostApi.getContext().startActivity(
ComplicationHelperActivity.createPermissionRequestHelperIntent(
watchFaceHostApi.getContext(),
getComponentName(watchFaceHostApi.getContext())
).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
)
return
}
data.tapAction?.send()
for (complicationListener in complicationListeners) {
complicationListener.onComplicationTapped(complicationId)
}
}
/**
* Adds a [TapCallback] which is called whenever the user interacts with a complication.
*/
@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("ComplicationsManager:")
writer.increaseIndent()
for ((_, complication) in complications) {
complication.dump(writer)
}
writer.decreaseIndent()
}
}