Move FlexClockView to SysUI
Bug: 364664928
Test: manual
Flag: EXEMPT callsite flagged
Change-Id: I3b07246f42078f149ab3870df9e56443e1b579a1
diff --git a/packages/SystemUI/customization/Android.bp b/packages/SystemUI/customization/Android.bp
index c399abc..81d92fa 100644
--- a/packages/SystemUI/customization/Android.bp
+++ b/packages/SystemUI/customization/Android.bp
@@ -36,6 +36,7 @@
"SystemUIPluginLib",
"SystemUIUnfoldLib",
"kotlinx_coroutines",
+ "monet",
"dagger2",
"jsr330",
],
diff --git a/packages/SystemUI/customization/res/values/ids.xml b/packages/SystemUI/customization/res/values/ids.xml
index 5eafbfc..ec466f0 100644
--- a/packages/SystemUI/customization/res/values/ids.xml
+++ b/packages/SystemUI/customization/res/values/ids.xml
@@ -6,4 +6,13 @@
<item type="id" name="weather_clock_weather_icon" />
<item type="id" name="weather_clock_temperature" />
<item type="id" name="weather_clock_alarm_dnd" />
+
+ <item type="id" name="HOUR_DIGIT_PAIR"/>
+ <item type="id" name="MINUTE_DIGIT_PAIR"/>
+ <item type="id" name="HOUR_FIRST_DIGIT"/>
+ <item type="id" name="HOUR_SECOND_DIGIT"/>
+ <item type="id" name="MINUTE_FIRST_DIGIT"/>
+ <item type="id" name="MINUTE_SECOND_DIGIT"/>
+ <item type="id" name="TIME_FULL_FORMAT"/>
+ <item type="id" name="DATE_FORMAT"/>
</resources>
\ No newline at end of file
diff --git a/packages/SystemUI/customization/src/com/android/systemui/shared/clocks/AnimatableClockView.kt b/packages/SystemUI/customization/src/com/android/systemui/shared/clocks/AnimatableClockView.kt
index 1863cd8..9877406 100644
--- a/packages/SystemUI/customization/src/com/android/systemui/shared/clocks/AnimatableClockView.kt
+++ b/packages/SystemUI/customization/src/com/android/systemui/shared/clocks/AnimatableClockView.kt
@@ -62,6 +62,7 @@
// implement the get method and ensure a value is returned before initialization is complete.
private var logger = DEFAULT_LOGGER
get() = field ?: DEFAULT_LOGGER
+
var messageBuffer: MessageBuffer
get() = logger.buffer
set(value) {
@@ -123,24 +124,24 @@
attrs,
R.styleable.AnimatableClockView,
defStyleAttr,
- defStyleRes
+ defStyleRes,
)
try {
dozingWeightInternal =
animatableClockViewAttributes.getInt(
R.styleable.AnimatableClockView_dozeWeight,
- /* default = */ 100
+ /* default = */ 100,
)
lockScreenWeightInternal =
animatableClockViewAttributes.getInt(
R.styleable.AnimatableClockView_lockScreenWeight,
- /* default = */ 300
+ /* default = */ 300,
)
chargeAnimationDelay =
animatableClockViewAttributes.getInt(
R.styleable.AnimatableClockView_chargeAnimationDelay,
- /* default = */ 200
+ /* default = */ 200,
)
} finally {
animatableClockViewAttributes.recycle()
@@ -151,14 +152,14 @@
attrs,
android.R.styleable.TextView,
defStyleAttr,
- defStyleRes
+ defStyleRes,
)
try {
isSingleLineInternal =
textViewAttributes.getBoolean(
android.R.styleable.TextView_singleLine,
- /* default = */ false
+ /* default = */ false,
)
} finally {
textViewAttributes.recycle()
@@ -280,7 +281,7 @@
text: CharSequence,
start: Int,
lengthBefore: Int,
- lengthAfter: Int
+ lengthAfter: Int,
) {
logger.d({ "onTextChanged($str1)" }) { str1 = text.toString() }
super.onTextChanged(text, start, lengthBefore, lengthAfter)
@@ -305,7 +306,7 @@
interpolator = null,
duration = 0,
delay = 0,
- onAnimationEnd = null
+ onAnimationEnd = null,
)
setTextStyle(
weight = lockScreenWeight,
@@ -314,7 +315,7 @@
interpolator = null,
duration = COLOR_ANIM_DURATION,
delay = 0,
- onAnimationEnd = null
+ onAnimationEnd = null,
)
}
@@ -327,7 +328,7 @@
interpolator = null,
duration = 0,
delay = 0,
- onAnimationEnd = null
+ onAnimationEnd = null,
)
setTextStyle(
weight = lockScreenWeight,
@@ -336,7 +337,7 @@
duration = APPEAR_ANIM_DURATION,
interpolator = Interpolators.EMPHASIZED_DECELERATE,
delay = 0,
- onAnimationEnd = null
+ onAnimationEnd = null,
)
}
@@ -353,7 +354,7 @@
interpolator = null,
duration = 0,
delay = 0,
- onAnimationEnd = null
+ onAnimationEnd = null,
)
setTextStyle(
weight = dozingWeightInternal,
@@ -362,7 +363,7 @@
interpolator = Interpolators.EMPHASIZED_DECELERATE,
duration = ANIMATION_DURATION_FOLD_TO_AOD.toLong(),
delay = 0,
- onAnimationEnd = null
+ onAnimationEnd = null,
)
}
@@ -381,7 +382,7 @@
interpolator = null,
duration = CHARGE_ANIM_DURATION_PHASE_1,
delay = 0,
- onAnimationEnd = null
+ onAnimationEnd = null,
)
}
setTextStyle(
@@ -391,7 +392,7 @@
interpolator = null,
duration = CHARGE_ANIM_DURATION_PHASE_0,
delay = chargeAnimationDelay.toLong(),
- onAnimationEnd = startAnimPhase2
+ onAnimationEnd = startAnimPhase2,
)
}
@@ -404,7 +405,7 @@
interpolator = null,
duration = DOZE_ANIM_DURATION,
delay = 0,
- onAnimationEnd = null
+ onAnimationEnd = null,
)
}
@@ -444,7 +445,7 @@
interpolator: TimeInterpolator?,
duration: Long,
delay: Long,
- onAnimationEnd: Runnable?
+ onAnimationEnd: Runnable?,
) {
textAnimator?.let {
it.setTextStyle(
@@ -454,7 +455,7 @@
duration = duration,
interpolator = interpolator,
delay = delay,
- onAnimationEnd = onAnimationEnd
+ onAnimationEnd = onAnimationEnd,
)
it.glyphFilter = glyphFilter
}
@@ -468,7 +469,7 @@
duration = duration,
interpolator = interpolator,
delay = delay,
- onAnimationEnd = onAnimationEnd
+ onAnimationEnd = onAnimationEnd,
)
textAnimator.glyphFilter = glyphFilter
}
@@ -476,6 +477,7 @@
}
fun refreshFormat() = refreshFormat(DateFormat.is24HourFormat(context))
+
fun refreshFormat(use24HourFormat: Boolean) {
Patterns.update(context)
@@ -560,18 +562,11 @@
* @param fraction fraction of the clock movement. 0 means it is at the beginning, and 1 means
* it finished moving.
*/
- fun offsetGlyphsForStepClockAnimation(
- distance: Float,
- fraction: Float,
- ) {
+ fun offsetGlyphsForStepClockAnimation(distance: Float, fraction: Float) {
for (i in 0 until NUM_DIGITS) {
val dir = if (isLayoutRtl) -1 else 1
val digitFraction =
- getDigitFraction(
- digit = i,
- isMovingToCenter = distance > 0,
- fraction = fraction,
- )
+ getDigitFraction(digit = i, isMovingToCenter = distance > 0, fraction = fraction)
val moveAmountForDigit = dir * distance * digitFraction
glyphOffsets[i] = moveAmountForDigit
diff --git a/packages/SystemUI/customization/src/com/android/systemui/shared/clocks/AssetLoader.kt b/packages/SystemUI/customization/src/com/android/systemui/shared/clocks/AssetLoader.kt
new file mode 100644
index 0000000..d001ef96
--- /dev/null
+++ b/packages/SystemUI/customization/src/com/android/systemui/shared/clocks/AssetLoader.kt
@@ -0,0 +1,448 @@
+/*
+ * Copyright (C) 2024 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 com.android.systemui.shared.clocks
+
+import android.content.Context
+import android.content.res.ColorStateList
+import android.content.res.Resources
+import android.graphics.Color
+import android.graphics.Typeface
+import android.graphics.drawable.Drawable
+import android.util.TypedValue
+import com.android.internal.graphics.ColorUtils
+import com.android.internal.graphics.cam.Cam
+import com.android.internal.graphics.cam.CamUtils
+import com.android.internal.policy.SystemBarUtils
+import com.android.systemui.log.core.Logger
+import com.android.systemui.log.core.MessageBuffer
+import com.android.systemui.monet.ColorScheme
+import com.android.systemui.monet.Style as MonetStyle
+import com.android.systemui.monet.TonalPalette
+import java.io.IOException
+import kotlin.math.abs
+
+class AssetLoader
+private constructor(
+ private val pluginCtx: Context,
+ private val sysuiCtx: Context,
+ private val baseDir: String,
+ var colorScheme: ColorScheme?,
+ var seedColor: Int?,
+ var overrideChroma: Float?,
+ val typefaceCache: TypefaceCache,
+ val getThemeSeedColor: (Context) -> Int,
+ messageBuffer: MessageBuffer,
+) {
+ val logger = Logger(messageBuffer, TAG)
+ private val resources =
+ listOf(
+ Pair(pluginCtx.resources, pluginCtx.packageName),
+ Pair(sysuiCtx.resources, sysuiCtx.packageName),
+ )
+
+ constructor(
+ pluginCtx: Context,
+ sysuiCtx: Context,
+ baseDir: String,
+ messageBuffer: MessageBuffer,
+ getThemeSeedColor: ((Context) -> Int)? = null,
+ ) : this(
+ pluginCtx,
+ sysuiCtx,
+ baseDir,
+ colorScheme = null,
+ seedColor = null,
+ overrideChroma = null,
+ typefaceCache =
+ TypefaceCache(messageBuffer) { Typeface.createFromAsset(pluginCtx.assets, it) },
+ getThemeSeedColor = getThemeSeedColor ?: Companion::getThemeSeedColor,
+ messageBuffer = messageBuffer,
+ )
+
+ fun listAssets(path: String): List<String> {
+ return pluginCtx.resources.assets.list("$baseDir$path")?.toList() ?: emptyList()
+ }
+
+ fun tryReadString(resStr: String): String? = tryRead(resStr, ::readString)
+
+ fun readString(resStr: String): String {
+ val resPair = resolveResourceId(resStr)
+ if (resPair == null) {
+ throw IOException("Failed to parse string: $resStr")
+ }
+
+ val (res, id) = resPair
+ return res.getString(id)
+ }
+
+ fun tryReadColor(resStr: String): Int? = tryRead(resStr, ::readColor)
+
+ fun readColor(resStr: String): Int {
+ if (resStr.startsWith("#")) {
+ return Color.parseColor(resStr)
+ }
+
+ val schemeColor = tryParseColorFromScheme(resStr)
+ if (schemeColor != null) {
+ logColor("ColorScheme: $resStr", schemeColor)
+ return checkChroma(schemeColor)
+ }
+
+ val result = resolveColorResourceId(resStr)
+ if (result == null) {
+ throw IOException("Failed to parse color: $resStr")
+ }
+
+ val (res, colorId, targetTone) = result
+ val color = res.getColor(colorId)
+ if (targetTone == null || TonalPalette.SHADE_KEYS.contains(targetTone.toInt())) {
+ logColor("Resources: $resStr", color)
+ return checkChroma(color)
+ } else {
+ val interpolatedColor =
+ ColorStateList.valueOf(color)
+ .withLStar((1000f - targetTone) / 10f)
+ .getDefaultColor()
+ logColor("Resources (interpolated tone): $resStr", interpolatedColor)
+ return checkChroma(interpolatedColor)
+ }
+ }
+
+ private fun checkChroma(color: Int): Int {
+ return overrideChroma?.let {
+ val cam = Cam.fromInt(color)
+ val tone = CamUtils.lstarFromInt(color)
+ val result = ColorUtils.CAMToColor(cam.hue, it, tone)
+ logColor("Chroma override", result)
+ result
+ } ?: color
+ }
+
+ private fun tryParseColorFromScheme(resStr: String): Int? {
+ val colorScheme = this.colorScheme
+ if (colorScheme == null) {
+ logger.w("No color scheme available")
+ return null
+ }
+
+ val (packageName, category, name) = parseResourceId(resStr)
+ if (packageName != "android" || category != "color") {
+ logger.w("Failed to parse package from $resStr")
+ return null
+ }
+
+ var parts = name.split('_')
+ if (parts.size != 3) {
+ logger.w("Failed to find palette and shade from $name")
+ return null
+ }
+ val (_, paletteKey, shadeKeyStr) = parts
+
+ val palette =
+ when (paletteKey) {
+ "accent1" -> colorScheme.accent1
+ "accent2" -> colorScheme.accent2
+ "accent3" -> colorScheme.accent3
+ "neutral1" -> colorScheme.neutral1
+ "neutral2" -> colorScheme.neutral2
+ else -> return null
+ }
+
+ if (shadeKeyStr.contains("+") || shadeKeyStr.contains("-")) {
+ val signIndex = shadeKeyStr.indexOfLast { it == '-' || it == '+' }
+ // Use the tone of the seed color if it was set explicitly.
+ var baseTone =
+ if (seedColor != null) colorScheme.seedTone.toFloat()
+ else shadeKeyStr.substring(0, signIndex).toFloatOrNull()
+ val diff = shadeKeyStr.substring(signIndex).toFloatOrNull()
+
+ if (baseTone == null) {
+ logger.w("Failed to parse base tone from $shadeKeyStr")
+ return null
+ }
+
+ if (diff == null) {
+ logger.w("Failed to parse relative tone from $shadeKeyStr")
+ return null
+ }
+ return palette.getAtTone(baseTone + diff)
+ } else {
+ val shadeKey = shadeKeyStr.toIntOrNull()
+ if (shadeKey == null) {
+ logger.w("Failed to parse tone from $shadeKeyStr")
+ return null
+ }
+ return palette.allShadesMapped.get(shadeKey) ?: palette.getAtTone(shadeKey.toFloat())
+ }
+ }
+
+ fun readFontAsset(resStr: String): Typeface = typefaceCache.getTypeface(resStr)
+
+ fun tryReadTextAsset(path: String?): String? = tryRead(path, ::readTextAsset)
+
+ fun readTextAsset(path: String): String {
+ return pluginCtx.resources.assets.open("$baseDir$path").use { stream ->
+ val buffer = ByteArray(stream.available())
+ stream.read(buffer)
+ String(buffer)
+ }
+ }
+
+ fun tryReadDrawableAsset(path: String?): Drawable? = tryRead(path, ::readDrawableAsset)
+
+ fun readDrawableAsset(path: String): Drawable {
+ var result: Drawable?
+
+ if (path.startsWith("@")) {
+ val pair = resolveResourceId(path)
+ if (pair == null) {
+ throw IOException("Failed to parse $path to an id")
+ }
+ val (res, id) = pair
+ result = res.getDrawable(id)
+ } else if (path.endsWith("xml")) {
+ // TODO(b/248609434): Support xml files in assets
+ throw IOException("Cannot load xml files from assets")
+ } else {
+ // Attempt to load as if it's a bitmap and directly loadable
+ result =
+ pluginCtx.resources.assets.open("$baseDir$path").use { stream ->
+ Drawable.createFromResourceStream(
+ pluginCtx.resources,
+ TypedValue(),
+ stream,
+ null,
+ )
+ }
+ }
+
+ return result ?: throw IOException("Failed to load: $baseDir$path")
+ }
+
+ fun parseResourceId(resStr: String): Triple<String?, String, String> {
+ if (!resStr.startsWith("@")) {
+ throw IOException("Invalid resource id: $resStr; Must start with '@'")
+ }
+
+ // Parse out resource string
+ val parts = resStr.drop(1).split('/', ':')
+ return when (parts.size) {
+ 2 -> Triple(null, parts[0], parts[1])
+ 3 -> Triple(parts[0], parts[1], parts[2])
+ else -> throw IOException("Failed to parse resource string: $resStr")
+ }
+ }
+
+ fun resolveColorResourceId(resStr: String): Triple<Resources, Int, Float?>? {
+ var (packageName, category, name) = parseResourceId(resStr)
+
+ // Convert relative tonal specifiers to standard
+ val relIndex = name.indexOfLast { it == '_' }
+ val isToneRelative = name.contains("-") || name.contains("+")
+ val targetTone =
+ if (packageName != "android") {
+ null
+ } else if (isToneRelative) {
+ val signIndex = name.indexOfLast { it == '-' || it == '+' }
+ val baseTone = name.substring(relIndex + 1, signIndex).toFloatOrNull()
+ var diff = name.substring(signIndex).toFloatOrNull()
+ if (baseTone == null || diff == null) {
+ logger.w("Failed to parse relative tone from $name")
+ return null
+ }
+ baseTone + diff
+ } else {
+ val absTone = name.substring(relIndex + 1).toFloatOrNull()
+ if (absTone == null) {
+ logger.w("Failed to parse absolute tone from $name")
+ return null
+ }
+ absTone
+ }
+
+ if (
+ targetTone != null &&
+ (isToneRelative || !TonalPalette.SHADE_KEYS.contains(targetTone.toInt()))
+ ) {
+ val closeTone = TonalPalette.SHADE_KEYS.minBy { abs(it - targetTone) }
+ val prevName = name
+ name = name.substring(0, relIndex + 1) + closeTone
+ logger.i("Converted $prevName to $name")
+ }
+
+ val result = resolveResourceId(packageName, category, name)
+ if (result == null) {
+ return null
+ }
+
+ val (res, resId) = result
+ return Triple(res, resId, targetTone)
+ }
+
+ fun resolveResourceId(resStr: String): Pair<Resources, Int>? {
+ val (packageName, category, name) = parseResourceId(resStr)
+ return resolveResourceId(packageName, category, name)
+ }
+
+ fun resolveResourceId(
+ packageName: String?,
+ category: String,
+ name: String,
+ ): Pair<Resources, Int>? {
+ for ((res, ctxPkgName) in resources) {
+ val result = res.getIdentifier(name, category, packageName ?: ctxPkgName)
+ if (result != 0) {
+ return Pair(res, result)
+ }
+ }
+ return null
+ }
+
+ private fun <TArg : Any, TRes : Any> tryRead(arg: TArg?, fn: (TArg) -> TRes): TRes? {
+ try {
+ if (arg == null) {
+ return null
+ }
+ return fn(arg)
+ } catch (ex: IOException) {
+ logger.w("Failed to read $arg", ex)
+ return null
+ }
+ }
+
+ fun assetExists(path: String): Boolean {
+ try {
+ if (path.startsWith("@")) {
+ val pair = resolveResourceId(path)
+ val colorPair = resolveColorResourceId(path)
+ return pair != null || colorPair != null
+ } else {
+ val stream = pluginCtx.resources.assets.open("$baseDir$path")
+ if (stream == null) {
+ return false
+ }
+
+ stream.close()
+ return true
+ }
+ } catch (ex: IOException) {
+ return false
+ }
+ }
+
+ fun copy(messageBuffer: MessageBuffer? = null): AssetLoader =
+ AssetLoader(
+ pluginCtx,
+ sysuiCtx,
+ baseDir,
+ colorScheme,
+ seedColor,
+ overrideChroma,
+ typefaceCache,
+ getThemeSeedColor,
+ messageBuffer ?: logger.buffer,
+ )
+
+ fun setSeedColor(seedColor: Int?, style: MonetStyle?) {
+ this.seedColor = seedColor
+ refreshColorPalette(style)
+ }
+
+ fun refreshColorPalette(style: MonetStyle?) {
+ val seedColor =
+ this.seedColor ?: getThemeSeedColor(sysuiCtx).also { logColor("Theme Seed Color", it) }
+ this.colorScheme =
+ ColorScheme(
+ seedColor,
+ false, // darkTheme is not used for palette generation
+ style ?: MonetStyle.CLOCK,
+ )
+
+ // Enforce low chroma on output colors if low chroma theme is selected
+ this.overrideChroma = run {
+ val cam = colorScheme?.seed?.let { Cam.fromInt(it) }
+ if (cam != null && cam.chroma < LOW_CHROMA_LIMIT) {
+ return@run cam.chroma * LOW_CHROMA_SCALE
+ }
+ return@run null
+ }
+ }
+
+ fun getClockPaddingStart(): Int {
+ val result = resolveResourceId(null, "dimen", "clock_padding_start")
+ if (result != null) {
+ val (res, id) = result
+ return res.getDimensionPixelSize(id)
+ }
+ return -1
+ }
+
+ fun getStatusBarHeight(): Int {
+ val display = pluginCtx.getDisplayNoVerify()
+ if (display != null) {
+ return SystemBarUtils.getStatusBarHeight(pluginCtx.resources, display.cutout)
+ }
+
+ logger.w("No display available; falling back to android.R.dimen.status_bar_height")
+ val statusBarHeight = resolveResourceId("android", "dimen", "status_bar_height")
+ if (statusBarHeight != null) {
+ val (res, resId) = statusBarHeight
+ return res.getDimensionPixelSize(resId)
+ }
+
+ throw Exception("Could not fetch StatusBarHeight")
+ }
+
+ fun getResourcesId(name: String): Int = getResource("id", name) { _, id -> id }
+
+ fun getDimen(name: String): Int = getResource("dimen", name, Resources::getDimensionPixelSize)
+
+ fun getString(name: String): String = getResource("string", name, Resources::getString)
+
+ private fun <T> getResource(
+ category: String,
+ name: String,
+ getter: (res: Resources, id: Int) -> T,
+ ): T {
+ val result = resolveResourceId(null, category, name)
+ if (result != null) {
+ val (res, id) = result
+ if (id == -1) throw Exception("Cannot find id of $id from $TAG")
+ return getter(res, id)
+ }
+ throw Exception("Cannot find id of $name from $TAG")
+ }
+
+ private fun logColor(name: String, color: Int) {
+ if (DEBUG_COLOR) {
+ val cam = Cam.fromInt(color)
+ val tone = CamUtils.lstarFromInt(color)
+ logger.i("$name -> (hue: ${cam.hue}, chroma: ${cam.chroma}, tone: $tone)")
+ }
+ }
+
+ companion object {
+ private val DEBUG_COLOR = true
+ private val LOW_CHROMA_LIMIT = 15
+ private val LOW_CHROMA_SCALE = 1.5f
+ private val TAG = AssetLoader::class.simpleName!!
+
+ private fun getThemeSeedColor(ctx: Context): Int {
+ return ctx.resources.getColor(android.R.color.system_palette_key_color_primary_light)
+ }
+ }
+}
diff --git a/packages/SystemUI/customization/src/com/android/systemui/shared/clocks/ClockAnimation.kt b/packages/SystemUI/customization/src/com/android/systemui/shared/clocks/ClockAnimation.kt
new file mode 100644
index 0000000..5a04169
--- /dev/null
+++ b/packages/SystemUI/customization/src/com/android/systemui/shared/clocks/ClockAnimation.kt
@@ -0,0 +1,21 @@
+/*
+ * Copyright (C) 2024 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 com.android.systemui.shared.clocks
+
+object ClockAnimation {
+ const val NUM_CLOCK_FONT_ANIMATION_STEPS = 30
+}
diff --git a/packages/SystemUI/customization/src/com/android/systemui/shared/clocks/ClockDesign.kt b/packages/SystemUI/customization/src/com/android/systemui/shared/clocks/ClockDesign.kt
new file mode 100644
index 0000000..f5e8432
--- /dev/null
+++ b/packages/SystemUI/customization/src/com/android/systemui/shared/clocks/ClockDesign.kt
@@ -0,0 +1,288 @@
+/*
+ * Copyright (C) 2024 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 com.android.systemui.shared.clocks
+
+import android.graphics.Point
+import android.view.animation.Interpolator
+import com.android.app.animation.Interpolators
+import com.android.internal.annotations.Keep
+import com.android.systemui.monet.Style as MonetStyle
+import com.android.systemui.shared.clocks.view.HorizontalAlignment
+import com.android.systemui.shared.clocks.view.VerticalAlignment
+
+/** Data format for a simple asset-defined clock */
+@Keep
+data class ClockDesign(
+ val id: String,
+ val name: String? = null,
+ val description: String? = null,
+ val thumbnail: String? = null,
+ val large: ClockFace? = null,
+ val small: ClockFace? = null,
+ val colorPalette: MonetStyle? = null,
+)
+
+/** Describes a clock using layers */
+@Keep
+data class ClockFace(
+ val layers: List<ClockLayer> = listOf<ClockLayer>(),
+ val layerBounds: LayerBounds = LayerBounds.FIT,
+ val wallpaper: String? = null,
+ val faceLayout: DigitalFaceLayout? = null,
+ val pickerScale: ClockFaceScaleInPicker? = ClockFaceScaleInPicker(1.0f, 1.0f),
+)
+
+@Keep data class ClockFaceScaleInPicker(val scaleX: Float, val scaleY: Float)
+
+/** Base Type for a Clock Layer */
+@Keep
+interface ClockLayer {
+ /** Override of face LayerBounds setting for this layer */
+ val layerBounds: LayerBounds?
+}
+
+/** Clock layer that renders a static asset */
+@Keep
+data class AssetLayer(
+ /** Asset to render in this layer */
+ val asset: AssetReference,
+ override val layerBounds: LayerBounds? = null,
+) : ClockLayer
+
+/** Clock layer that renders the time (or a component of it) using numerals */
+@Keep
+data class DigitalHandLayer(
+ /** See SimpleDateFormat for timespec format info */
+ val timespec: DigitalTimespec,
+ val style: TextStyle,
+ // adoStyle concrete type must match style,
+ // cause styles will transition between style and aodStyle
+ val aodStyle: TextStyle?,
+ val timer: Int? = null,
+ override val layerBounds: LayerBounds? = null,
+ var faceLayout: DigitalFaceLayout? = null,
+ // we pass 12-hour format from json, which will be converted to 24-hour format in codes
+ val dateTimeFormat: String,
+ val alignment: DigitalAlignment?,
+ // ratio of margins to measured size, currently used for handwritten clocks
+ val marginRatio: DigitalMarginRatio? = DigitalMarginRatio(),
+) : ClockLayer
+
+/** Clock layer that renders the time (or a component of it) using numerals */
+@Keep
+data class ComposedDigitalHandLayer(
+ val customizedView: String? = null,
+ /** See SimpleDateFormat for timespec format info */
+ val digitalLayers: List<DigitalHandLayer> = listOf<DigitalHandLayer>(),
+ override val layerBounds: LayerBounds? = null,
+) : ClockLayer
+
+@Keep
+data class DigitalAlignment(
+ val horizontalAlignment: HorizontalAlignment?,
+ val verticalAlignment: VerticalAlignment?,
+)
+
+@Keep
+data class DigitalMarginRatio(
+ val left: Float = 0F,
+ val top: Float = 0F,
+ val right: Float = 0F,
+ val bottom: Float = 0F,
+)
+
+/** Clock layer which renders a component of the time using an analog hand */
+@Keep
+data class AnalogHandLayer(
+ val timespec: AnalogTimespec,
+ val tickMode: AnalogTickMode,
+ val asset: AssetReference,
+ val timer: Int? = null,
+ val clock_pivot: Point = Point(0, 0),
+ val asset_pivot: Point? = null,
+ val length: Float = 1f,
+ override val layerBounds: LayerBounds? = null,
+) : ClockLayer
+
+/** Clock layer which renders the time using an AVD */
+@Keep
+data class AnimatedHandLayer(
+ val timespec: AnalogTimespec,
+ val asset: AssetReference,
+ val timer: Int? = null,
+ override val layerBounds: LayerBounds? = null,
+) : ClockLayer
+
+/** A collection of asset references for use in different device modes */
+@Keep
+data class AssetReference(
+ val light: String,
+ val dark: String,
+ val doze: String? = null,
+ val lightTint: String? = null,
+ val darkTint: String? = null,
+ val dozeTint: String? = null,
+)
+
+/**
+ * Core TextStyling attributes for text clocks. Both color and sizing information can be applied to
+ * either subtype.
+ */
+@Keep
+interface TextStyle {
+ // fontSizeScale is a scale factor applied to the default clock's font size.
+ val fontSizeScale: Float?
+}
+
+/**
+ * This specifies a font and styling parameters for that font. This is rendered using a text view
+ * and the text animation classes used by the default clock. To ensure default value take effects,
+ * all parameters MUST have a default value
+ */
+@Keep
+data class FontTextStyle(
+ // Font to load and use in the TextView
+ val fontFamily: String? = null,
+ val lineHeight: Float? = null,
+ val borderWidth: String? = null,
+ // ratio of borderWidth / fontSize
+ val borderWidthScale: Float? = null,
+ // A color literal like `#FF00FF` or a color resource like `@android:color/system_accent1_100`
+ val fillColorLight: String? = null,
+ // A color literal like `#FF00FF` or a color resource like `@android:color/system_accent1_100`
+ val fillColorDark: String? = null,
+ override val fontSizeScale: Float? = null,
+ /**
+ * use `wdth` for width, `wght` for weight, 'opsz' for optical size single quote for tag name,
+ * and no quote for value separate different axis with `,` e.g. "'wght' 1000, 'wdth' 108, 'opsz'
+ * 90"
+ */
+ var fontVariation: String? = null,
+ // used when alternate in one font file is needed
+ var fontFeatureSettings: String? = null,
+ val renderType: RenderType = RenderType.STROKE_TEXT,
+ val outlineColor: String? = null,
+ val transitionDuration: Long = -1L,
+ val transitionInterpolator: InterpolatorEnum? = null,
+) : TextStyle
+
+/**
+ * As an alternative to using a font, we can instead render a digital clock using a set of drawables
+ * for each numeral, and optionally a colon. These drawables will be rendered directly after sizing
+ * and placing them. This may be easier than generating a font file in some cases, and is provided
+ * for ease of use. Unlike fonts, these are not localizable to other numeric systems (like Burmese).
+ */
+@Keep
+data class LottieTextStyle(
+ val numbers: List<String> = listOf(),
+ // Spacing between numbers, dimension string
+ val spacing: String = "0dp",
+ // Colon drawable may be omitted if unused in format spec
+ val colon: String? = null,
+ // key is keypath name to get strokes from lottie, value is the color name to query color in
+ // palette, e.g. @android:color/system_accent1_100
+ val fillColorLightMap: Map<String, String>? = null,
+ val fillColorDarkMap: Map<String, String>? = null,
+ override val fontSizeScale: Float? = null,
+ val paddingVertical: String = "0dp",
+ val paddingHorizontal: String = "0dp",
+) : TextStyle
+
+/** Layer sizing mode for the clockface or layer */
+enum class LayerBounds {
+ /**
+ * Sized so the larger dimension matches the allocated space. This results in some of the
+ * allocated space being unused.
+ */
+ FIT,
+
+ /**
+ * Sized so the smaller dimension matches the allocated space. This will clip some content to
+ * the edges of the space.
+ */
+ FILL,
+
+ /** Fills the allocated space exactly by stretching the layer */
+ STRETCH,
+}
+
+/** Ticking mode for analog hands. */
+enum class AnalogTickMode {
+ SWEEP,
+ TICK,
+}
+
+/** Timspec options for Analog Hands. Named for tick interval. */
+enum class AnalogTimespec {
+ SECONDS,
+ MINUTES,
+ HOURS,
+ HOURS_OF_DAY,
+ DAY_OF_WEEK,
+ DAY_OF_MONTH,
+ DAY_OF_YEAR,
+ WEEK,
+ MONTH,
+ TIMER,
+}
+
+enum class DigitalTimespec {
+ TIME_FULL_FORMAT,
+ DIGIT_PAIR,
+ FIRST_DIGIT,
+ SECOND_DIGIT,
+ DATE_FORMAT,
+}
+
+enum class DigitalFaceLayout {
+ // can only use HH_PAIR, MM_PAIR from DigitalTimespec
+ TWO_PAIRS_VERTICAL,
+ TWO_PAIRS_HORIZONTAL,
+ // can only use HOUR_FIRST_DIGIT, HOUR_SECOND_DIGIT, MINUTE_FIRST_DIGIT, MINUTE_SECOND_DIGIT
+ // from DigitalTimespec, used for tabular layout when the font doesn't support tnum
+ FOUR_DIGITS_ALIGN_CENTER,
+ FOUR_DIGITS_HORIZONTAL,
+}
+
+enum class RenderType {
+ CHANGE_WEIGHT,
+ HOLLOW_TEXT,
+ STROKE_TEXT,
+ OUTER_OUTLINE_TEXT,
+}
+
+enum class InterpolatorEnum(factory: () -> Interpolator) {
+ STANDARD({ Interpolators.STANDARD }),
+ EMPHASIZED({ Interpolators.EMPHASIZED });
+
+ val interpolator: Interpolator by lazy(factory)
+}
+
+fun generateDigitalLayerIdString(layer: DigitalHandLayer): String {
+ return if (
+ layer.timespec == DigitalTimespec.TIME_FULL_FORMAT ||
+ layer.timespec == DigitalTimespec.DATE_FORMAT
+ ) {
+ layer.timespec.toString()
+ } else {
+ if ("h" in layer.dateTimeFormat) {
+ "HOUR" + "_" + layer.timespec.toString()
+ } else {
+ "MINUTE" + "_" + layer.timespec.toString()
+ }
+ }
+}
diff --git a/packages/SystemUI/customization/src/com/android/systemui/shared/clocks/ClockRegistry.kt b/packages/SystemUI/customization/src/com/android/systemui/shared/clocks/ClockRegistry.kt
index 954155d..9da3022 100644
--- a/packages/SystemUI/customization/src/com/android/systemui/shared/clocks/ClockRegistry.kt
+++ b/packages/SystemUI/customization/src/com/android/systemui/shared/clocks/ClockRegistry.kt
@@ -65,7 +65,7 @@
private fun <TKey : Any, TVal : Any> ConcurrentHashMap<TKey, TVal>.concurrentGetOrPut(
key: TKey,
value: TVal,
- onNew: (TVal) -> Unit
+ onNew: (TVal) -> Unit,
): TVal {
val result = this.putIfAbsent(key, value)
if (result == null) {
@@ -110,7 +110,7 @@
selfChange: Boolean,
uris: Collection<Uri>,
flags: Int,
- userId: Int
+ userId: Int,
) {
scope.launch(bgDispatcher) { querySettings() }
}
@@ -180,7 +180,7 @@
override fun onPluginLoaded(
plugin: ClockProviderPlugin,
pluginContext: Context,
- manager: PluginLifecycleManager<ClockProviderPlugin>
+ manager: PluginLifecycleManager<ClockProviderPlugin>,
) {
plugin.initialize(clockBuffers)
@@ -218,7 +218,7 @@
override fun onPluginUnloaded(
plugin: ClockProviderPlugin,
- manager: PluginLifecycleManager<ClockProviderPlugin>
+ manager: PluginLifecycleManager<ClockProviderPlugin>,
) {
for (clock in plugin.getClocks()) {
val id = clock.clockId
@@ -290,12 +290,12 @@
Settings.Secure.getStringForUser(
context.contentResolver,
Settings.Secure.LOCK_SCREEN_CUSTOM_CLOCK_FACE,
- ActivityManager.getCurrentUser()
+ ActivityManager.getCurrentUser(),
)
} else {
Settings.Secure.getString(
context.contentResolver,
- Settings.Secure.LOCK_SCREEN_CUSTOM_CLOCK_FACE
+ Settings.Secure.LOCK_SCREEN_CUSTOM_CLOCK_FACE,
)
}
@@ -320,13 +320,13 @@
context.contentResolver,
Settings.Secure.LOCK_SCREEN_CUSTOM_CLOCK_FACE,
json,
- ActivityManager.getCurrentUser()
+ ActivityManager.getCurrentUser(),
)
} else {
Settings.Secure.putString(
context.contentResolver,
Settings.Secure.LOCK_SCREEN_CUSTOM_CLOCK_FACE,
- json
+ json,
)
}
} catch (ex: Exception) {
@@ -418,7 +418,7 @@
pluginManager.addPluginListener(
pluginListener,
ClockProviderPlugin::class.java,
- /*allowMultiple=*/ true
+ /*allowMultiple=*/ true,
)
scope.launch(bgDispatcher) { querySettings() }
@@ -427,7 +427,7 @@
Settings.Secure.getUriFor(Settings.Secure.LOCK_SCREEN_CUSTOM_CLOCK_FACE),
/*notifyForDescendants=*/ false,
settingObserver,
- UserHandle.USER_ALL
+ UserHandle.USER_ALL,
)
ActivityManager.getService().registerUserSwitchObserver(userSwitchObserver, TAG)
@@ -435,7 +435,7 @@
context.contentResolver.registerContentObserver(
Settings.Secure.getUriFor(Settings.Secure.LOCK_SCREEN_CUSTOM_CLOCK_FACE),
/*notifyForDescendants=*/ false,
- settingObserver
+ settingObserver,
)
}
}
@@ -504,7 +504,7 @@
val isCurrent = currentClockId == info.metadata.clockId
logger.log(
if (isCurrent) LogLevel.INFO else LogLevel.DEBUG,
- { "Connected $str1 @$str2" + if (bool1) " (Current Clock)" else "" }
+ { "Connected $str1 @$str2" + if (bool1) " (Current Clock)" else "" },
) {
str1 = info.metadata.clockId
str2 = info.manager.toString()
@@ -516,7 +516,7 @@
val isCurrent = currentClockId == info.metadata.clockId
logger.log(
if (isCurrent) LogLevel.INFO else LogLevel.DEBUG,
- { "Loaded $str1 @$str2" + if (bool1) " (Current Clock)" else "" }
+ { "Loaded $str1 @$str2" + if (bool1) " (Current Clock)" else "" },
) {
str1 = info.metadata.clockId
str2 = info.manager.toString()
@@ -532,7 +532,7 @@
val isCurrent = currentClockId == info.metadata.clockId
logger.log(
if (isCurrent) LogLevel.WARNING else LogLevel.DEBUG,
- { "Unloaded $str1 @$str2" + if (bool1) " (Current Clock)" else "" }
+ { "Unloaded $str1 @$str2" + if (bool1) " (Current Clock)" else "" },
) {
str1 = info.metadata.clockId
str2 = info.manager.toString()
@@ -548,7 +548,7 @@
val isCurrent = currentClockId == info.metadata.clockId
logger.log(
if (isCurrent) LogLevel.INFO else LogLevel.DEBUG,
- { "Disconnected $str1 @$str2" + if (bool1) " (Current Clock)" else "" }
+ { "Disconnected $str1 @$str2" + if (bool1) " (Current Clock)" else "" },
) {
str1 = info.metadata.clockId
str2 = info.manager.toString()
diff --git a/packages/SystemUI/customization/src/com/android/systemui/shared/clocks/DefaultClockProvider.kt b/packages/SystemUI/customization/src/com/android/systemui/shared/clocks/DefaultClockProvider.kt
index 4802e34..07191c6 100644
--- a/packages/SystemUI/customization/src/com/android/systemui/shared/clocks/DefaultClockProvider.kt
+++ b/packages/SystemUI/customization/src/com/android/systemui/shared/clocks/DefaultClockProvider.kt
@@ -34,7 +34,7 @@
val layoutInflater: LayoutInflater,
val resources: Resources,
val hasStepClockAnimation: Boolean = false,
- val migratedClocks: Boolean = false
+ val migratedClocks: Boolean = false,
) : ClockProvider {
private var messageBuffers: ClockMessageBuffers? = null
diff --git a/packages/SystemUI/customization/src/com/android/systemui/shared/clocks/DigitTranslateAnimator.kt b/packages/SystemUI/customization/src/com/android/systemui/shared/clocks/DigitTranslateAnimator.kt
new file mode 100644
index 0000000..3869706
--- /dev/null
+++ b/packages/SystemUI/customization/src/com/android/systemui/shared/clocks/DigitTranslateAnimator.kt
@@ -0,0 +1,104 @@
+/*
+ * Copyright (C) 2024 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 com.android.systemui.shared.clocks
+
+import android.animation.Animator
+import android.animation.AnimatorListenerAdapter
+import android.animation.TimeInterpolator
+import android.animation.ValueAnimator
+import android.graphics.Point
+
+class DigitTranslateAnimator(val updateCallback: () -> Unit) {
+ val DEFAULT_ANIMATION_DURATION = 500L
+ val updatedTranslate = Point(0, 0)
+
+ val baseTranslation = Point(0, 0)
+ var targetTranslation: Point? = null
+ val bounceAnimator: ValueAnimator =
+ ValueAnimator.ofFloat(1f).apply {
+ duration = DEFAULT_ANIMATION_DURATION
+ addUpdateListener {
+ updateTranslation(it.animatedFraction, updatedTranslate)
+ updateCallback()
+ }
+ addListener(
+ object : AnimatorListenerAdapter() {
+ override fun onAnimationEnd(animation: Animator) {
+ rebase()
+ }
+
+ override fun onAnimationCancel(animation: Animator) {
+ rebase()
+ }
+ }
+ )
+ }
+
+ fun rebase() {
+ baseTranslation.x = updatedTranslate.x
+ baseTranslation.y = updatedTranslate.y
+ }
+
+ fun animatePosition(
+ animate: Boolean = true,
+ delay: Long = 0,
+ duration: Long = -1L,
+ interpolator: TimeInterpolator? = null,
+ targetTranslation: Point? = null,
+ onAnimationEnd: Runnable? = null,
+ ) {
+ this.targetTranslation = targetTranslation ?: Point(0, 0)
+ if (animate) {
+ bounceAnimator.cancel()
+ bounceAnimator.startDelay = delay
+ bounceAnimator.duration =
+ if (duration == -1L) {
+ DEFAULT_ANIMATION_DURATION
+ } else {
+ duration
+ }
+ interpolator?.let { bounceAnimator.interpolator = it }
+ if (onAnimationEnd != null) {
+ val listener =
+ object : AnimatorListenerAdapter() {
+ override fun onAnimationEnd(animation: Animator) {
+ onAnimationEnd.run()
+ bounceAnimator.removeListener(this)
+ }
+
+ override fun onAnimationCancel(animation: Animator) {
+ bounceAnimator.removeListener(this)
+ }
+ }
+ bounceAnimator.addListener(listener)
+ }
+ bounceAnimator.start()
+ } else {
+ // No animation is requested, thus set base and target state to the same state.
+ updateTranslation(1F, updatedTranslate)
+ rebase()
+ updateCallback()
+ }
+ }
+
+ fun updateTranslation(progress: Float, outPoint: Point) {
+ outPoint.x =
+ (baseTranslation.x + progress * (targetTranslation!!.x - baseTranslation.x)).toInt()
+ outPoint.y =
+ (baseTranslation.y + progress * (targetTranslation!!.y - baseTranslation.y)).toInt()
+ }
+}
diff --git a/packages/SystemUI/customization/src/com/android/systemui/shared/clocks/DimensionParser.kt b/packages/SystemUI/customization/src/com/android/systemui/shared/clocks/DimensionParser.kt
new file mode 100644
index 0000000..2be6c65
--- /dev/null
+++ b/packages/SystemUI/customization/src/com/android/systemui/shared/clocks/DimensionParser.kt
@@ -0,0 +1,64 @@
+/*
+ * Copyright (C) 2024 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 com.android.systemui.shared.clocks
+
+import android.content.Context
+import android.util.TypedValue
+import java.util.regex.Pattern
+
+class DimensionParser(private val ctx: Context) {
+ fun convert(dimension: String?): Float? {
+ if (dimension == null) {
+ return null
+ }
+ return convert(dimension)
+ }
+
+ fun convert(dimension: String): Float {
+ val metrics = ctx.resources.displayMetrics
+ val (value, unit) = parse(dimension)
+ return TypedValue.applyDimension(unit, value, metrics)
+ }
+
+ fun parse(dimension: String): Pair<Float, Int> {
+ val matcher = parserPattern.matcher(dimension)
+ if (!matcher.matches()) {
+ throw NumberFormatException("Failed to parse '$dimension'")
+ }
+
+ val value =
+ matcher.group(1)?.toFloat() ?: throw NumberFormatException("Bad value in '$dimension'")
+ val unit =
+ dimensionMap.get(matcher.group(3) ?: "")
+ ?: throw NumberFormatException("Bad unit in '$dimension'")
+ return Pair(value, unit)
+ }
+
+ private companion object {
+ val parserPattern = Pattern.compile("(\\d+(\\.\\d+)?)([a-z]+)")
+ val dimensionMap =
+ mapOf(
+ "dp" to TypedValue.COMPLEX_UNIT_DIP,
+ "dip" to TypedValue.COMPLEX_UNIT_DIP,
+ "sp" to TypedValue.COMPLEX_UNIT_SP,
+ "px" to TypedValue.COMPLEX_UNIT_PX,
+ "pt" to TypedValue.COMPLEX_UNIT_PT,
+ "mm" to TypedValue.COMPLEX_UNIT_MM,
+ "in" to TypedValue.COMPLEX_UNIT_IN,
+ )
+ }
+}
diff --git a/packages/SystemUI/customization/src/com/android/systemui/shared/clocks/LogUtil.kt b/packages/SystemUI/customization/src/com/android/systemui/shared/clocks/LogUtil.kt
new file mode 100644
index 0000000..34cb4ef
--- /dev/null
+++ b/packages/SystemUI/customization/src/com/android/systemui/shared/clocks/LogUtil.kt
@@ -0,0 +1,32 @@
+/*
+ * Copyright (C) 2024 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 com.android.systemui.shared.clocks
+
+import com.android.systemui.log.core.LogLevel
+import com.android.systemui.log.core.LogcatOnlyMessageBuffer
+import com.android.systemui.log.core.Logger
+
+object LogUtil {
+ // Used when MessageBuffers are not provided by the host application
+ val DEFAULT_MESSAGE_BUFFER = LogcatOnlyMessageBuffer(LogLevel.INFO)
+
+ // Only intended for use during initialization steps where the correct logger doesn't exist yet
+ val FALLBACK_INIT_LOGGER = Logger(LogcatOnlyMessageBuffer(LogLevel.ERROR), "CLOCK_INIT")
+
+ // Debug is primarially used for tests, but can also be used for tracking down hard issues.
+ val DEBUG_MESSAGE_BUFFER = LogcatOnlyMessageBuffer(LogLevel.DEBUG)
+}
diff --git a/packages/SystemUI/customization/src/com/android/systemui/shared/clocks/TypefaceCache.kt b/packages/SystemUI/customization/src/com/android/systemui/shared/clocks/TypefaceCache.kt
new file mode 100644
index 0000000..f5a9375
--- /dev/null
+++ b/packages/SystemUI/customization/src/com/android/systemui/shared/clocks/TypefaceCache.kt
@@ -0,0 +1,117 @@
+/*
+ * Copyright (C) 2024 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 com.android.systemui.shared.clocks
+
+import android.graphics.Typeface
+import com.android.systemui.animation.TypefaceVariantCache
+import com.android.systemui.log.core.Logger
+import com.android.systemui.log.core.MessageBuffer
+import java.lang.ref.ReferenceQueue
+import java.lang.ref.WeakReference
+
+class TypefaceCache(messageBuffer: MessageBuffer, val typefaceFactory: (String) -> Typeface) {
+ private val logger = Logger(messageBuffer, this::class.simpleName!!)
+
+ private data class CacheKey(val res: String, val fvar: String?)
+
+ private inner class WeakTypefaceRef(val key: CacheKey, typeface: Typeface) :
+ WeakReference<Typeface>(typeface, queue)
+
+ private var totalHits = 0
+
+ private var totalMisses = 0
+
+ private var totalEvictions = 0
+
+ // We use a map of WeakRefs here instead of an LruCache. This prevents needing to resize the
+ // cache depending on the number of distinct fonts used by a clock, as different clocks have
+ // different numbers of simultaneously loaded and configured fonts. Because our clocks tend to
+ // initialize a number of parallel views and animators, our usages of Typefaces overlap. As a
+ // result, once a typeface is no longer being used, it is unlikely to be recreated immediately.
+ private val cache = mutableMapOf<CacheKey, WeakTypefaceRef>()
+ private val queue = ReferenceQueue<Typeface>()
+
+ fun getTypeface(res: String): Typeface {
+ checkQueue()
+ val key = CacheKey(res, null)
+ cache.get(key)?.get()?.let {
+ logHit(key)
+ return it
+ }
+
+ logMiss(key)
+ val result = typefaceFactory(res)
+ cache.put(key, WeakTypefaceRef(key, result))
+ return result
+ }
+
+ fun getVariantCache(res: String): TypefaceVariantCache {
+ val baseTypeface = getTypeface(res)
+ return object : TypefaceVariantCache {
+ override fun getTypefaceForVariant(fvar: String?): Typeface? {
+ checkQueue()
+ val key = CacheKey(res, fvar)
+ cache.get(key)?.get()?.let {
+ logHit(key)
+ return it
+ }
+
+ logMiss(key)
+ return TypefaceVariantCache.createVariantTypeface(baseTypeface, fvar).also {
+ cache.put(key, WeakTypefaceRef(key, it))
+ }
+ }
+ }
+ }
+
+ private fun logHit(key: CacheKey) {
+ totalHits++
+ if (DEBUG_HITS)
+ logger.i({ "HIT: $str1; Total: $int1" }) {
+ str1 = key.toString()
+ int1 = totalHits
+ }
+ }
+
+ private fun logMiss(key: CacheKey) {
+ totalMisses++
+ logger.w({ "MISS: $str1; Total: $int1" }) {
+ str1 = key.toString()
+ int1 = totalMisses
+ }
+ }
+
+ private fun logEviction(key: CacheKey) {
+ totalEvictions++
+ logger.i({ "EVICTED: $str1; Total: $int1" }) {
+ str1 = key.toString()
+ int1 = totalEvictions
+ }
+ }
+
+ private fun checkQueue() =
+ generateSequence { queue.poll() }
+ .filterIsInstance<WeakTypefaceRef>()
+ .forEach {
+ logEviction(it.key)
+ cache.remove(it.key)
+ }
+
+ companion object {
+ private val DEBUG_HITS = false
+ }
+}
diff --git a/packages/SystemUI/customization/src/com/android/systemui/shared/clocks/view/DigitalClockFaceView.kt b/packages/SystemUI/customization/src/com/android/systemui/shared/clocks/view/DigitalClockFaceView.kt
new file mode 100644
index 0000000..eb72346
--- /dev/null
+++ b/packages/SystemUI/customization/src/com/android/systemui/shared/clocks/view/DigitalClockFaceView.kt
@@ -0,0 +1,180 @@
+/*
+ * Copyright (C) 2024 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 com.android.systemui.shared.clocks.view
+
+import android.content.Context
+import android.graphics.Canvas
+import android.graphics.Point
+import android.view.View
+import android.widget.FrameLayout
+import androidx.annotation.VisibleForTesting
+import com.android.systemui.log.core.Logger
+import com.android.systemui.log.core.MessageBuffer
+import com.android.systemui.plugins.clocks.AlarmData
+import com.android.systemui.plugins.clocks.WeatherData
+import com.android.systemui.plugins.clocks.ZenData
+import com.android.systemui.shared.clocks.AssetLoader
+import com.android.systemui.shared.clocks.LogUtil
+import java.util.Locale
+
+abstract class DigitalClockFaceView(ctx: Context, messageBuffer: MessageBuffer) : FrameLayout(ctx) {
+ protected val logger = Logger(messageBuffer, this::class.simpleName!!)
+ get() = field ?: LogUtil.FALLBACK_INIT_LOGGER
+
+ abstract var digitalClockTextViewMap: MutableMap<Int, SimpleDigitalClockTextView>
+
+ @VisibleForTesting
+ var isAnimationEnabled = true
+ set(value) {
+ field = value
+ digitalClockTextViewMap.forEach { _, view -> view.isAnimationEnabled = value }
+ }
+
+ var dozeFraction: Float = 0F
+ set(value) {
+ field = value
+ digitalClockTextViewMap.forEach { _, view -> view.dozeFraction = field }
+ }
+
+ val dozeControlState = DozeControlState()
+
+ var isReactiveTouchInteractionEnabled = false
+ set(value) {
+ field = value
+ }
+
+ open val text: String?
+ get() = null
+
+ open fun refreshTime() = logger.d("refreshTime()")
+
+ override fun invalidate() {
+ logger.d("invalidate()")
+ super.invalidate()
+ }
+
+ override fun requestLayout() {
+ logger.d("requestLayout()")
+ super.requestLayout()
+ }
+
+ override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
+ logger.d("onMeasure()")
+ calculateSize(widthMeasureSpec, heightMeasureSpec)?.let { setMeasuredDimension(it.x, it.y) }
+ ?: run { super.onMeasure(widthMeasureSpec, heightMeasureSpec) }
+ calculateLeftTopPosition()
+ dozeControlState.animateReady = true
+ }
+
+ override fun onLayout(changed: Boolean, left: Int, top: Int, right: Int, bottom: Int) {
+ logger.d("onLayout()")
+ super.onLayout(changed, left, top, right, bottom)
+ }
+
+ override fun onDraw(canvas: Canvas) {
+ text?.let { logger.d({ "onDraw($str1)" }) { str1 = it } } ?: run { logger.d("onDraw()") }
+ super.onDraw(canvas)
+ }
+
+ /*
+ * Called in onMeasure to generate width/height overrides to the normal measuring logic. A null
+ * result causes the normal view measuring logic to execute.
+ */
+ protected open fun calculateSize(widthMeasureSpec: Int, heightMeasureSpec: Int): Point? = null
+
+ protected open fun calculateLeftTopPosition() {}
+
+ override fun addView(child: View?) {
+ if (child == null) return
+ logger.d({ "addView($str1 @$int1)" }) {
+ str1 = child::class.simpleName!!
+ int1 = child.id
+ }
+ super.addView(child)
+ if (child is SimpleDigitalClockTextView) {
+ digitalClockTextViewMap[child.id] = child
+ }
+ child.setWillNotDraw(true)
+ }
+
+ open fun animateDoze(isDozing: Boolean, isAnimated: Boolean) {
+ digitalClockTextViewMap.forEach { _, view -> view.animateDoze(isDozing, isAnimated) }
+ }
+
+ open fun animateCharge() {
+ digitalClockTextViewMap.forEach { _, view -> view.animateCharge() }
+ }
+
+ open fun onPositionUpdated(fromLeft: Int, direction: Int, fraction: Float) {}
+
+ fun updateColors(assets: AssetLoader, isRegionDark: Boolean) {
+ digitalClockTextViewMap.forEach { _, view -> view.updateColors(assets, isRegionDark) }
+ invalidate()
+ }
+
+ fun onFontSettingChanged(fontSizePx: Float) {
+ digitalClockTextViewMap.forEach { _, view -> view.applyTextSize(fontSizePx) }
+ }
+
+ open val hasCustomWeatherDataDisplay
+ get() = false
+
+ open val hasCustomPositionUpdatedAnimation
+ get() = false
+
+ /** True if it's large weather clock, will use weatherBlueprint in compose */
+ open val useCustomClockScene
+ get() = false
+
+ // TODO: implement ClockEventUnion?
+ open fun onLocaleChanged(locale: Locale) {}
+
+ open fun onWeatherDataChanged(data: WeatherData) {}
+
+ open fun onAlarmDataChanged(data: AlarmData) {}
+
+ open fun onZenDataChanged(data: ZenData) {}
+
+ open fun onPickerCarouselSwiping(swipingFraction: Float) {}
+
+ open fun isAlignedWithScreen(): Boolean = false
+
+ /**
+ * animateDoze needs correct translate value, which is calculated in onMeasure so we need to
+ * delay this animation when we get correct values
+ */
+ class DozeControlState {
+ var animateDoze: () -> Unit = {}
+ set(value) {
+ if (animateReady) {
+ value()
+ field = {}
+ } else {
+ field = value
+ }
+ }
+
+ var animateReady = false
+ set(value) {
+ if (value) {
+ animateDoze()
+ animateDoze = {}
+ }
+ field = value
+ }
+ }
+}
diff --git a/packages/SystemUI/customization/src/com/android/systemui/shared/clocks/view/FlexClockView.kt b/packages/SystemUI/customization/src/com/android/systemui/shared/clocks/view/FlexClockView.kt
new file mode 100644
index 0000000..c29c8da
--- /dev/null
+++ b/packages/SystemUI/customization/src/com/android/systemui/shared/clocks/view/FlexClockView.kt
@@ -0,0 +1,260 @@
+/*
+ * Copyright (C) 2024 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 com.android.systemui.shared.clocks.view
+
+import android.content.Context
+import android.graphics.Canvas
+import android.graphics.Point
+import android.view.MotionEvent
+import android.view.View
+import android.view.ViewGroup
+import android.widget.RelativeLayout
+import com.android.app.animation.Interpolators
+import com.android.systemui.customization.R
+import com.android.systemui.log.core.MessageBuffer
+import com.android.systemui.shared.clocks.AssetLoader
+import com.android.systemui.shared.clocks.DigitTranslateAnimator
+import com.android.systemui.shared.clocks.FontTextStyle
+import kotlin.math.abs
+import kotlin.math.max
+import kotlin.math.min
+
+fun clamp(value: Float, minVal: Float, maxVal: Float): Float = max(min(value, maxVal), minVal)
+
+class FlexClockView(context: Context, val assetLoader: AssetLoader, messageBuffer: MessageBuffer) :
+ DigitalClockFaceView(context, messageBuffer) {
+ override var digitalClockTextViewMap = mutableMapOf<Int, SimpleDigitalClockTextView>()
+ val digitLeftTopMap = mutableMapOf<Int, Point>()
+ var maxSingleDigitHeight = -1
+ var maxSingleDigitWidth = -1
+ val lockscreenTranslate = Point(0, 0)
+ val aodTranslate = Point(0, 0)
+
+ init {
+ setWillNotDraw(false)
+ layoutParams =
+ RelativeLayout.LayoutParams(
+ ViewGroup.LayoutParams.WRAP_CONTENT,
+ ViewGroup.LayoutParams.WRAP_CONTENT,
+ )
+ }
+
+ private var prevX = 0f
+ private var prevY = 0f
+ private var isDown = false
+
+ // TODO(b/340253296): Genericize; json spec
+ private var wght = 603f
+ private var wdth = 100f
+
+ // TODO(b/340253296): Json spec
+ private val MAX_WGHT = 950f
+ private val MIN_WGHT = 50f
+ private val WGHT_SCALE = 0.5f
+
+ private val MAX_WDTH = 150f
+ private val MIN_WDTH = 0f
+ private val WDTH_SCALE = 0.2f
+
+ override fun onTouchEvent(evt: MotionEvent): Boolean {
+ // TODO(b/340253296): implement on DigitalClockFaceView?
+ if (!isReactiveTouchInteractionEnabled) {
+ return super.onTouchEvent(evt)
+ }
+
+ when (evt.action) {
+ MotionEvent.ACTION_DOWN -> {
+ isDown = true
+ prevX = evt.x
+ prevY = evt.y
+ return true
+ }
+
+ MotionEvent.ACTION_MOVE -> {
+ if (!isDown) {
+ return super.onTouchEvent(evt)
+ }
+
+ wdth = clamp(wdth + (evt.x - prevX) * WDTH_SCALE, MIN_WDTH, MAX_WDTH)
+ wght = clamp(wght + (evt.y - prevY) * WGHT_SCALE, MIN_WGHT, MAX_WGHT)
+ prevX = evt.x
+ prevY = evt.y
+
+ // TODO(b/340253296): Genericize; json spec
+ val fvar = "'wght' $wght, 'wdth' $wdth, 'opsz' 144, 'ROND' 100"
+ digitalClockTextViewMap.forEach { (_, view) ->
+ val textStyle = view.textStyle as FontTextStyle
+ textStyle.fontVariation = fvar
+ view.applyStyles(assetLoader, textStyle, view.aodStyle)
+ }
+
+ requestLayout()
+ invalidate()
+ return true
+ }
+
+ MotionEvent.ACTION_UP -> {
+ isDown = false
+ return true
+ }
+ }
+
+ return super.onTouchEvent(evt)
+ }
+
+ override fun addView(child: View?) {
+ super.addView(child)
+ (child as SimpleDigitalClockTextView).digitTranslateAnimator =
+ DigitTranslateAnimator(::invalidate)
+ }
+
+ protected override fun calculateSize(widthMeasureSpec: Int, heightMeasureSpec: Int): Point {
+ digitalClockTextViewMap.forEach { (_, textView) ->
+ textView.measure(MeasureSpec.UNSPECIFIED, MeasureSpec.UNSPECIFIED)
+ }
+ val textView = digitalClockTextViewMap[R.id.HOUR_FIRST_DIGIT]!!
+ maxSingleDigitHeight = textView.measuredHeight
+ maxSingleDigitWidth = textView.measuredWidth
+ aodTranslate.x = -(maxSingleDigitWidth * AOD_HORIZONTAL_TRANSLATE_RATIO).toInt()
+ aodTranslate.y = (maxSingleDigitHeight * AOD_VERTICAL_TRANSLATE_RATIO).toInt()
+ return Point(
+ ((maxSingleDigitWidth + abs(aodTranslate.x)) * 2),
+ ((maxSingleDigitHeight + abs(aodTranslate.y)) * 2),
+ )
+ }
+
+ protected override fun calculateLeftTopPosition() {
+ digitLeftTopMap[R.id.HOUR_FIRST_DIGIT] = Point(0, 0)
+ digitLeftTopMap[R.id.HOUR_SECOND_DIGIT] = Point(maxSingleDigitWidth, 0)
+ digitLeftTopMap[R.id.MINUTE_FIRST_DIGIT] = Point(0, maxSingleDigitHeight)
+ digitLeftTopMap[R.id.MINUTE_SECOND_DIGIT] = Point(maxSingleDigitWidth, maxSingleDigitHeight)
+ digitLeftTopMap.forEach { _, point ->
+ point.x += abs(aodTranslate.x)
+ point.y += abs(aodTranslate.y)
+ }
+ }
+
+ override fun refreshTime() {
+ super.refreshTime()
+ digitalClockTextViewMap.forEach { (_, textView) -> textView.refreshText() }
+ }
+
+ override fun onDraw(canvas: Canvas) {
+ super.onDraw(canvas)
+ digitalClockTextViewMap.forEach { (id, _) ->
+ val textView = digitalClockTextViewMap[id]!!
+ canvas.translate(digitLeftTopMap[id]!!.x.toFloat(), digitLeftTopMap[id]!!.y.toFloat())
+ textView.draw(canvas)
+ canvas.translate(-digitLeftTopMap[id]!!.x.toFloat(), -digitLeftTopMap[id]!!.y.toFloat())
+ }
+ }
+
+ override fun animateDoze(isDozing: Boolean, isAnimated: Boolean) {
+ dozeControlState.animateDoze = {
+ super.animateDoze(isDozing, isAnimated)
+ if (maxSingleDigitHeight == -1) {
+ measure(MeasureSpec.UNSPECIFIED, MeasureSpec.UNSPECIFIED)
+ }
+ digitalClockTextViewMap.forEach { (id, textView) ->
+ textView.digitTranslateAnimator?.let {
+ if (!isDozing) {
+ it.animatePosition(
+ animate = isAnimated && isAnimationEnabled,
+ interpolator = Interpolators.EMPHASIZED,
+ duration = AOD_TRANSITION_DURATION,
+ targetTranslation =
+ updateDirectionalTargetTranslate(id, lockscreenTranslate),
+ )
+ } else {
+ it.animatePosition(
+ animate = isAnimated && isAnimationEnabled,
+ interpolator = Interpolators.EMPHASIZED,
+ duration = AOD_TRANSITION_DURATION,
+ onAnimationEnd = null,
+ targetTranslation = updateDirectionalTargetTranslate(id, aodTranslate),
+ )
+ }
+ }
+ }
+ }
+ }
+
+ override fun animateCharge() {
+ super.animateCharge()
+ digitalClockTextViewMap.forEach { (id, textView) ->
+ textView.digitTranslateAnimator?.let {
+ it.animatePosition(
+ animate = isAnimationEnabled,
+ interpolator = Interpolators.EMPHASIZED,
+ duration = CHARGING_TRANSITION_DURATION,
+ onAnimationEnd = {
+ it.animatePosition(
+ animate = isAnimationEnabled,
+ interpolator = Interpolators.EMPHASIZED,
+ duration = CHARGING_TRANSITION_DURATION,
+ targetTranslation =
+ updateDirectionalTargetTranslate(
+ id,
+ if (dozeFraction == 1F) aodTranslate else lockscreenTranslate,
+ ),
+ )
+ },
+ targetTranslation =
+ updateDirectionalTargetTranslate(
+ id,
+ if (dozeFraction == 1F) lockscreenTranslate else aodTranslate,
+ ),
+ )
+ }
+ }
+ }
+
+ companion object {
+ val AOD_TRANSITION_DURATION = 750L
+ val CHARGING_TRANSITION_DURATION = 300L
+
+ val AOD_HORIZONTAL_TRANSLATE_RATIO = 0.15F
+ val AOD_VERTICAL_TRANSLATE_RATIO = 0.075F
+
+ // Use the sign of targetTranslation to control the direction of digit translation
+ fun updateDirectionalTargetTranslate(id: Int, targetTranslation: Point): Point {
+ val outPoint = Point(targetTranslation)
+ when (id) {
+ R.id.HOUR_FIRST_DIGIT -> {
+ outPoint.x *= -1
+ outPoint.y *= -1
+ }
+
+ R.id.HOUR_SECOND_DIGIT -> {
+ outPoint.x *= 1
+ outPoint.y *= -1
+ }
+
+ R.id.MINUTE_FIRST_DIGIT -> {
+ outPoint.x *= -1
+ outPoint.y *= 1
+ }
+
+ R.id.MINUTE_SECOND_DIGIT -> {
+ outPoint.x *= 1
+ outPoint.y *= 1
+ }
+ }
+ return outPoint
+ }
+ }
+}
diff --git a/packages/SystemUI/customization/src/com/android/systemui/shared/clocks/view/SimpleDigitalClockTextView.kt b/packages/SystemUI/customization/src/com/android/systemui/shared/clocks/view/SimpleDigitalClockTextView.kt
new file mode 100644
index 0000000..74617b1
--- /dev/null
+++ b/packages/SystemUI/customization/src/com/android/systemui/shared/clocks/view/SimpleDigitalClockTextView.kt
@@ -0,0 +1,654 @@
+/*
+ * Copyright (C) 2024 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 com.android.systemui.shared.clocks.view
+
+import android.annotation.SuppressLint
+import android.content.Context
+import android.graphics.Canvas
+import android.graphics.Color
+import android.graphics.Paint
+import android.graphics.Point
+import android.graphics.PorterDuff
+import android.graphics.PorterDuffXfermode
+import android.graphics.Rect
+import android.text.Layout
+import android.text.TextPaint
+import android.util.AttributeSet
+import android.util.Log
+import android.util.MathUtils
+import android.util.TypedValue
+import android.view.View.MeasureSpec.AT_MOST
+import android.view.View.MeasureSpec.EXACTLY
+import android.view.animation.Interpolator
+import android.widget.TextView
+import com.android.internal.annotations.VisibleForTesting
+import com.android.systemui.animation.TextAnimator
+import com.android.systemui.animation.TypefaceVariantCache
+import com.android.systemui.customization.R
+import com.android.systemui.log.core.Logger
+import com.android.systemui.log.core.MessageBuffer
+import com.android.systemui.shared.clocks.AssetLoader
+import com.android.systemui.shared.clocks.ClockAnimation
+import com.android.systemui.shared.clocks.DigitTranslateAnimator
+import com.android.systemui.shared.clocks.DimensionParser
+import com.android.systemui.shared.clocks.FontTextStyle
+import com.android.systemui.shared.clocks.LogUtil
+import com.android.systemui.shared.clocks.RenderType
+import com.android.systemui.shared.clocks.TextStyle
+import java.lang.Thread
+import kotlin.math.ceil
+import kotlin.math.max
+import kotlin.math.min
+
+private val TAG = SimpleDigitalClockTextView::class.simpleName!!
+
+@SuppressLint("AppCompatCustomView")
+open class SimpleDigitalClockTextView(
+ ctx: Context,
+ messageBuffer: MessageBuffer,
+ attrs: AttributeSet? = null,
+) : TextView(ctx, attrs), SimpleDigitalClockView {
+ val lockScreenPaint = TextPaint()
+ override lateinit var textStyle: FontTextStyle
+ lateinit var aodStyle: FontTextStyle
+ private val parser = DimensionParser(ctx)
+ var maxSingleDigitHeight = -1
+ var maxSingleDigitWidth = -1
+ var digitTranslateAnimator: DigitTranslateAnimator? = null
+ var aodFontSizePx: Float = -1F
+ var isVertical: Boolean = false
+
+ // Store the font size when there's no height constraint as a reference when adjusting font size
+ private var lastUnconstrainedTextSize: Float = Float.MAX_VALUE
+ // Calculated by height of styled text view / text size
+ // Used as a factor to calculate a smaller font size when text height is constrained
+ @VisibleForTesting var fontSizeAdjustFactor = 1F
+
+ private val initThread = Thread.currentThread()
+
+ // textBounds is the size of text in LS, which only measures current text in lockscreen style
+ var textBounds = Rect()
+ // prevTextBounds and targetTextBounds are to deal with dozing animation between LS and AOD
+ // especially for the textView which has different bounds during the animation
+ // prevTextBounds holds the state we are transitioning from
+ private val prevTextBounds = Rect()
+ // targetTextBounds holds the state we are interpolating to
+ private val targetTextBounds = Rect()
+ protected val logger = Logger(messageBuffer, this::class.simpleName!!)
+ get() = field ?: LogUtil.FALLBACK_INIT_LOGGER
+
+ private var aodDozingInterpolator: Interpolator? = null
+
+ @VisibleForTesting lateinit var textAnimator: TextAnimator
+ @VisibleForTesting var outlineAnimator: TextAnimator? = null
+ // used for hollow style for AOD version
+ // because stroke style for some fonts have some unwanted inner strokes
+ // we want to draw this layer on top to oclude them
+ @VisibleForTesting var innerAnimator: TextAnimator? = null
+
+ lateinit var typefaceCache: TypefaceVariantCache
+ private set
+
+ private fun setTypefaceCache(value: TypefaceVariantCache) {
+ typefaceCache = value
+ if (this::textAnimator.isInitialized) {
+ textAnimator.typefaceCache = value
+ }
+ outlineAnimator?.typefaceCache = value
+ innerAnimator?.typefaceCache = value
+ }
+
+ @VisibleForTesting
+ var textAnimatorFactory: (Layout, () -> Unit) -> TextAnimator = { layout, invalidateCb ->
+ TextAnimator(layout, ClockAnimation.NUM_CLOCK_FONT_ANIMATION_STEPS, invalidateCb).also {
+ if (this::typefaceCache.isInitialized) {
+ it.typefaceCache = typefaceCache
+ }
+ }
+ }
+
+ override var verticalAlignment: VerticalAlignment = VerticalAlignment.CENTER
+ override var horizontalAlignment: HorizontalAlignment = HorizontalAlignment.LEFT
+ override var isAnimationEnabled = true
+ override var dozeFraction: Float = 0F
+ set(value) {
+ field = value
+ invalidate()
+ }
+
+ // Have to passthrough to unify View with SimpleDigitalClockView
+ override var text: String
+ get() = super.getText().toString()
+ set(value) = super.setText(value)
+
+ var textBorderWidth = 0F
+ var aodBorderWidth = 0F
+ var baselineFromMeasure = 0
+
+ var textFillColor: Int? = null
+ var textOutlineColor = TEXT_OUTLINE_DEFAULT_COLOR
+ var aodFillColor = AOD_DEFAULT_COLOR
+ var aodOutlineColor = AOD_OUTLINE_DEFAULT_COLOR
+
+ override fun updateColors(assets: AssetLoader, isRegionDark: Boolean) {
+ val fillColor = if (isRegionDark) textStyle.fillColorLight else textStyle.fillColorDark
+ textFillColor =
+ fillColor?.let { assets.readColor(it) }
+ ?: assets.seedColor
+ ?: getDefaultColor(assets, isRegionDark)
+ // for NumberOverlapView to read correct color
+ lockScreenPaint.color = textFillColor as Int
+ textStyle.outlineColor?.let { textOutlineColor = assets.readColor(it) }
+ ?: run { textOutlineColor = TEXT_OUTLINE_DEFAULT_COLOR }
+ (aodStyle.fillColorLight ?: aodStyle.fillColorDark)?.let {
+ aodFillColor = assets.readColor(it)
+ } ?: run { aodFillColor = AOD_DEFAULT_COLOR }
+ aodStyle.outlineColor?.let { aodOutlineColor = assets.readColor(it) }
+ ?: run { aodOutlineColor = AOD_OUTLINE_DEFAULT_COLOR }
+ if (dozeFraction < 1f) {
+ textAnimator.setTextStyle(color = textFillColor, animate = false)
+ outlineAnimator?.setTextStyle(color = textOutlineColor, animate = false)
+ }
+ invalidate()
+ }
+
+ override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
+ logger.d("onMeasure()")
+ if (isVertical) {
+ // use at_most to avoid apply measuredWidth from last measuring to measuredHeight
+ // cause we use max to setMeasuredDimension
+ super.onMeasure(
+ MeasureSpec.makeMeasureSpec(MeasureSpec.getSize(widthMeasureSpec), AT_MOST),
+ MeasureSpec.makeMeasureSpec(MeasureSpec.getSize(heightMeasureSpec), AT_MOST),
+ )
+ } else {
+ super.onMeasure(widthMeasureSpec, heightMeasureSpec)
+ }
+
+ val layout = this.layout
+ if (layout != null) {
+ if (!this::textAnimator.isInitialized) {
+ textAnimator = textAnimatorFactory(layout, ::invalidate)
+ outlineAnimator = textAnimatorFactory(layout) {}
+ innerAnimator = textAnimatorFactory(layout) {}
+ setInterpolatorPaint()
+ } else {
+ textAnimator.updateLayout(layout)
+ outlineAnimator?.updateLayout(layout)
+ innerAnimator?.updateLayout(layout)
+ }
+ baselineFromMeasure = layout.getLineBaseline(0)
+ } else {
+ val currentThread = Thread.currentThread()
+ Log.wtf(
+ TAG,
+ "TextView.getLayout() is null after measure! " +
+ "currentThread=$currentThread; initThread=$initThread",
+ )
+ }
+
+ var expectedWidth: Int
+ var expectedHeight: Int
+
+ if (MeasureSpec.getMode(heightMeasureSpec) == EXACTLY) {
+ // For view which has fixed height, e.g. small clock,
+ // we should always return the size required from parent view
+ expectedHeight = heightMeasureSpec
+ } else {
+ expectedHeight =
+ MeasureSpec.makeMeasureSpec(
+ if (isSingleDigit()) {
+ maxSingleDigitHeight
+ } else {
+ textBounds.height() + 2 * lockScreenPaint.strokeWidth.toInt()
+ },
+ MeasureSpec.getMode(measuredHeight),
+ )
+ }
+ if (MeasureSpec.getMode(widthMeasureSpec) == EXACTLY) {
+ expectedWidth = widthMeasureSpec
+ } else {
+ expectedWidth =
+ MeasureSpec.makeMeasureSpec(
+ if (isSingleDigit()) {
+ maxSingleDigitWidth
+ } else {
+ max(
+ textBounds.width() + 2 * lockScreenPaint.strokeWidth.toInt(),
+ MeasureSpec.getSize(measuredWidth),
+ )
+ },
+ MeasureSpec.getMode(measuredWidth),
+ )
+ }
+
+ if (isVertical) {
+ expectedWidth = expectedHeight.also { expectedHeight = expectedWidth }
+ }
+ setMeasuredDimension(expectedWidth, expectedHeight)
+ }
+
+ override fun onDraw(canvas: Canvas) {
+ if (isVertical) {
+ canvas.save()
+ canvas.translate(0F, measuredHeight.toFloat())
+ canvas.rotate(-90F)
+ }
+ logger.d({ "onDraw(); ls: $str1; aod: $str2;" }) {
+ str1 = textAnimator.textInterpolator.shapedText
+ str2 = outlineAnimator?.textInterpolator?.shapedText
+ }
+ val translation = getLocalTranslation()
+ canvas.translate(translation.x.toFloat(), translation.y.toFloat())
+ digitTranslateAnimator?.let {
+ canvas.translate(it.updatedTranslate.x.toFloat(), it.updatedTranslate.y.toFloat())
+ }
+
+ if (aodStyle.renderType == RenderType.HOLLOW_TEXT) {
+ canvas.saveLayer(
+ -translation.x.toFloat(),
+ -translation.y.toFloat(),
+ (-translation.x + measuredWidth).toFloat(),
+ (-translation.y + measuredHeight).toFloat(),
+ null,
+ )
+ outlineAnimator?.draw(canvas)
+ canvas.saveLayer(
+ -translation.x.toFloat(),
+ -translation.y.toFloat(),
+ (-translation.x + measuredWidth).toFloat(),
+ (-translation.y + measuredHeight).toFloat(),
+ Paint().also { it.xfermode = PorterDuffXfermode(PorterDuff.Mode.DST_OUT) },
+ )
+ innerAnimator?.draw(canvas)
+ canvas.restore()
+ canvas.restore()
+ } else if (aodStyle.renderType != RenderType.CHANGE_WEIGHT) {
+ outlineAnimator?.draw(canvas)
+ }
+ textAnimator.draw(canvas)
+
+ digitTranslateAnimator?.let {
+ canvas.translate(-it.updatedTranslate.x.toFloat(), -it.updatedTranslate.y.toFloat())
+ }
+ canvas.translate(-translation.x.toFloat(), -translation.y.toFloat())
+ if (isVertical) {
+ canvas.restore()
+ }
+ }
+
+ override fun invalidate() {
+ logger.d("invalidate()")
+ super.invalidate()
+ (parent as? DigitalClockFaceView)?.invalidate()
+ }
+
+ override fun refreshTime() {
+ logger.d("refreshTime()")
+ refreshText()
+ }
+
+ override fun animateDoze(isDozing: Boolean, isAnimated: Boolean) {
+ if (!this::textAnimator.isInitialized) {
+ return
+ }
+ val fvar = if (isDozing) aodStyle.fontVariation else textStyle.fontVariation
+ textAnimator.setTextStyle(
+ animate = isAnimated && isAnimationEnabled,
+ color = if (isDozing) aodFillColor else textFillColor,
+ textSize = if (isDozing) aodFontSizePx else lockScreenPaint.textSize,
+ fvar = fvar,
+ duration = aodStyle.transitionDuration,
+ interpolator = aodDozingInterpolator,
+ )
+ updateTextBoundsForTextAnimator()
+ outlineAnimator?.setTextStyle(
+ animate = isAnimated && isAnimationEnabled,
+ color = if (isDozing) aodOutlineColor else textOutlineColor,
+ textSize = if (isDozing) aodFontSizePx else lockScreenPaint.textSize,
+ fvar = fvar,
+ strokeWidth = if (isDozing) aodBorderWidth else textBorderWidth,
+ duration = aodStyle.transitionDuration,
+ interpolator = aodDozingInterpolator,
+ )
+ innerAnimator?.setTextStyle(
+ animate = isAnimated && isAnimationEnabled,
+ color = Color.WHITE,
+ textSize = if (isDozing) aodFontSizePx else lockScreenPaint.textSize,
+ fvar = fvar,
+ duration = aodStyle.transitionDuration,
+ interpolator = aodDozingInterpolator,
+ )
+ }
+
+ override fun animateCharge() {
+ if (!this::textAnimator.isInitialized || textAnimator.isRunning()) {
+ // Skip charge animation if dozing animation is already playing.
+ return
+ }
+ logger.d("animateCharge()")
+ val middleFvar = if (dozeFraction == 0F) aodStyle.fontVariation else textStyle.fontVariation
+ val endFvar = if (dozeFraction == 0F) textStyle.fontVariation else aodStyle.fontVariation
+ val startAnimPhase2 = Runnable {
+ textAnimator.setTextStyle(fvar = endFvar, animate = isAnimationEnabled)
+ outlineAnimator?.setTextStyle(fvar = endFvar, animate = isAnimationEnabled)
+ innerAnimator?.setTextStyle(fvar = endFvar, animate = isAnimationEnabled)
+ updateTextBoundsForTextAnimator()
+ }
+ textAnimator.setTextStyle(
+ fvar = middleFvar,
+ animate = isAnimationEnabled,
+ onAnimationEnd = startAnimPhase2,
+ )
+ outlineAnimator?.setTextStyle(fvar = middleFvar, animate = isAnimationEnabled)
+ innerAnimator?.setTextStyle(fvar = middleFvar, animate = isAnimationEnabled)
+ updateTextBoundsForTextAnimator()
+ }
+
+ fun refreshText() {
+ lockScreenPaint.getTextBounds(text, 0, text.length, textBounds)
+ if (this::textAnimator.isInitialized) {
+ textAnimator.textInterpolator.targetPaint.getTextBounds(
+ text,
+ 0,
+ text.length,
+ targetTextBounds,
+ )
+ }
+ if (layout == null) {
+ requestLayout()
+ } else {
+ textAnimator.updateLayout(layout)
+ outlineAnimator?.updateLayout(layout)
+ innerAnimator?.updateLayout(layout)
+ }
+ }
+
+ private fun isSingleDigit(): Boolean {
+ return id == R.id.HOUR_FIRST_DIGIT ||
+ id == R.id.HOUR_SECOND_DIGIT ||
+ id == R.id.MINUTE_FIRST_DIGIT ||
+ id == R.id.MINUTE_SECOND_DIGIT
+ }
+
+ private fun updateInterpolatedTextBounds(): Rect {
+ val interpolatedTextBounds = Rect()
+ if (textAnimator.animator.animatedFraction != 1.0f && textAnimator.animator.isRunning) {
+ interpolatedTextBounds.left =
+ MathUtils.lerp(
+ prevTextBounds.left,
+ targetTextBounds.left,
+ textAnimator.animator.animatedValue as Float,
+ )
+ .toInt()
+
+ interpolatedTextBounds.right =
+ MathUtils.lerp(
+ prevTextBounds.right,
+ targetTextBounds.right,
+ textAnimator.animator.animatedValue as Float,
+ )
+ .toInt()
+
+ interpolatedTextBounds.top =
+ MathUtils.lerp(
+ prevTextBounds.top,
+ targetTextBounds.top,
+ textAnimator.animator.animatedValue as Float,
+ )
+ .toInt()
+
+ interpolatedTextBounds.bottom =
+ MathUtils.lerp(
+ prevTextBounds.bottom,
+ targetTextBounds.bottom,
+ textAnimator.animator.animatedValue as Float,
+ )
+ .toInt()
+ } else {
+ interpolatedTextBounds.set(targetTextBounds)
+ }
+ return interpolatedTextBounds
+ }
+
+ private fun updateXtranslation(inPoint: Point, interpolatedTextBounds: Rect): Point {
+ val viewWidth = if (isVertical) measuredHeight else measuredWidth
+ when (horizontalAlignment) {
+ HorizontalAlignment.LEFT -> {
+ inPoint.x = lockScreenPaint.strokeWidth.toInt() - interpolatedTextBounds.left
+ }
+ HorizontalAlignment.RIGHT -> {
+ inPoint.x =
+ viewWidth - interpolatedTextBounds.right - lockScreenPaint.strokeWidth.toInt()
+ }
+ HorizontalAlignment.CENTER -> {
+ inPoint.x =
+ (viewWidth - interpolatedTextBounds.width()) / 2 - interpolatedTextBounds.left
+ }
+ }
+ return inPoint
+ }
+
+ // translation of reference point of text
+ // used for translation when calling textInterpolator
+ fun getLocalTranslation(): Point {
+ val viewHeight = if (isVertical) measuredWidth else measuredHeight
+ val interpolatedTextBounds = updateInterpolatedTextBounds()
+ val localTranslation = Point(0, 0)
+ val correctedBaseline = if (baseline != -1) baseline else baselineFromMeasure
+ // get the change from current baseline to expected baseline
+ when (verticalAlignment) {
+ VerticalAlignment.CENTER -> {
+ localTranslation.y =
+ ((viewHeight - interpolatedTextBounds.height()) / 2 -
+ interpolatedTextBounds.top -
+ correctedBaseline)
+ }
+ VerticalAlignment.TOP -> {
+ localTranslation.y =
+ (-interpolatedTextBounds.top + lockScreenPaint.strokeWidth - correctedBaseline)
+ .toInt()
+ }
+ VerticalAlignment.BOTTOM -> {
+ localTranslation.y =
+ viewHeight -
+ interpolatedTextBounds.bottom -
+ lockScreenPaint.strokeWidth.toInt() -
+ correctedBaseline
+ }
+ VerticalAlignment.BASELINE -> {
+ localTranslation.y = -lockScreenPaint.strokeWidth.toInt()
+ }
+ }
+
+ return updateXtranslation(localTranslation, interpolatedTextBounds)
+ }
+
+ override fun applyStyles(assets: AssetLoader, textStyle: TextStyle, aodStyle: TextStyle?) {
+ this.textStyle = textStyle as FontTextStyle
+ val typefaceName = "fonts/" + textStyle.fontFamily
+ setTypefaceCache(assets.typefaceCache.getVariantCache(typefaceName))
+ lockScreenPaint.strokeJoin = Paint.Join.ROUND
+ lockScreenPaint.typeface = typefaceCache.getTypefaceForVariant(textStyle.fontVariation)
+ textStyle.fontFeatureSettings?.let {
+ lockScreenPaint.fontFeatureSettings = it
+ fontFeatureSettings = it
+ }
+ typeface = lockScreenPaint.typeface
+ textStyle.lineHeight?.let { lineHeight = it.toInt() }
+ // borderWidth in textStyle and aodStyle is used to draw,
+ // strokeWidth in lockScreenPaint is used to measure and get enough space for the text
+ textStyle.borderWidth?.let { textBorderWidth = parser.convert(it) }
+
+ if (aodStyle != null && aodStyle is FontTextStyle) {
+ this.aodStyle = aodStyle
+ } else {
+ this.aodStyle = textStyle.copy()
+ }
+ this.aodStyle.transitionInterpolator?.let { aodDozingInterpolator = it.interpolator }
+ aodBorderWidth = parser.convert(this.aodStyle.borderWidth ?: DEFAULT_AOD_STROKE_WIDTH)
+ lockScreenPaint.strokeWidth = ceil(max(textBorderWidth, aodBorderWidth))
+ measure(MeasureSpec.UNSPECIFIED, MeasureSpec.UNSPECIFIED)
+ setInterpolatorPaint()
+ recomputeMaxSingleDigitSizes()
+ invalidate()
+ }
+
+ // When constrainedByHeight is on, targetFontSizePx is the constrained height of textView
+ override fun applyTextSize(targetFontSizePx: Float?, constrainedByHeight: Boolean) {
+ val adjustedFontSizePx = adjustFontSize(targetFontSizePx, constrainedByHeight)
+ val fontSizePx = adjustedFontSizePx * (textStyle.fontSizeScale ?: 1f)
+ aodFontSizePx =
+ adjustedFontSizePx * (aodStyle.fontSizeScale ?: textStyle.fontSizeScale ?: 1f)
+ if (fontSizePx > 0) {
+ setTextSize(TypedValue.COMPLEX_UNIT_PX, fontSizePx)
+ lockScreenPaint.textSize = textSize
+ lockScreenPaint.getTextBounds(text, 0, text.length, textBounds)
+ targetTextBounds.set(textBounds)
+ }
+ if (!constrainedByHeight) {
+ val lastUnconstrainedHeight = textBounds.height() + lockScreenPaint.strokeWidth * 2
+ fontSizeAdjustFactor = lastUnconstrainedHeight / lastUnconstrainedTextSize
+ }
+ textStyle.borderWidthScale?.let {
+ textBorderWidth = fontSizePx * it
+ if (dozeFraction < 1.0F) {
+ outlineAnimator?.setTextStyle(strokeWidth = textBorderWidth, animate = false)
+ }
+ }
+ aodStyle.borderWidthScale?.let {
+ aodBorderWidth = fontSizePx * it
+ if (dozeFraction > 0.0F) {
+ outlineAnimator?.setTextStyle(strokeWidth = aodBorderWidth, animate = false)
+ }
+ }
+
+ lockScreenPaint.strokeWidth = ceil(max(textBorderWidth, aodBorderWidth))
+ recomputeMaxSingleDigitSizes()
+
+ if (this::textAnimator.isInitialized) {
+ textAnimator.setTextStyle(textSize = lockScreenPaint.textSize, animate = false)
+ }
+ outlineAnimator?.setTextStyle(textSize = lockScreenPaint.textSize, animate = false)
+ innerAnimator?.setTextStyle(textSize = lockScreenPaint.textSize, animate = false)
+ }
+
+ private fun recomputeMaxSingleDigitSizes() {
+ val rectForCalculate = Rect()
+ maxSingleDigitHeight = 0
+ maxSingleDigitWidth = 0
+
+ for (i in 0..9) {
+ lockScreenPaint.getTextBounds(i.toString(), 0, 1, rectForCalculate)
+ maxSingleDigitHeight = max(maxSingleDigitHeight, rectForCalculate.height())
+ maxSingleDigitWidth = max(maxSingleDigitWidth, rectForCalculate.width())
+ }
+ maxSingleDigitWidth += 2 * lockScreenPaint.strokeWidth.toInt()
+ maxSingleDigitHeight += 2 * lockScreenPaint.strokeWidth.toInt()
+ }
+
+ // called without animation, can be used to set the initial state of animator
+ private fun setInterpolatorPaint() {
+ if (this::textAnimator.isInitialized) {
+ // set initial style
+ textAnimator.textInterpolator.targetPaint.set(lockScreenPaint)
+ textAnimator.textInterpolator.onTargetPaintModified()
+ textAnimator.setTextStyle(
+ fvar = textStyle.fontVariation,
+ textSize = lockScreenPaint.textSize,
+ color = textFillColor,
+ animate = false,
+ )
+ }
+
+ if (outlineAnimator != null) {
+ outlineAnimator!!
+ .textInterpolator
+ .targetPaint
+ .set(
+ TextPaint(lockScreenPaint).also {
+ it.style =
+ if (aodStyle.renderType == RenderType.HOLLOW_TEXT)
+ Paint.Style.FILL_AND_STROKE
+ else Paint.Style.STROKE
+ }
+ )
+ outlineAnimator!!.textInterpolator.onTargetPaintModified()
+ outlineAnimator!!.setTextStyle(
+ fvar = aodStyle.fontVariation,
+ textSize = lockScreenPaint.textSize,
+ color = Color.TRANSPARENT,
+ animate = false,
+ )
+ }
+
+ if (innerAnimator != null) {
+ innerAnimator!!
+ .textInterpolator
+ .targetPaint
+ .set(TextPaint(lockScreenPaint).also { it.style = Paint.Style.FILL })
+ innerAnimator!!.textInterpolator.onTargetPaintModified()
+ innerAnimator!!.setTextStyle(
+ fvar = aodStyle.fontVariation,
+ textSize = lockScreenPaint.textSize,
+ color = Color.WHITE,
+ animate = false,
+ )
+ }
+ }
+
+ /* Called after textAnimator.setTextStyle
+ * textAnimator.setTextStyle will update targetPaint,
+ * and rebase if previous animator is canceled
+ * so basePaint will store the state we transition from
+ * and targetPaint will store the state we transition to
+ */
+ private fun updateTextBoundsForTextAnimator() {
+ textAnimator.textInterpolator.basePaint.getTextBounds(text, 0, text.length, prevTextBounds)
+ textAnimator.textInterpolator.targetPaint.getTextBounds(
+ text,
+ 0,
+ text.length,
+ targetTextBounds,
+ )
+ }
+
+ /*
+ * Adjust text size to adapt to large display / font size
+ * where the text view will be constrained by height
+ */
+ private fun adjustFontSize(targetFontSizePx: Float?, constrainedByHeight: Boolean): Float {
+ return if (constrainedByHeight) {
+ min((targetFontSizePx ?: 0F) / fontSizeAdjustFactor, lastUnconstrainedTextSize)
+ } else {
+ lastUnconstrainedTextSize = targetFontSizePx ?: 1F
+ lastUnconstrainedTextSize
+ }
+ }
+
+ companion object {
+ val DEFAULT_AOD_STROKE_WIDTH = "2dp"
+ val TEXT_OUTLINE_DEFAULT_COLOR = Color.TRANSPARENT
+ val AOD_DEFAULT_COLOR = Color.TRANSPARENT
+ val AOD_OUTLINE_DEFAULT_COLOR = Color.WHITE
+ private val DEFAULT_LIGHT_COLOR = "@android:color/system_accent1_100+0"
+ private val DEFAULT_DARK_COLOR = "@android:color/system_accent2_600+0"
+
+ fun getDefaultColor(assets: AssetLoader, isRegionDark: Boolean) =
+ assets.readColor(if (isRegionDark) DEFAULT_LIGHT_COLOR else DEFAULT_DARK_COLOR)
+ }
+}
diff --git a/packages/SystemUI/customization/src/com/android/systemui/shared/clocks/view/SimpleDigitalClockView.kt b/packages/SystemUI/customization/src/com/android/systemui/shared/clocks/view/SimpleDigitalClockView.kt
new file mode 100644
index 0000000..bbd2d3d
--- /dev/null
+++ b/packages/SystemUI/customization/src/com/android/systemui/shared/clocks/view/SimpleDigitalClockView.kt
@@ -0,0 +1,55 @@
+/*
+ * Copyright (C) 2024 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 com.android.systemui.shared.clocks.view
+
+import androidx.annotation.VisibleForTesting
+import com.android.systemui.shared.clocks.AssetLoader
+import com.android.systemui.shared.clocks.TextStyle
+
+interface SimpleDigitalClockView {
+ var text: String
+ var verticalAlignment: VerticalAlignment
+ var horizontalAlignment: HorizontalAlignment
+ var dozeFraction: Float
+ val textStyle: TextStyle
+ @VisibleForTesting var isAnimationEnabled: Boolean
+
+ fun applyStyles(assets: AssetLoader, textStyle: TextStyle, aodStyle: TextStyle?)
+
+ fun applyTextSize(targetFontSizePx: Float?, constrainedByHeight: Boolean = false)
+
+ fun updateColors(assets: AssetLoader, isRegionDark: Boolean)
+
+ fun refreshTime()
+
+ fun animateCharge()
+
+ fun animateDoze(isDozing: Boolean, isAnimated: Boolean)
+}
+
+enum class VerticalAlignment {
+ TOP,
+ BOTTOM,
+ BASELINE, // default
+ CENTER,
+}
+
+enum class HorizontalAlignment {
+ LEFT,
+ RIGHT,
+ CENTER, // default
+}