1 /*
2  * Copyright (C) 2020 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 
17 package com.android.emergency.widgets.countdown;
18 
19 
20 import android.animation.TimeInterpolator;
21 import android.content.Context;
22 import android.graphics.Canvas;
23 import android.graphics.Color;
24 import android.graphics.Paint;
25 import android.graphics.PorterDuff;
26 import android.graphics.RectF;
27 import android.view.SurfaceHolder;
28 import android.view.animation.DecelerateInterpolator;
29 
30 import androidx.annotation.GuardedBy;
31 
32 import com.android.emergency.action.R;
33 
34 import java.time.Duration;
35 import java.time.Instant;
36 
37 /**
38  * Thread that handles the looping "pending" animation with optional count down text.
39  */
40 final class LoopingAnimationThread extends Thread {
41 
42     private final SurfaceHolder mSurfaceHolder;
43     private final Context mContext;
44     private final Paint mBackgroundPaint;
45     private final Paint mLoopPaint;
46     private final Paint mLoopHeadPaint;
47     private final TimeInterpolator mDecelerateInterpolator = new DecelerateInterpolator();
48     private final Duration mLoopInterval;
49     private final Duration mLoopTrailDelay;
50 
51     private volatile boolean mIsDrawing = false;
52 
53     @GuardedBy("this")
54     private RectF mLoopBounds;
55 
56     @GuardedBy("this")
57     private float mLoopRadius;
58 
59     @GuardedBy("this")
60     private float mLoopHeadRadius;
61 
62     @GuardedBy("this")
63     private final CountDownRenderer mCountDownRenderer;
64 
65     @GuardedBy("this")
66     private int mTotalDiameter;
67 
68     /**
69      * Constructor for looping animation thread.
70      *
71      * @param surfaceHolder Surface holder from surface view.
72      * @param context       The context from the view.
73      */
LoopingAnimationThread( SurfaceHolder surfaceHolder, Context context)74     LoopingAnimationThread(
75             SurfaceHolder surfaceHolder,
76             Context context) {
77         super(LoopingAnimationThread.class.getSimpleName());
78         this.mSurfaceHolder = surfaceHolder;
79         mContext = context;
80         mBackgroundPaint = new Paint();
81         mBackgroundPaint.setColor(Color.BLACK);
82         mBackgroundPaint.setStyle(Paint.Style.FILL);
83         mBackgroundPaint.setAntiAlias(true);
84         mLoopPaint = new Paint();
85         mLoopPaint.setColor(Color.WHITE);
86         mLoopPaint.setStyle(Paint.Style.STROKE);
87         mLoopPaint.setAntiAlias(true);
88         mLoopHeadPaint = new Paint();
89         mLoopHeadPaint.setColor(Color.WHITE);
90         mLoopHeadPaint.setStyle(Paint.Style.FILL);
91         mLoopHeadPaint.setAntiAlias(true);
92 
93         mCountDownRenderer = new CountDownRenderer(context);
94 
95         mLoopInterval = Duration.ofMillis(
96                 mContext.getResources().getInteger(R.integer.count_down_view_loop_interval_millis));
97         mLoopTrailDelay = Duration.ofMillis(
98                 mContext.getResources().getInteger(R.integer.count_down_view_loop_delay_millis));
99     }
100 
101     @Override
run()102     public void run() {
103         mIsDrawing = true;
104         Instant now = Instant.ofEpochMilli(System.currentTimeMillis());
105         updateSize(
106                 mSurfaceHolder.getSurfaceFrame().width(),
107                 mSurfaceHolder.getSurfaceFrame().height());
108         while (mIsDrawing) {
109             if (isInterrupted()) {
110                 mIsDrawing = false;
111                 return;
112             }
113             Canvas canvas = null;
114             try {
115                 synchronized (mSurfaceHolder) {
116                     canvas = mSurfaceHolder.lockCanvas(null);
117                     draw(canvas, now);
118                 }
119             } finally {
120                 // Make sure we don't leave the Surface in an inconsistent state.
121                 if (canvas != null) {
122                     mSurfaceHolder.unlockCanvasAndPost(canvas);
123                 }
124             }
125         }
126     }
127 
128     /**
129      * Reveals the count down if animation started, otherwise count down will show when animation
130      * starts.
131      */
showCountDown()132     synchronized void showCountDown() {
133         mCountDownRenderer.show();
134     }
135 
136     /** Sets the count down left duration to be drawn. */
setCountDownLeft(Duration timeLeft)137     synchronized void setCountDownLeft(Duration timeLeft) {
138         if (timeLeft.isNegative() || timeLeft.isZero()) {
139             mCountDownRenderer.setCountDownLeft(Duration.ZERO);
140         } else {
141             mCountDownRenderer.setCountDownLeft(timeLeft);
142         }
143     }
144 
145     /**
146      * Draw frame.
147      *
148      * @param canvas    Canvas to draw on.
149      * @param startTime start time of animation.
150      */
draw(Canvas canvas, Instant startTime)151     private synchronized void draw(Canvas canvas, Instant startTime) {
152         if (!mIsDrawing) {
153             // It is possible to lose the canvas because surface got destroyed here.
154             return;
155         }
156         // Clear background.
157         canvas.drawColor(0, PorterDuff.Mode.CLEAR);
158         // Draw background.
159         canvas.drawCircle(mLoopBounds.centerX(), mLoopBounds.centerY(), mLoopRadius,
160                 mBackgroundPaint);
161 
162         // Calculate each end of the loop's elapsed time for calculating what arc to draw.
163 
164         long progressedMillis =
165                 Duration.between(
166                         startTime, Instant.ofEpochMilli(System.currentTimeMillis())).toMillis();
167         long loopHeadT = progressedMillis % mLoopInterval.toMillis();
168         long loopTailT = loopHeadT - mLoopTrailDelay.toMillis();
169 
170         // 0 means track start, 1 means track end.
171 
172         Duration loopTrackInterval = mLoopInterval.minus(mLoopTrailDelay);
173         float loopTailTrackRatio =
174                 loopTailT <= 0f ? 0f : loopTailT / (float) loopTrackInterval.toMillis();
175         float loopHeadTrackRatio =
176                 loopHeadT <= loopTrackInterval.toMillis()
177                         ? loopHeadT / (float) loopTrackInterval.toMillis()
178                         : 1f;
179         // Interpolate and convert track completion ratio to degrees. From a clockwise perspective,
180         // tail is starting angle and head is the ending angle so we intentionally swap terminology
181         // here.
182         float interpolatedSweepAngleStart =
183                 mDecelerateInterpolator.getInterpolation(loopTailTrackRatio) * 360f;
184         float interpolatedSweepAngleEnd = mDecelerateInterpolator.getInterpolation(
185                 loopHeadTrackRatio) * 360f;
186         float finalSweepAngleStart = interpolatedSweepAngleStart - 90f;
187         float finalSweepAngleEnd = interpolatedSweepAngleEnd - 90f;
188         canvas.drawArc(
189                 mLoopBounds,
190                 finalSweepAngleStart,
191                 finalSweepAngleEnd - finalSweepAngleStart,
192                 false,
193                 mLoopPaint);
194         float leadingDotX =
195                 (float) Math.cos(Math.toRadians(finalSweepAngleEnd)) * mLoopRadius
196                         + mLoopBounds.centerX();
197         float leadingDotY =
198                 (float) Math.sin(Math.toRadians(finalSweepAngleEnd)) * mLoopRadius
199                         + mLoopBounds.centerY();
200         canvas.drawCircle(leadingDotX, leadingDotY, mLoopHeadRadius, mLoopHeadPaint);
201         if (mCountDownRenderer.isRevealed()) {
202             mCountDownRenderer.draw(canvas);
203         }
204     }
205 
206     /** Update size of loop based on new width and height. */
updateSize(int w, int h)207     synchronized void updateSize(int w, int h) {
208         float lookTrackDiameterToBoundsRatio =
209                 mContext.getResources().getFloat(
210                         R.dimen.count_down_view_loop_track_diameter_to_bounds_ratio);
211         float lookHeadDiameterToBoundsRatio =
212                 mContext.getResources().getFloat(
213                         R.dimen.count_down_view_loop_head_diameter_to_bounds_ratio);
214         float lookStrokeWidthToBoundsRatio =
215                 mContext.getResources().getFloat(
216                         R.dimen.count_down_view_loop_stoke_width_to_bounds_ratio);
217 
218         mTotalDiameter = Math.min(w, h);
219         // Use ratios to calculate loop/track radius.
220         mLoopRadius = mTotalDiameter * lookTrackDiameterToBoundsRatio * 0.5f;
221         // Use ratios to calculate loop head radius.
222         mLoopHeadRadius = mTotalDiameter * lookHeadDiameterToBoundsRatio * 0.5f;
223         // Use ratios to calculate loop stroke width.
224         mLoopPaint.setStrokeWidth(mTotalDiameter * lookStrokeWidthToBoundsRatio);
225         mLoopBounds = new RectF(0, 0, mLoopRadius * 2, mLoopRadius * 2);
226         float updatedSizeCenterOffsetX = (w - (mLoopRadius + mLoopHeadRadius) * 2) * 0.5f;
227         float updatedSizeCenterOffsetY = (h - (mLoopRadius + mLoopHeadRadius) * 2) * 0.5f;
228         mLoopBounds.offset(
229                 updatedSizeCenterOffsetX + mLoopHeadRadius,
230                 updatedSizeCenterOffsetY + mLoopHeadRadius);
231         // Update bounds for count down text.
232         mCountDownRenderer.updateBounds(mLoopBounds, mTotalDiameter);
233     }
234 
235     /** Stop animation from drawing. */
stopDrawing()236     void stopDrawing() {
237         mIsDrawing = false;
238     }
239 }
240