1 /*
2  * Copyright (C) 2021 The Android Open Source Project
3  *
4  * Licensed under the Apache License, Version 2.0 (the "License");
5  * you may not use this file except in compliance with the License.
6  * You may obtain a copy of the License at
7  *
8  *      http://www.apache.org/licenses/LICENSE-2.0
9  *
10  * Unless required by applicable law or agreed to in writing, software
11  * distributed under the License is distributed on an "AS IS" BASIS,
12  * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13  * See the License for the specific language governing permissions and
14  * limitations under the License.
15  */
16 package com.android.systemui.shared.clocks
17 
18 import android.animation.TimeInterpolator
19 import android.annotation.ColorInt
20 import android.annotation.FloatRange
21 import android.annotation.IntRange
22 import android.annotation.SuppressLint
23 import android.content.Context
24 import android.graphics.Canvas
25 import android.text.Layout
26 import android.text.TextUtils
27 import android.text.format.DateFormat
28 import android.util.AttributeSet
29 import android.util.MathUtils.constrainedMap
30 import android.widget.TextView
31 import com.android.app.animation.Interpolators
32 import com.android.internal.annotations.VisibleForTesting
33 import com.android.systemui.animation.GlyphCallback
34 import com.android.systemui.animation.TextAnimator
35 import com.android.systemui.customization.R
36 import com.android.systemui.log.core.Logger
37 import com.android.systemui.log.core.MessageBuffer
38 import java.io.PrintWriter
39 import java.util.Calendar
40 import java.util.Locale
41 import java.util.TimeZone
42 
43 /**
44  * Displays the time with the hour positioned above the minutes. (ie: 09 above 30 is 9:30)
45  * The time's text color is a gradient that changes its colors based on its controller.
46  */
47 @SuppressLint("AppCompatCustomView")
48 class AnimatableClockView @JvmOverloads constructor(
49     context: Context,
50     attrs: AttributeSet? = null,
51     defStyleAttr: Int = 0,
52     defStyleRes: Int = 0
53 ) : TextView(context, attrs, defStyleAttr, defStyleRes) {
54     var messageBuffer: MessageBuffer? = null
55         set(value) {
56             logger = if (value != null) Logger(value, TAG) else null
57         }
58 
59     private var logger: Logger? = null
60 
61     private val time = Calendar.getInstance()
62 
63     private val dozingWeightInternal: Int
64     private val lockScreenWeightInternal: Int
65     private val isSingleLineInternal: Boolean
66 
67     private var format: CharSequence? = null
68     private var descFormat: CharSequence? = null
69 
70     @ColorInt
71     private var dozingColor = 0
72 
73     @ColorInt
74     private var lockScreenColor = 0
75 
76     private var lineSpacingScale = 1f
77     private val chargeAnimationDelay: Int
78     private var textAnimator: TextAnimator? = null
79     private var onTextAnimatorInitialized: Runnable? = null
80 
81     @VisibleForTesting var textAnimatorFactory: (Layout, () -> Unit) -> TextAnimator =
82         { layout, invalidateCb ->
83             TextAnimator(layout, NUM_CLOCK_FONT_ANIMATION_STEPS, invalidateCb) }
84     @VisibleForTesting var isAnimationEnabled: Boolean = true
85     @VisibleForTesting var timeOverrideInMillis: Long? = null
86 
87     val dozingWeight: Int
88         get() = if (useBoldedVersion()) dozingWeightInternal + 100 else dozingWeightInternal
89 
90     val lockScreenWeight: Int
91         get() = if (useBoldedVersion()) lockScreenWeightInternal + 100 else lockScreenWeightInternal
92 
93     /**
94      * The number of pixels below the baseline. For fonts that support languages such as
95      * Burmese, this space can be significant and should be accounted for when computing layout.
96      */
97     val bottom get() = paint?.fontMetrics?.bottom ?: 0f
98 
99     init {
100         val animatableClockViewAttributes = context.obtainStyledAttributes(
101             attrs, R.styleable.AnimatableClockView, defStyleAttr, defStyleRes
102         )
103 
104         try {
105             dozingWeightInternal = animatableClockViewAttributes.getInt(
106                 R.styleable.AnimatableClockView_dozeWeight,
107                 100
108             )
109             lockScreenWeightInternal = animatableClockViewAttributes.getInt(
110                 R.styleable.AnimatableClockView_lockScreenWeight,
111                 300
112             )
113             chargeAnimationDelay = animatableClockViewAttributes.getInt(
114                 R.styleable.AnimatableClockView_chargeAnimationDelay, 200
115             )
116         } finally {
117             animatableClockViewAttributes.recycle()
118         }
119 
120         val textViewAttributes = context.obtainStyledAttributes(
121             attrs, android.R.styleable.TextView,
122             defStyleAttr, defStyleRes
123         )
124 
125         isSingleLineInternal =
126             try {
127                 textViewAttributes.getBoolean(android.R.styleable.TextView_singleLine, false)
128             } finally {
129                 textViewAttributes.recycle()
130             }
131 
132         refreshFormat()
133     }
134 
135     override fun onAttachedToWindow() {
136         super.onAttachedToWindow()
137         logger?.d("onAttachedToWindow")
138         refreshFormat()
139     }
140 
141     /**
142      * Whether to use a bolded version based on the user specified fontWeightAdjustment.
143      */
144     fun useBoldedVersion(): Boolean {
145         // "Bold text" fontWeightAdjustment is 300.
146         return resources.configuration.fontWeightAdjustment > 100
147     }
148 
149     fun refreshTime() {
150         time.timeInMillis = timeOverrideInMillis ?: System.currentTimeMillis()
151         contentDescription = DateFormat.format(descFormat, time)
152         val formattedText = DateFormat.format(format, time)
153         logger?.d({ "refreshTime: new formattedText=$str1" }) { str1 = formattedText?.toString() }
154         // Setting text actually triggers a layout pass (because the text view is set to
155         // wrap_content width and TextView always relayouts for this). Avoid needless
156         // relayout if the text didn't actually change.
157         if (!TextUtils.equals(text, formattedText)) {
158             text = formattedText
159             logger?.d({ "refreshTime: done setting new time text to: $str1" }) {
160                 str1 = formattedText?.toString()
161             }
162             // Because the TextLayout may mutate under the hood as a result of the new text, we
163             // notify the TextAnimator that it may have changed and request a measure/layout. A
164             // crash will occur on the next invocation of setTextStyle if the layout is mutated
165             // without being notified TextInterpolator being notified.
166             if (layout != null) {
167                 textAnimator?.updateLayout(layout)
168                 logger?.d("refreshTime: done updating textAnimator layout")
169             }
170             requestLayout()
171             logger?.d("refreshTime: after requestLayout")
172         }
173     }
174 
175     fun onTimeZoneChanged(timeZone: TimeZone?) {
176         time.timeZone = timeZone
177         refreshFormat()
178         logger?.d({ "onTimeZoneChanged newTimeZone=$str1" }) { str1 = timeZone?.toString() }
179     }
180 
181     @SuppressLint("DrawAllocation")
182     override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
183         super.onMeasure(widthMeasureSpec, heightMeasureSpec)
184         val animator = textAnimator
185         if (animator == null) {
186             textAnimator = textAnimatorFactory(layout, ::invalidate)
187             onTextAnimatorInitialized?.run()
188             onTextAnimatorInitialized = null
189         } else {
190             animator.updateLayout(layout)
191         }
192         logger?.d("onMeasure")
193     }
194 
195     override fun onDraw(canvas: Canvas) {
196         // Use textAnimator to render text if animation is enabled.
197         // Otherwise default to using standard draw functions.
198         if (isAnimationEnabled) {
199             // intentionally doesn't call super.onDraw here or else the text will be rendered twice
200             textAnimator?.draw(canvas)
201         } else {
202             super.onDraw(canvas)
203         }
204         logger?.d("onDraw")
205     }
206 
207     override fun invalidate() {
208         super.invalidate()
209         logger?.d("invalidate")
210     }
211 
212     override fun onTextChanged(
213             text: CharSequence,
214             start: Int,
215             lengthBefore: Int,
216             lengthAfter: Int
217     ) {
218         super.onTextChanged(text, start, lengthBefore, lengthAfter)
219         logger?.d({ "onTextChanged text=$str1" }) { str1 = text.toString() }
220     }
221 
222     fun setLineSpacingScale(scale: Float) {
223         lineSpacingScale = scale
224         setLineSpacing(0f, lineSpacingScale)
225     }
226 
227     fun setColors(@ColorInt dozingColor: Int, lockScreenColor: Int) {
228         this.dozingColor = dozingColor
229         this.lockScreenColor = lockScreenColor
230     }
231 
232     fun animateColorChange() {
233         logger?.d("animateColorChange")
234         setTextStyle(
235             weight = lockScreenWeight,
236             textSize = -1f,
237             color = null, /* using current color */
238             animate = false,
239             duration = 0,
240             delay = 0,
241             onAnimationEnd = null
242         )
243         setTextStyle(
244             weight = lockScreenWeight,
245             textSize = -1f,
246             color = lockScreenColor,
247             animate = true,
248             duration = COLOR_ANIM_DURATION,
249             delay = 0,
250             onAnimationEnd = null
251         )
252     }
253 
254     fun animateAppearOnLockscreen() {
255         logger?.d("animateAppearOnLockscreen")
256         setTextStyle(
257             weight = dozingWeight,
258             textSize = -1f,
259             color = lockScreenColor,
260             animate = false,
261             duration = 0,
262             delay = 0,
263             onAnimationEnd = null
264         )
265         setTextStyle(
266             weight = lockScreenWeight,
267             textSize = -1f,
268             color = lockScreenColor,
269             animate = isAnimationEnabled,
270             duration = APPEAR_ANIM_DURATION,
271             interpolator = Interpolators.EMPHASIZED_DECELERATE,
272             delay = 0,
273             onAnimationEnd = null
274         )
275     }
276 
277     fun animateFoldAppear(animate: Boolean = true) {
278         if (isAnimationEnabled && textAnimator == null) {
279             return
280         }
281         logger?.d("animateFoldAppear")
282         setTextStyle(
283             weight = lockScreenWeightInternal,
284             textSize = -1f,
285             color = lockScreenColor,
286             animate = false,
287             duration = 0,
288             delay = 0,
289             onAnimationEnd = null
290         )
291         setTextStyle(
292             weight = dozingWeightInternal,
293             textSize = -1f,
294             color = dozingColor,
295             animate = animate && isAnimationEnabled,
296             interpolator = Interpolators.EMPHASIZED_DECELERATE,
297             duration = ANIMATION_DURATION_FOLD_TO_AOD.toLong(),
298             delay = 0,
299             onAnimationEnd = null
300         )
301     }
302 
303     fun animateCharge(isDozing: () -> Boolean) {
304         if (textAnimator == null || textAnimator!!.isRunning()) {
305             // Skip charge animation if dozing animation is already playing.
306             return
307         }
308         logger?.d("animateCharge")
309         val startAnimPhase2 = Runnable {
310             setTextStyle(
311                 weight = if (isDozing()) dozingWeight else lockScreenWeight,
312                 textSize = -1f,
313                 color = null,
314                 animate = isAnimationEnabled,
315                 duration = CHARGE_ANIM_DURATION_PHASE_1,
316                 delay = 0,
317                 onAnimationEnd = null
318             )
319         }
320         setTextStyle(
321             weight = if (isDozing()) lockScreenWeight else dozingWeight,
322             textSize = -1f,
323             color = null,
324             animate = isAnimationEnabled,
325             duration = CHARGE_ANIM_DURATION_PHASE_0,
326             delay = chargeAnimationDelay.toLong(),
327             onAnimationEnd = startAnimPhase2
328         )
329     }
330 
331     fun animateDoze(isDozing: Boolean, animate: Boolean) {
332         logger?.d("animateDoze")
333         setTextStyle(
334             weight = if (isDozing) dozingWeight else lockScreenWeight,
335             textSize = -1f,
336             color = if (isDozing) dozingColor else lockScreenColor,
337             animate = animate && isAnimationEnabled,
338             duration = DOZE_ANIM_DURATION,
339             delay = 0,
340             onAnimationEnd = null
341         )
342     }
343 
344     // The offset of each glyph from where it should be.
345     private var glyphOffsets = mutableListOf(0.0f, 0.0f, 0.0f, 0.0f)
346 
347     private var lastSeenAnimationProgress = 1.0f
348 
349     // If the animation is being reversed, the target offset for each glyph for the "stop".
350     private var animationCancelStartPosition = mutableListOf(0.0f, 0.0f, 0.0f, 0.0f)
351     private var animationCancelStopPosition = 0.0f
352 
353     // Whether the currently playing animation needed a stop (and thus, is shortened).
354     private var currentAnimationNeededStop = false
355 
356     private val glyphFilter: GlyphCallback = { positionedGlyph, _ ->
357         val offset = positionedGlyph.lineNo * DIGITS_PER_LINE + positionedGlyph.glyphIndex
358         if (offset < glyphOffsets.size) {
359             positionedGlyph.x += glyphOffsets[offset]
360         }
361     }
362 
363     /**
364      * Set text style with an optional animation.
365      *
366      * By passing -1 to weight, the view preserves its current weight.
367      * By passing -1 to textSize, the view preserves its current text size.
368      * By passing null to color, the view preserves its current color.
369      *
370      * @param weight text weight.
371      * @param textSize font size.
372      * @param animate true to animate the text style change, otherwise false.
373      */
374     private fun setTextStyle(
375         @IntRange(from = 0, to = 1000) weight: Int,
376         @FloatRange(from = 0.0) textSize: Float,
377         color: Int?,
378         animate: Boolean,
379         interpolator: TimeInterpolator?,
380         duration: Long,
381         delay: Long,
382         onAnimationEnd: Runnable?
383     ) {
384         if (textAnimator != null) {
385             textAnimator?.setTextStyle(
386                 weight = weight,
387                 textSize = textSize,
388                 color = color,
389                 animate = animate && isAnimationEnabled,
390                 duration = duration,
391                 interpolator = interpolator,
392                 delay = delay,
393                 onAnimationEnd = onAnimationEnd
394             )
395             textAnimator?.glyphFilter = glyphFilter
396             if (color != null && !isAnimationEnabled) {
397                 setTextColor(color)
398             }
399         } else {
400             // when the text animator is set, update its start values
401             onTextAnimatorInitialized = Runnable {
402                 textAnimator?.setTextStyle(
403                     weight = weight,
404                     textSize = textSize,
405                     color = color,
406                     animate = false,
407                     duration = duration,
408                     interpolator = interpolator,
409                     delay = delay,
410                     onAnimationEnd = onAnimationEnd
411                 )
412                 textAnimator?.glyphFilter = glyphFilter
413                 if (color != null && !isAnimationEnabled) {
414                     setTextColor(color)
415                 }
416             }
417         }
418     }
419 
420     private fun setTextStyle(
421         @IntRange(from = 0, to = 1000) weight: Int,
422         @FloatRange(from = 0.0) textSize: Float,
423         color: Int?,
424         animate: Boolean,
425         duration: Long,
426         delay: Long,
427         onAnimationEnd: Runnable?
428     ) {
429         setTextStyle(
430             weight = weight,
431             textSize = textSize,
432             color = color,
433             animate = animate && isAnimationEnabled,
434             interpolator = null,
435             duration = duration,
436             delay = delay,
437             onAnimationEnd = onAnimationEnd
438         )
439     }
440 
441     fun refreshFormat() = refreshFormat(DateFormat.is24HourFormat(context))
442     fun refreshFormat(use24HourFormat: Boolean) {
443         Patterns.update(context)
444 
445         format = when {
446             isSingleLineInternal && use24HourFormat -> Patterns.sClockView24
447             !isSingleLineInternal && use24HourFormat -> DOUBLE_LINE_FORMAT_24_HOUR
448             isSingleLineInternal && !use24HourFormat -> Patterns.sClockView12
449             else -> DOUBLE_LINE_FORMAT_12_HOUR
450         }
451         logger?.d({ "refreshFormat format=$str1" }) { str1 = format?.toString() }
452 
453         descFormat = if (use24HourFormat) Patterns.sClockView24 else Patterns.sClockView12
454         refreshTime()
455     }
456 
457     fun dump(pw: PrintWriter) {
458         pw.println("$this")
459         pw.println("    alpha=$alpha")
460         pw.println("    measuredWidth=$measuredWidth")
461         pw.println("    measuredHeight=$measuredHeight")
462         pw.println("    singleLineInternal=$isSingleLineInternal")
463         pw.println("    currText=$text")
464         pw.println("    currTimeContextDesc=$contentDescription")
465         pw.println("    dozingWeightInternal=$dozingWeightInternal")
466         pw.println("    lockScreenWeightInternal=$lockScreenWeightInternal")
467         pw.println("    dozingColor=$dozingColor")
468         pw.println("    lockScreenColor=$lockScreenColor")
469         pw.println("    time=$time")
470     }
471 
472     private val moveToCenterDelays
473         get() = if (isLayoutRtl) MOVE_LEFT_DELAYS else MOVE_RIGHT_DELAYS
474 
475     private val moveToSideDelays
476         get() = if (isLayoutRtl) MOVE_RIGHT_DELAYS else MOVE_LEFT_DELAYS
477 
478     /**
479      * Offsets the glyphs of the clock for the step clock animation.
480      *
481      * The animation makes the glyphs of the clock move at different speeds, when the clock is
482      * moving horizontally.
483      *
484      * @param clockStartLeft the [getLeft] position of the clock, before it started moving.
485      * @param clockMoveDirection the direction in which it is moving. A positive number means right,
486      *   and negative means left.
487      * @param moveFraction fraction of the clock movement. 0 means it is at the beginning, and 1
488      *   means it finished moving.
489      */
490     fun offsetGlyphsForStepClockAnimation(
491             clockStartLeft: Int,
492             clockMoveDirection: Int,
493             moveFraction: Float
494     ) {
495         val isMovingToCenter = if (isLayoutRtl) clockMoveDirection < 0 else clockMoveDirection > 0
496         val currentMoveAmount = left - clockStartLeft
497         val digitOffsetDirection = if (isLayoutRtl) -1 else 1
498         for (i in 0 until NUM_DIGITS) {
499             // The delay for the digit, in terms of fraction (i.e. the digit should not move
500             // during 0.0 - 0.1).
501             val digitInitialDelay =
502                     if (isMovingToCenter) {
503                         moveToCenterDelays[i] * MOVE_DIGIT_STEP
504                     } else {
505                         moveToSideDelays[i] * MOVE_DIGIT_STEP
506                     }
507             val digitFraction =
508                     MOVE_INTERPOLATOR.getInterpolation(
509                             constrainedMap(
510                                     0.0f,
511                                     1.0f,
512                                     digitInitialDelay,
513                                     digitInitialDelay + AVAILABLE_ANIMATION_TIME,
514                                     moveFraction
515                             )
516                     )
517             val moveAmountForDigit = currentMoveAmount * digitFraction
518             val moveAmountDeltaForDigit = moveAmountForDigit - currentMoveAmount
519             glyphOffsets[i] = digitOffsetDirection * moveAmountDeltaForDigit
520         }
521         invalidate()
522     }
523 
524     // DateFormat.getBestDateTimePattern is extremely expensive, and refresh is called often.
525     // This is an optimization to ensure we only recompute the patterns when the inputs change.
526     private object Patterns {
527         var sClockView12: String? = null
528         var sClockView24: String? = null
529         var sCacheKey: String? = null
530 
531         fun update(context: Context) {
532             val locale = Locale.getDefault()
533             val res = context.resources
534             val clockView12Skel = res.getString(R.string.clock_12hr_format)
535             val clockView24Skel = res.getString(R.string.clock_24hr_format)
536             val key = locale.toString() + clockView12Skel + clockView24Skel
537             if (key == sCacheKey) return
538 
539             val clockView12 = DateFormat.getBestDateTimePattern(locale, clockView12Skel)
540             sClockView12 = clockView12
541 
542             // CLDR insists on adding an AM/PM indicator even though it wasn't in the skeleton
543             // format.  The following code removes the AM/PM indicator if we didn't want it.
544             if (!clockView12Skel.contains("a")) {
545                 sClockView12 = clockView12.replace("a".toRegex(), "").trim { it <= ' ' }
546             }
547 
548             sClockView24 = DateFormat.getBestDateTimePattern(locale, clockView24Skel)
549             sCacheKey = key
550         }
551     }
552 
553     companion object {
554         private val TAG = AnimatableClockView::class.simpleName!!
555         const val ANIMATION_DURATION_FOLD_TO_AOD: Int = 600
556         private const val DOUBLE_LINE_FORMAT_12_HOUR = "hh\nmm"
557         private const val DOUBLE_LINE_FORMAT_24_HOUR = "HH\nmm"
558         private const val DOZE_ANIM_DURATION: Long = 300
559         private const val APPEAR_ANIM_DURATION: Long = 833
560         private const val CHARGE_ANIM_DURATION_PHASE_0: Long = 500
561         private const val CHARGE_ANIM_DURATION_PHASE_1: Long = 1000
562         private const val COLOR_ANIM_DURATION: Long = 400
563         private const val NUM_CLOCK_FONT_ANIMATION_STEPS = 30
564 
565         // Constants for the animation
566         private val MOVE_INTERPOLATOR = Interpolators.EMPHASIZED
567 
568         // Calculate the positions of all of the digits...
569         // Offset each digit by, say, 0.1
570         // This means that each digit needs to move over a slice of "fractions", i.e. digit 0 should
571         // move from 0.0 - 0.7, digit 1 from 0.1 - 0.8, digit 2 from 0.2 - 0.9, and digit 3
572         // from 0.3 - 1.0.
573         private const val NUM_DIGITS = 4
574         private const val DIGITS_PER_LINE = 2
575 
576         // Delays. Each digit's animation should have a slight delay, so we get a nice
577         // "stepping" effect. When moving right, the second digit of the hour should move first.
578         // When moving left, the first digit of the hour should move first. The lists encode
579         // the delay for each digit (hour[0], hour[1], minute[0], minute[1]), to be multiplied
580         // by delayMultiplier.
581         private val MOVE_LEFT_DELAYS = listOf(0, 1, 2, 3)
582         private val MOVE_RIGHT_DELAYS = listOf(1, 0, 3, 2)
583 
584         // How much delay to apply to each subsequent digit. This is measured in terms of "fraction"
585         // (i.e. a value of 0.1 would cause a digit to wait until fraction had hit 0.1, or 0.2 etc
586         // before moving).
587         //
588         // The current specs dictate that each digit should have a 33ms gap between them. The
589         // overall time is 1s right now.
590         private const val MOVE_DIGIT_STEP = 0.033f
591 
592         // Total available transition time for each digit, taking into account the step. If step is
593         // 0.1, then digit 0 would animate over 0.0 - 0.7, making availableTime 0.7.
594         private const val AVAILABLE_ANIMATION_TIME = 1.0f - MOVE_DIGIT_STEP * (NUM_DIGITS - 1)
595     }
596 }
597