1/*
2 * Copyright (C) 2014 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
17package com.android.keyguard;
18
19import android.animation.Animator;
20import android.animation.AnimatorListenerAdapter;
21import android.animation.AnimatorSet;
22import android.animation.ValueAnimator;
23import android.content.Context;
24import android.content.res.TypedArray;
25import android.graphics.Canvas;
26import android.graphics.Paint;
27import android.graphics.Rect;
28import android.graphics.Typeface;
29import android.os.PowerManager;
30import android.os.SystemClock;
31import android.os.UserHandle;
32import android.provider.Settings;
33import android.text.InputType;
34import android.text.TextUtils;
35import android.util.AttributeSet;
36import android.view.View;
37import android.view.accessibility.AccessibilityEvent;
38import android.view.accessibility.AccessibilityManager;
39import android.view.accessibility.AccessibilityNodeInfo;
40import android.view.animation.AnimationUtils;
41import android.view.animation.Interpolator;
42
43import java.util.ArrayList;
44import java.util.Stack;
45
46/**
47 * A View similar to a textView which contains password text and can animate when the text is
48 * changed
49 */
50public class PasswordTextView extends View {
51
52    private static final float DOT_OVERSHOOT_FACTOR = 1.5f;
53    private static final long DOT_APPEAR_DURATION_OVERSHOOT = 320;
54    private static final long APPEAR_DURATION = 160;
55    private static final long DISAPPEAR_DURATION = 160;
56    private static final long RESET_DELAY_PER_ELEMENT = 40;
57    private static final long RESET_MAX_DELAY = 200;
58
59    /**
60     * The overlap between the text disappearing and the dot appearing animation
61     */
62    private static final long DOT_APPEAR_TEXT_DISAPPEAR_OVERLAP_DURATION = 130;
63
64    /**
65     * The duration the text needs to stay there at least before it can morph into a dot
66     */
67    private static final long TEXT_REST_DURATION_AFTER_APPEAR = 100;
68
69    /**
70     * The duration the text should be visible, starting with the appear animation
71     */
72    private static final long TEXT_VISIBILITY_DURATION = 1300;
73
74    /**
75     * The position in time from [0,1] where the overshoot should be finished and the settle back
76     * animation of the dot should start
77     */
78    private static final float OVERSHOOT_TIME_POSITION = 0.5f;
79
80    /**
81     * The raw text size, will be multiplied by the scaled density when drawn
82     */
83    private final int mTextHeightRaw;
84    private ArrayList<CharState> mTextChars = new ArrayList<>();
85    private String mText = "";
86    private Stack<CharState> mCharPool = new Stack<>();
87    private int mDotSize;
88    private PowerManager mPM;
89    private int mCharPadding;
90    private final Paint mDrawPaint = new Paint();
91    private Interpolator mAppearInterpolator;
92    private Interpolator mDisappearInterpolator;
93    private Interpolator mFastOutSlowInInterpolator;
94    private boolean mShowPassword;
95
96    public PasswordTextView(Context context) {
97        this(context, null);
98    }
99
100    public PasswordTextView(Context context, AttributeSet attrs) {
101        this(context, attrs, 0);
102    }
103
104    public PasswordTextView(Context context, AttributeSet attrs, int defStyleAttr) {
105        this(context, attrs, defStyleAttr, 0);
106    }
107
108    public PasswordTextView(Context context, AttributeSet attrs, int defStyleAttr,
109            int defStyleRes) {
110        super(context, attrs, defStyleAttr, defStyleRes);
111        setFocusableInTouchMode(true);
112        setFocusable(true);
113        TypedArray a = context.obtainStyledAttributes(attrs, R.styleable.PasswordTextView);
114        try {
115            mTextHeightRaw = a.getInt(R.styleable.PasswordTextView_scaledTextSize, 0);
116        } finally {
117            a.recycle();
118        }
119        mDrawPaint.setFlags(Paint.SUBPIXEL_TEXT_FLAG | Paint.ANTI_ALIAS_FLAG);
120        mDrawPaint.setTextAlign(Paint.Align.CENTER);
121        mDrawPaint.setColor(0xffffffff);
122        mDrawPaint.setTypeface(Typeface.create("sans-serif-light", 0));
123        mDotSize = getContext().getResources().getDimensionPixelSize(R.dimen.password_dot_size);
124        mCharPadding = getContext().getResources().getDimensionPixelSize(R.dimen
125                .password_char_padding);
126        mShowPassword = Settings.System.getInt(mContext.getContentResolver(),
127                Settings.System.TEXT_SHOW_PASSWORD, 1) == 1;
128        mAppearInterpolator = AnimationUtils.loadInterpolator(mContext,
129                android.R.interpolator.linear_out_slow_in);
130        mDisappearInterpolator = AnimationUtils.loadInterpolator(mContext,
131                android.R.interpolator.fast_out_linear_in);
132        mFastOutSlowInInterpolator = AnimationUtils.loadInterpolator(mContext,
133                android.R.interpolator.fast_out_slow_in);
134        mPM = (PowerManager) mContext.getSystemService(Context.POWER_SERVICE);
135    }
136
137    @Override
138    protected void onDraw(Canvas canvas) {
139        float totalDrawingWidth = getDrawingWidth();
140        float currentDrawPosition = getWidth() / 2 - totalDrawingWidth / 2;
141        int length = mTextChars.size();
142        Rect bounds = getCharBounds();
143        int charHeight = (bounds.bottom - bounds.top);
144        float yPosition = getHeight() / 2;
145        float charLength = bounds.right - bounds.left;
146        for (int i = 0; i < length; i++) {
147            CharState charState = mTextChars.get(i);
148            float charWidth = charState.draw(canvas, currentDrawPosition, charHeight, yPosition,
149                    charLength);
150            currentDrawPosition += charWidth;
151        }
152    }
153
154    @Override
155    public boolean hasOverlappingRendering() {
156        return false;
157    }
158
159    private Rect getCharBounds() {
160        float textHeight = mTextHeightRaw * getResources().getDisplayMetrics().scaledDensity;
161        mDrawPaint.setTextSize(textHeight);
162        Rect bounds = new Rect();
163        mDrawPaint.getTextBounds("0", 0, 1, bounds);
164        return bounds;
165    }
166
167    private float getDrawingWidth() {
168        int width = 0;
169        int length = mTextChars.size();
170        Rect bounds = getCharBounds();
171        int charLength = bounds.right - bounds.left;
172        for (int i = 0; i < length; i++) {
173            CharState charState = mTextChars.get(i);
174            if (i != 0) {
175                width += mCharPadding * charState.currentWidthFactor;
176            }
177            width += charLength * charState.currentWidthFactor;
178        }
179        return width;
180    }
181
182
183    public void append(char c) {
184        int visibleChars = mTextChars.size();
185        String textbefore = mText;
186        mText = mText + c;
187        int newLength = mText.length();
188        CharState charState;
189        if (newLength > visibleChars) {
190            charState = obtainCharState(c);
191            mTextChars.add(charState);
192        } else {
193            charState = mTextChars.get(newLength - 1);
194            charState.whichChar = c;
195        }
196        charState.startAppearAnimation();
197
198        // ensure that the previous element is being swapped
199        if (newLength > 1) {
200            CharState previousState = mTextChars.get(newLength - 2);
201            if (previousState.isDotSwapPending) {
202                previousState.swapToDotWhenAppearFinished();
203            }
204        }
205        userActivity();
206        sendAccessibilityEventTypeViewTextChanged(textbefore, textbefore.length(), 0, 1);
207    }
208
209    private void userActivity() {
210        mPM.userActivity(SystemClock.uptimeMillis(), false);
211    }
212
213    public void deleteLastChar() {
214        int length = mText.length();
215        String textbefore = mText;
216        if (length > 0) {
217            mText = mText.substring(0, length - 1);
218            CharState charState = mTextChars.get(length - 1);
219            charState.startRemoveAnimation(0, 0);
220        }
221        userActivity();
222        sendAccessibilityEventTypeViewTextChanged(textbefore, textbefore.length() - 1, 1, 0);
223    }
224
225    public String getText() {
226        return mText;
227    }
228
229    private CharState obtainCharState(char c) {
230        CharState charState;
231        if(mCharPool.isEmpty()) {
232            charState = new CharState();
233        } else {
234            charState = mCharPool.pop();
235            charState.reset();
236        }
237        charState.whichChar = c;
238        return charState;
239    }
240
241    public void reset(boolean animated) {
242        String textbefore = mText;
243        mText = "";
244        int length = mTextChars.size();
245        int middleIndex = (length - 1) / 2;
246        long delayPerElement = RESET_DELAY_PER_ELEMENT;
247        for (int i = 0; i < length; i++) {
248            CharState charState = mTextChars.get(i);
249            if (animated) {
250                int delayIndex;
251                if (i <= middleIndex) {
252                    delayIndex = i * 2;
253                } else {
254                    int distToMiddle = i - middleIndex;
255                    delayIndex = (length - 1) - (distToMiddle - 1) * 2;
256                }
257                long startDelay = delayIndex * delayPerElement;
258                startDelay = Math.min(startDelay, RESET_MAX_DELAY);
259                long maxDelay = delayPerElement * (length - 1);
260                maxDelay = Math.min(maxDelay, RESET_MAX_DELAY) + DISAPPEAR_DURATION;
261                charState.startRemoveAnimation(startDelay, maxDelay);
262                charState.removeDotSwapCallbacks();
263            } else {
264                mCharPool.push(charState);
265            }
266        }
267        if (!animated) {
268            mTextChars.clear();
269        }
270        sendAccessibilityEventTypeViewTextChanged(textbefore, 0, textbefore.length(), 0);
271    }
272
273    void sendAccessibilityEventTypeViewTextChanged(String beforeText, int fromIndex,
274                                                   int removedCount, int addedCount) {
275        if (AccessibilityManager.getInstance(mContext).isEnabled() &&
276                (isFocused() || isSelected() && isShown())) {
277            if (!shouldSpeakPasswordsForAccessibility()) {
278                beforeText = null;
279            }
280            AccessibilityEvent event =
281                    AccessibilityEvent.obtain(AccessibilityEvent.TYPE_VIEW_TEXT_CHANGED);
282            event.setFromIndex(fromIndex);
283            event.setRemovedCount(removedCount);
284            event.setAddedCount(addedCount);
285            event.setBeforeText(beforeText);
286            event.setPassword(true);
287            sendAccessibilityEventUnchecked(event);
288        }
289    }
290
291    @Override
292    public void onInitializeAccessibilityEvent(AccessibilityEvent event) {
293        super.onInitializeAccessibilityEvent(event);
294
295        event.setClassName(PasswordTextView.class.getName());
296        event.setPassword(true);
297    }
298
299    @Override
300    public void onPopulateAccessibilityEvent(AccessibilityEvent event) {
301        super.onPopulateAccessibilityEvent(event);
302
303        if (shouldSpeakPasswordsForAccessibility()) {
304            final CharSequence text = mText;
305            if (!TextUtils.isEmpty(text)) {
306                event.getText().add(text);
307            }
308        }
309    }
310
311    @Override
312    public void onInitializeAccessibilityNodeInfo(AccessibilityNodeInfo info) {
313        super.onInitializeAccessibilityNodeInfo(info);
314
315        info.setClassName(PasswordTextView.class.getName());
316        info.setPassword(true);
317
318        if (shouldSpeakPasswordsForAccessibility()) {
319            info.setText(mText);
320        }
321
322        info.setEditable(true);
323
324        info.setInputType(InputType.TYPE_NUMBER_VARIATION_PASSWORD);
325    }
326
327    /**
328     * @return true if the user has explicitly allowed accessibility services
329     * to speak passwords.
330     */
331    private boolean shouldSpeakPasswordsForAccessibility() {
332        return (Settings.Secure.getIntForUser(mContext.getContentResolver(),
333                Settings.Secure.ACCESSIBILITY_SPEAK_PASSWORD, 0,
334                UserHandle.USER_CURRENT_OR_SELF) == 1);
335    }
336
337    private class CharState {
338        char whichChar;
339        ValueAnimator textAnimator;
340        boolean textAnimationIsGrowing;
341        Animator dotAnimator;
342        boolean dotAnimationIsGrowing;
343        ValueAnimator widthAnimator;
344        boolean widthAnimationIsGrowing;
345        float currentTextSizeFactor;
346        float currentDotSizeFactor;
347        float currentWidthFactor;
348        boolean isDotSwapPending;
349        float currentTextTranslationY = 1.0f;
350        ValueAnimator textTranslateAnimator;
351
352        Animator.AnimatorListener removeEndListener = new AnimatorListenerAdapter() {
353            private boolean mCancelled;
354            @Override
355            public void onAnimationCancel(Animator animation) {
356                mCancelled = true;
357            }
358
359            @Override
360            public void onAnimationEnd(Animator animation) {
361                if (!mCancelled) {
362                    mTextChars.remove(CharState.this);
363                    mCharPool.push(CharState.this);
364                    reset();
365                    cancelAnimator(textTranslateAnimator);
366                    textTranslateAnimator = null;
367                }
368            }
369
370            @Override
371            public void onAnimationStart(Animator animation) {
372                mCancelled = false;
373            }
374        };
375
376        Animator.AnimatorListener dotFinishListener = new AnimatorListenerAdapter() {
377            @Override
378            public void onAnimationEnd(Animator animation) {
379                dotAnimator = null;
380            }
381        };
382
383        Animator.AnimatorListener textFinishListener = new AnimatorListenerAdapter() {
384            @Override
385            public void onAnimationEnd(Animator animation) {
386                textAnimator = null;
387            }
388        };
389
390        Animator.AnimatorListener textTranslateFinishListener = new AnimatorListenerAdapter() {
391            @Override
392            public void onAnimationEnd(Animator animation) {
393                textTranslateAnimator = null;
394            }
395        };
396
397        Animator.AnimatorListener widthFinishListener = new AnimatorListenerAdapter() {
398            @Override
399            public void onAnimationEnd(Animator animation) {
400                widthAnimator = null;
401            }
402        };
403
404        private ValueAnimator.AnimatorUpdateListener dotSizeUpdater
405                = new ValueAnimator.AnimatorUpdateListener() {
406            @Override
407            public void onAnimationUpdate(ValueAnimator animation) {
408                currentDotSizeFactor = (float) animation.getAnimatedValue();
409                invalidate();
410            }
411        };
412
413        private ValueAnimator.AnimatorUpdateListener textSizeUpdater
414                = new ValueAnimator.AnimatorUpdateListener() {
415            @Override
416            public void onAnimationUpdate(ValueAnimator animation) {
417                currentTextSizeFactor = (float) animation.getAnimatedValue();
418                invalidate();
419            }
420        };
421
422        private ValueAnimator.AnimatorUpdateListener textTranslationUpdater
423                = new ValueAnimator.AnimatorUpdateListener() {
424            @Override
425            public void onAnimationUpdate(ValueAnimator animation) {
426                currentTextTranslationY = (float) animation.getAnimatedValue();
427                invalidate();
428            }
429        };
430
431        private ValueAnimator.AnimatorUpdateListener widthUpdater
432                = new ValueAnimator.AnimatorUpdateListener() {
433            @Override
434            public void onAnimationUpdate(ValueAnimator animation) {
435                currentWidthFactor = (float) animation.getAnimatedValue();
436                invalidate();
437            }
438        };
439
440        private Runnable dotSwapperRunnable = new Runnable() {
441            @Override
442            public void run() {
443                performSwap();
444                isDotSwapPending = false;
445            }
446        };
447
448        void reset() {
449            whichChar = 0;
450            currentTextSizeFactor = 0.0f;
451            currentDotSizeFactor = 0.0f;
452            currentWidthFactor = 0.0f;
453            cancelAnimator(textAnimator);
454            textAnimator = null;
455            cancelAnimator(dotAnimator);
456            dotAnimator = null;
457            cancelAnimator(widthAnimator);
458            widthAnimator = null;
459            currentTextTranslationY = 1.0f;
460            removeDotSwapCallbacks();
461        }
462
463        void startRemoveAnimation(long startDelay, long widthDelay) {
464            boolean dotNeedsAnimation = (currentDotSizeFactor > 0.0f && dotAnimator == null)
465                    || (dotAnimator != null && dotAnimationIsGrowing);
466            boolean textNeedsAnimation = (currentTextSizeFactor > 0.0f && textAnimator == null)
467                    || (textAnimator != null && textAnimationIsGrowing);
468            boolean widthNeedsAnimation = (currentWidthFactor > 0.0f && widthAnimator == null)
469                    || (widthAnimator != null && widthAnimationIsGrowing);
470            if (dotNeedsAnimation) {
471                startDotDisappearAnimation(startDelay);
472            }
473            if (textNeedsAnimation) {
474                startTextDisappearAnimation(startDelay);
475            }
476            if (widthNeedsAnimation) {
477                startWidthDisappearAnimation(widthDelay);
478            }
479        }
480
481        void startAppearAnimation() {
482            boolean dotNeedsAnimation = !mShowPassword
483                    && (dotAnimator == null || !dotAnimationIsGrowing);
484            boolean textNeedsAnimation = mShowPassword
485                    && (textAnimator == null || !textAnimationIsGrowing);
486            boolean widthNeedsAnimation = (widthAnimator == null || !widthAnimationIsGrowing);
487            if (dotNeedsAnimation) {
488                startDotAppearAnimation(0);
489            }
490            if (textNeedsAnimation) {
491                startTextAppearAnimation();
492            }
493            if (widthNeedsAnimation) {
494                startWidthAppearAnimation();
495            }
496            if (mShowPassword) {
497                postDotSwap(TEXT_VISIBILITY_DURATION);
498            }
499        }
500
501        /**
502         * Posts a runnable which ensures that the text will be replaced by a dot after {@link
503         * com.android.keyguard.PasswordTextView#TEXT_VISIBILITY_DURATION}.
504         */
505        private void postDotSwap(long delay) {
506            removeDotSwapCallbacks();
507            postDelayed(dotSwapperRunnable, delay);
508            isDotSwapPending = true;
509        }
510
511        private void removeDotSwapCallbacks() {
512            removeCallbacks(dotSwapperRunnable);
513            isDotSwapPending = false;
514        }
515
516        void swapToDotWhenAppearFinished() {
517            removeDotSwapCallbacks();
518            if (textAnimator != null) {
519                long remainingDuration = textAnimator.getDuration()
520                        - textAnimator.getCurrentPlayTime();
521                postDotSwap(remainingDuration + TEXT_REST_DURATION_AFTER_APPEAR);
522            } else {
523                performSwap();
524            }
525        }
526
527        private void performSwap() {
528            startTextDisappearAnimation(0);
529            startDotAppearAnimation(DISAPPEAR_DURATION
530                    - DOT_APPEAR_TEXT_DISAPPEAR_OVERLAP_DURATION);
531        }
532
533        private void startWidthDisappearAnimation(long widthDelay) {
534            cancelAnimator(widthAnimator);
535            widthAnimator = ValueAnimator.ofFloat(currentWidthFactor, 0.0f);
536            widthAnimator.addUpdateListener(widthUpdater);
537            widthAnimator.addListener(widthFinishListener);
538            widthAnimator.addListener(removeEndListener);
539            widthAnimator.setDuration((long) (DISAPPEAR_DURATION * currentWidthFactor));
540            widthAnimator.setStartDelay(widthDelay);
541            widthAnimator.start();
542            widthAnimationIsGrowing = false;
543        }
544
545        private void startTextDisappearAnimation(long startDelay) {
546            cancelAnimator(textAnimator);
547            textAnimator = ValueAnimator.ofFloat(currentTextSizeFactor, 0.0f);
548            textAnimator.addUpdateListener(textSizeUpdater);
549            textAnimator.addListener(textFinishListener);
550            textAnimator.setInterpolator(mDisappearInterpolator);
551            textAnimator.setDuration((long) (DISAPPEAR_DURATION * currentTextSizeFactor));
552            textAnimator.setStartDelay(startDelay);
553            textAnimator.start();
554            textAnimationIsGrowing = false;
555        }
556
557        private void startDotDisappearAnimation(long startDelay) {
558            cancelAnimator(dotAnimator);
559            ValueAnimator animator = ValueAnimator.ofFloat(currentDotSizeFactor, 0.0f);
560            animator.addUpdateListener(dotSizeUpdater);
561            animator.addListener(dotFinishListener);
562            animator.setInterpolator(mDisappearInterpolator);
563            long duration = (long) (DISAPPEAR_DURATION * Math.min(currentDotSizeFactor, 1.0f));
564            animator.setDuration(duration);
565            animator.setStartDelay(startDelay);
566            animator.start();
567            dotAnimator = animator;
568            dotAnimationIsGrowing = false;
569        }
570
571        private void startWidthAppearAnimation() {
572            cancelAnimator(widthAnimator);
573            widthAnimator = ValueAnimator.ofFloat(currentWidthFactor, 1.0f);
574            widthAnimator.addUpdateListener(widthUpdater);
575            widthAnimator.addListener(widthFinishListener);
576            widthAnimator.setDuration((long) (APPEAR_DURATION * (1f - currentWidthFactor)));
577            widthAnimator.start();
578            widthAnimationIsGrowing = true;
579        }
580
581        private void startTextAppearAnimation() {
582            cancelAnimator(textAnimator);
583            textAnimator = ValueAnimator.ofFloat(currentTextSizeFactor, 1.0f);
584            textAnimator.addUpdateListener(textSizeUpdater);
585            textAnimator.addListener(textFinishListener);
586            textAnimator.setInterpolator(mAppearInterpolator);
587            textAnimator.setDuration((long) (APPEAR_DURATION * (1f - currentTextSizeFactor)));
588            textAnimator.start();
589            textAnimationIsGrowing = true;
590
591            // handle translation
592            if (textTranslateAnimator == null) {
593                textTranslateAnimator = ValueAnimator.ofFloat(1.0f, 0.0f);
594                textTranslateAnimator.addUpdateListener(textTranslationUpdater);
595                textTranslateAnimator.addListener(textTranslateFinishListener);
596                textTranslateAnimator.setInterpolator(mAppearInterpolator);
597                textTranslateAnimator.setDuration(APPEAR_DURATION);
598                textTranslateAnimator.start();
599            }
600        }
601
602        private void startDotAppearAnimation(long delay) {
603            cancelAnimator(dotAnimator);
604            if (!mShowPassword) {
605                // We perform an overshoot animation
606                ValueAnimator overShootAnimator = ValueAnimator.ofFloat(currentDotSizeFactor,
607                        DOT_OVERSHOOT_FACTOR);
608                overShootAnimator.addUpdateListener(dotSizeUpdater);
609                overShootAnimator.setInterpolator(mAppearInterpolator);
610                long overShootDuration = (long) (DOT_APPEAR_DURATION_OVERSHOOT
611                        * OVERSHOOT_TIME_POSITION);
612                overShootAnimator.setDuration(overShootDuration);
613                ValueAnimator settleBackAnimator = ValueAnimator.ofFloat(DOT_OVERSHOOT_FACTOR,
614                        1.0f);
615                settleBackAnimator.addUpdateListener(dotSizeUpdater);
616                settleBackAnimator.setDuration(DOT_APPEAR_DURATION_OVERSHOOT - overShootDuration);
617                settleBackAnimator.addListener(dotFinishListener);
618                AnimatorSet animatorSet = new AnimatorSet();
619                animatorSet.playSequentially(overShootAnimator, settleBackAnimator);
620                animatorSet.setStartDelay(delay);
621                animatorSet.start();
622                dotAnimator = animatorSet;
623            } else {
624                ValueAnimator growAnimator = ValueAnimator.ofFloat(currentDotSizeFactor, 1.0f);
625                growAnimator.addUpdateListener(dotSizeUpdater);
626                growAnimator.setDuration((long) (APPEAR_DURATION * (1.0f - currentDotSizeFactor)));
627                growAnimator.addListener(dotFinishListener);
628                growAnimator.setStartDelay(delay);
629                growAnimator.start();
630                dotAnimator = growAnimator;
631            }
632            dotAnimationIsGrowing = true;
633        }
634
635        private void cancelAnimator(Animator animator) {
636            if (animator != null) {
637                animator.cancel();
638            }
639        }
640
641        /**
642         * Draw this char to the canvas.
643         *
644         * @return The width this character contributes, including padding.
645         */
646        public float draw(Canvas canvas, float currentDrawPosition, int charHeight, float yPosition,
647                float charLength) {
648            boolean textVisible = currentTextSizeFactor > 0;
649            boolean dotVisible = currentDotSizeFactor > 0;
650            float charWidth = charLength * currentWidthFactor;
651            if (textVisible) {
652                float currYPosition = yPosition + charHeight / 2.0f * currentTextSizeFactor
653                        + charHeight * currentTextTranslationY * 0.8f;
654                canvas.save();
655                float centerX = currentDrawPosition + charWidth / 2;
656                canvas.translate(centerX, currYPosition);
657                canvas.scale(currentTextSizeFactor, currentTextSizeFactor);
658                canvas.drawText(Character.toString(whichChar), 0, 0, mDrawPaint);
659                canvas.restore();
660            }
661            if (dotVisible) {
662                canvas.save();
663                float centerX = currentDrawPosition + charWidth / 2;
664                canvas.translate(centerX, yPosition);
665                canvas.drawCircle(0, 0, mDotSize / 2 * currentDotSizeFactor, mDrawPaint);
666                canvas.restore();
667            }
668            return charWidth + mCharPadding * currentWidthFactor;
669        }
670    }
671}
672