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 
17 package com.example.android.wearable.watchface;
18 
19 import com.google.android.gms.common.ConnectionResult;
20 import com.google.android.gms.common.api.GoogleApiClient;
21 import com.google.android.gms.common.api.PendingResult;
22 import com.google.android.gms.common.api.ResultCallback;
23 import com.google.android.gms.common.api.Status;
24 import com.google.android.gms.fitness.Fitness;
25 import com.google.android.gms.fitness.FitnessStatusCodes;
26 import com.google.android.gms.fitness.data.DataPoint;
27 import com.google.android.gms.fitness.data.DataType;
28 import com.google.android.gms.fitness.data.Field;
29 import com.google.android.gms.fitness.result.DailyTotalResult;
30 
31 import android.content.BroadcastReceiver;
32 import android.content.Context;
33 import android.content.Intent;
34 import android.content.IntentFilter;
35 import android.content.res.Resources;
36 import android.graphics.Canvas;
37 import android.graphics.Color;
38 import android.graphics.Paint;
39 import android.graphics.Rect;
40 import android.graphics.Typeface;
41 import android.os.Bundle;
42 import android.os.Handler;
43 import android.os.Message;
44 import android.support.wearable.watchface.CanvasWatchFaceService;
45 import android.support.wearable.watchface.WatchFaceStyle;
46 import android.text.format.DateFormat;
47 import android.util.Log;
48 import android.view.SurfaceHolder;
49 import android.view.WindowInsets;
50 
51 import java.util.Calendar;
52 import java.util.List;
53 import java.util.TimeZone;
54 import java.util.concurrent.TimeUnit;
55 
56 /**
57  * The step count watch face shows user's daily step total via Google Fit (matches Google Fit app).
58  * Steps are polled initially when the Google API Client successfully connects and once a minute
59  * after that via the onTimeTick callback. If you want more frequent updates, you will want to add
60  * your own  Handler.
61  *
62  * Authentication is not a requirement to request steps from Google Fit on Wear.
63  *
64  * In ambient mode, the seconds are replaced with an AM/PM indicator.
65  *
66  * On devices with low-bit ambient mode, the text is drawn without anti-aliasing. On devices which
67  * require burn-in protection, the hours are drawn in normal rather than bold.
68  *
69  */
70 public class FitStepsWatchFaceService extends CanvasWatchFaceService {
71 
72     private static final String TAG = "StepCountWatchFace";
73 
74     private static final Typeface BOLD_TYPEFACE =
75             Typeface.create(Typeface.SANS_SERIF, Typeface.BOLD);
76     private static final Typeface NORMAL_TYPEFACE =
77             Typeface.create(Typeface.SANS_SERIF, Typeface.NORMAL);
78 
79     /**
80      * Update rate in milliseconds for active mode (non-ambient).
81      */
82     private static final long ACTIVE_INTERVAL_MS = TimeUnit.SECONDS.toMillis(1);
83 
84     @Override
onCreateEngine()85     public Engine onCreateEngine() {
86         return new Engine();
87     }
88 
89     private class Engine extends CanvasWatchFaceService.Engine implements
90             GoogleApiClient.ConnectionCallbacks,
91             GoogleApiClient.OnConnectionFailedListener,
92             ResultCallback<DailyTotalResult> {
93 
94         private static final int BACKGROUND_COLOR = Color.BLACK;
95         private static final int TEXT_HOURS_MINS_COLOR = Color.WHITE;
96         private static final int TEXT_SECONDS_COLOR = Color.GRAY;
97         private static final int TEXT_AM_PM_COLOR = Color.GRAY;
98         private static final int TEXT_COLON_COLOR = Color.GRAY;
99         private static final int TEXT_STEP_COUNT_COLOR = Color.GRAY;
100 
101         private static final String COLON_STRING = ":";
102 
103         private static final int MSG_UPDATE_TIME = 0;
104 
105         /* Handler to update the time periodically in interactive mode. */
106         private final Handler mUpdateTimeHandler = new Handler() {
107             @Override
108             public void handleMessage(Message message) {
109                 switch (message.what) {
110                     case MSG_UPDATE_TIME:
111                         if (Log.isLoggable(TAG, Log.VERBOSE)) {
112                             Log.v(TAG, "updating time");
113                         }
114                         invalidate();
115                         if (shouldUpdateTimeHandlerBeRunning()) {
116                             long timeMs = System.currentTimeMillis();
117                             long delayMs =
118                                     ACTIVE_INTERVAL_MS - (timeMs % ACTIVE_INTERVAL_MS);
119                             mUpdateTimeHandler.sendEmptyMessageDelayed(MSG_UPDATE_TIME, delayMs);
120                         }
121                         break;
122                 }
123             }
124         };
125 
126         /**
127          * Handles time zone and locale changes.
128          */
129         private final BroadcastReceiver mReceiver = new BroadcastReceiver() {
130             @Override
131             public void onReceive(Context context, Intent intent) {
132                 mCalendar.setTimeZone(TimeZone.getDefault());
133                 invalidate();
134             }
135         };
136 
137         /**
138          * Unregistering an unregistered receiver throws an exception. Keep track of the
139          * registration state to prevent that.
140          */
141         private boolean mRegisteredReceiver = false;
142 
143         private Paint mHourPaint;
144         private Paint mMinutePaint;
145         private Paint mSecondPaint;
146         private Paint mAmPmPaint;
147         private Paint mColonPaint;
148         private Paint mStepCountPaint;
149 
150         private float mColonWidth;
151 
152         private Calendar mCalendar;
153 
154         private float mXOffset;
155         private float mXStepsOffset;
156         private float mYOffset;
157         private float mLineHeight;
158 
159         private String mAmString;
160         private String mPmString;
161 
162 
163         /**
164          * Whether the display supports fewer bits for each color in ambient mode. When true, we
165          * disable anti-aliasing in ambient mode.
166          */
167         private boolean mLowBitAmbient;
168 
169         /*
170          * Google API Client used to make Google Fit requests for step data.
171          */
172         private GoogleApiClient mGoogleApiClient;
173 
174         private boolean mStepsRequested;
175 
176         private int mStepsTotal = 0;
177 
178         @Override
onCreate(SurfaceHolder holder)179         public void onCreate(SurfaceHolder holder) {
180             if (Log.isLoggable(TAG, Log.DEBUG)) {
181                 Log.d(TAG, "onCreate");
182             }
183 
184             super.onCreate(holder);
185 
186             mStepsRequested = false;
187             mGoogleApiClient = new GoogleApiClient.Builder(FitStepsWatchFaceService.this)
188                     .addConnectionCallbacks(this)
189                     .addOnConnectionFailedListener(this)
190                     .addApi(Fitness.HISTORY_API)
191                     .addApi(Fitness.RECORDING_API)
192                     // When user has multiple accounts, useDefaultAccount() allows Google Fit to
193                     // associated with the main account for steps. It also replaces the need for
194                     // a scope request.
195                     .useDefaultAccount()
196                     .build();
197 
198             setWatchFaceStyle(new WatchFaceStyle.Builder(FitStepsWatchFaceService.this)
199                     .setCardPeekMode(WatchFaceStyle.PEEK_MODE_VARIABLE)
200                     .setBackgroundVisibility(WatchFaceStyle.BACKGROUND_VISIBILITY_INTERRUPTIVE)
201                     .setShowSystemUiTime(false)
202                     .build());
203 
204             Resources resources = getResources();
205 
206             mYOffset = resources.getDimension(R.dimen.fit_y_offset);
207             mLineHeight = resources.getDimension(R.dimen.fit_line_height);
208             mAmString = resources.getString(R.string.fit_am);
209             mPmString = resources.getString(R.string.fit_pm);
210 
211             mHourPaint = createTextPaint(TEXT_HOURS_MINS_COLOR, BOLD_TYPEFACE);
212             mMinutePaint = createTextPaint(TEXT_HOURS_MINS_COLOR);
213             mSecondPaint = createTextPaint(TEXT_SECONDS_COLOR);
214             mAmPmPaint = createTextPaint(TEXT_AM_PM_COLOR);
215             mColonPaint = createTextPaint(TEXT_COLON_COLOR);
216             mStepCountPaint = createTextPaint(TEXT_STEP_COUNT_COLOR);
217 
218             mCalendar = Calendar.getInstance();
219 
220         }
221 
222         @Override
onDestroy()223         public void onDestroy() {
224             mUpdateTimeHandler.removeMessages(MSG_UPDATE_TIME);
225             super.onDestroy();
226         }
227 
createTextPaint(int color)228         private Paint createTextPaint(int color) {
229             return createTextPaint(color, NORMAL_TYPEFACE);
230         }
231 
createTextPaint(int color, Typeface typeface)232         private Paint createTextPaint(int color, Typeface typeface) {
233             Paint paint = new Paint();
234             paint.setColor(color);
235             paint.setTypeface(typeface);
236             paint.setAntiAlias(true);
237             return paint;
238         }
239 
240         @Override
onVisibilityChanged(boolean visible)241         public void onVisibilityChanged(boolean visible) {
242             if (Log.isLoggable(TAG, Log.DEBUG)) {
243                 Log.d(TAG, "onVisibilityChanged: " + visible);
244             }
245             super.onVisibilityChanged(visible);
246 
247             if (visible) {
248                 mGoogleApiClient.connect();
249 
250                 registerReceiver();
251 
252                 // Update time zone and date formats, in case they changed while we weren't visible.
253                 mCalendar.setTimeZone(TimeZone.getDefault());
254             } else {
255                 unregisterReceiver();
256 
257                 if (mGoogleApiClient != null && mGoogleApiClient.isConnected()) {
258                     mGoogleApiClient.disconnect();
259                 }
260             }
261 
262             // Whether the timer should be running depends on whether we're visible (as well as
263             // whether we're in ambient mode), so we may need to start or stop the timer.
264             updateTimer();
265         }
266 
267 
registerReceiver()268         private void registerReceiver() {
269             if (mRegisteredReceiver) {
270                 return;
271             }
272             mRegisteredReceiver = true;
273             IntentFilter filter = new IntentFilter(Intent.ACTION_TIMEZONE_CHANGED);
274             FitStepsWatchFaceService.this.registerReceiver(mReceiver, filter);
275         }
276 
unregisterReceiver()277         private void unregisterReceiver() {
278             if (!mRegisteredReceiver) {
279                 return;
280             }
281             mRegisteredReceiver = false;
282             FitStepsWatchFaceService.this.unregisterReceiver(mReceiver);
283         }
284 
285         @Override
onApplyWindowInsets(WindowInsets insets)286         public void onApplyWindowInsets(WindowInsets insets) {
287             if (Log.isLoggable(TAG, Log.DEBUG)) {
288                 Log.d(TAG, "onApplyWindowInsets: " + (insets.isRound() ? "round" : "square"));
289             }
290             super.onApplyWindowInsets(insets);
291 
292             // Load resources that have alternate values for round watches.
293             Resources resources = FitStepsWatchFaceService.this.getResources();
294             boolean isRound = insets.isRound();
295             mXOffset = resources.getDimension(isRound
296                     ? R.dimen.fit_x_offset_round : R.dimen.fit_x_offset);
297             mXStepsOffset =  resources.getDimension(isRound
298                     ? R.dimen.fit_steps_or_distance_x_offset_round : R.dimen.fit_steps_or_distance_x_offset);
299             float textSize = resources.getDimension(isRound
300                     ? R.dimen.fit_text_size_round : R.dimen.fit_text_size);
301             float amPmSize = resources.getDimension(isRound
302                     ? R.dimen.fit_am_pm_size_round : R.dimen.fit_am_pm_size);
303 
304             mHourPaint.setTextSize(textSize);
305             mMinutePaint.setTextSize(textSize);
306             mSecondPaint.setTextSize(textSize);
307             mAmPmPaint.setTextSize(amPmSize);
308             mColonPaint.setTextSize(textSize);
309             mStepCountPaint.setTextSize(resources.getDimension(R.dimen.fit_steps_or_distance_text_size));
310 
311             mColonWidth = mColonPaint.measureText(COLON_STRING);
312         }
313 
314         @Override
onPropertiesChanged(Bundle properties)315         public void onPropertiesChanged(Bundle properties) {
316             super.onPropertiesChanged(properties);
317 
318             boolean burnInProtection = properties.getBoolean(PROPERTY_BURN_IN_PROTECTION, false);
319             mHourPaint.setTypeface(burnInProtection ? NORMAL_TYPEFACE : BOLD_TYPEFACE);
320 
321             mLowBitAmbient = properties.getBoolean(PROPERTY_LOW_BIT_AMBIENT, false);
322 
323             if (Log.isLoggable(TAG, Log.DEBUG)) {
324                 Log.d(TAG, "onPropertiesChanged: burn-in protection = " + burnInProtection
325                         + ", low-bit ambient = " + mLowBitAmbient);
326             }
327         }
328 
329         @Override
onTimeTick()330         public void onTimeTick() {
331             super.onTimeTick();
332             if (Log.isLoggable(TAG, Log.DEBUG)) {
333                 Log.d(TAG, "onTimeTick: ambient = " + isInAmbientMode());
334             }
335 
336             getTotalSteps();
337             invalidate();
338         }
339 
340         @Override
onAmbientModeChanged(boolean inAmbientMode)341         public void onAmbientModeChanged(boolean inAmbientMode) {
342             super.onAmbientModeChanged(inAmbientMode);
343             if (Log.isLoggable(TAG, Log.DEBUG)) {
344                 Log.d(TAG, "onAmbientModeChanged: " + inAmbientMode);
345             }
346 
347             if (mLowBitAmbient) {
348                 boolean antiAlias = !inAmbientMode;;
349                 mHourPaint.setAntiAlias(antiAlias);
350                 mMinutePaint.setAntiAlias(antiAlias);
351                 mSecondPaint.setAntiAlias(antiAlias);
352                 mAmPmPaint.setAntiAlias(antiAlias);
353                 mColonPaint.setAntiAlias(antiAlias);
354                 mStepCountPaint.setAntiAlias(antiAlias);
355             }
356             invalidate();
357 
358             // Whether the timer should be running depends on whether we're in ambient mode (as well
359             // as whether we're visible), so we may need to start or stop the timer.
360             updateTimer();
361         }
362 
formatTwoDigitNumber(int hour)363         private String formatTwoDigitNumber(int hour) {
364             return String.format("%02d", hour);
365         }
366 
getAmPmString(int amPm)367         private String getAmPmString(int amPm) {
368             return amPm == Calendar.AM ? mAmString : mPmString;
369         }
370 
371         @Override
onDraw(Canvas canvas, Rect bounds)372         public void onDraw(Canvas canvas, Rect bounds) {
373             long now = System.currentTimeMillis();
374             mCalendar.setTimeInMillis(now);
375             boolean is24Hour = DateFormat.is24HourFormat(FitStepsWatchFaceService.this);
376 
377             // Draw the background.
378             canvas.drawColor(BACKGROUND_COLOR);
379 
380             // Draw the hours.
381             float x = mXOffset;
382             String hourString;
383             if (is24Hour) {
384                 hourString = formatTwoDigitNumber(mCalendar.get(Calendar.HOUR_OF_DAY));
385             } else {
386                 int hour = mCalendar.get(Calendar.HOUR);
387                 if (hour == 0) {
388                     hour = 12;
389                 }
390                 hourString = String.valueOf(hour);
391             }
392             canvas.drawText(hourString, x, mYOffset, mHourPaint);
393             x += mHourPaint.measureText(hourString);
394 
395             // Draw first colon (between hour and minute).
396             canvas.drawText(COLON_STRING, x, mYOffset, mColonPaint);
397 
398             x += mColonWidth;
399 
400             // Draw the minutes.
401             String minuteString = formatTwoDigitNumber(mCalendar.get(Calendar.MINUTE));
402             canvas.drawText(minuteString, x, mYOffset, mMinutePaint);
403             x += mMinutePaint.measureText(minuteString);
404 
405             // In interactive mode, draw a second colon followed by the seconds.
406             // Otherwise, if we're in 12-hour mode, draw AM/PM
407             if (!isInAmbientMode()) {
408                 canvas.drawText(COLON_STRING, x, mYOffset, mColonPaint);
409 
410                 x += mColonWidth;
411                 canvas.drawText(formatTwoDigitNumber(
412                         mCalendar.get(Calendar.SECOND)), x, mYOffset, mSecondPaint);
413             } else if (!is24Hour) {
414                 x += mColonWidth;
415                 canvas.drawText(getAmPmString(
416                         mCalendar.get(Calendar.AM_PM)), x, mYOffset, mAmPmPaint);
417             }
418 
419             // Only render steps if there is no peek card, so they do not bleed into each other
420             // in ambient mode.
421             if (getPeekCardPosition().isEmpty()) {
422                 canvas.drawText(
423                         getString(R.string.fit_steps, mStepsTotal),
424                         mXStepsOffset,
425                         mYOffset + mLineHeight,
426                         mStepCountPaint);
427             }
428         }
429 
430         /**
431          * Starts the {@link #mUpdateTimeHandler} timer if it should be running and isn't currently
432          * or stops it if it shouldn't be running but currently is.
433          */
updateTimer()434         private void updateTimer() {
435             if (Log.isLoggable(TAG, Log.DEBUG)) {
436                 Log.d(TAG, "updateTimer");
437             }
438             mUpdateTimeHandler.removeMessages(MSG_UPDATE_TIME);
439             if (shouldUpdateTimeHandlerBeRunning()) {
440                 mUpdateTimeHandler.sendEmptyMessage(MSG_UPDATE_TIME);
441             }
442         }
443 
444         /**
445          * Returns whether the {@link #mUpdateTimeHandler} timer should be running. The timer should
446          * only run when we're visible and in interactive mode.
447          */
shouldUpdateTimeHandlerBeRunning()448         private boolean shouldUpdateTimeHandlerBeRunning() {
449             return isVisible() && !isInAmbientMode();
450         }
451 
getTotalSteps()452         private void getTotalSteps() {
453             if (Log.isLoggable(TAG, Log.DEBUG)) {
454                 Log.d(TAG, "getTotalSteps()");
455             }
456 
457             if ((mGoogleApiClient != null)
458                     && (mGoogleApiClient.isConnected())
459                     && (!mStepsRequested)) {
460 
461                 mStepsRequested = true;
462 
463                 PendingResult<DailyTotalResult> stepsResult =
464                         Fitness.HistoryApi.readDailyTotal(
465                                 mGoogleApiClient,
466                                 DataType.TYPE_STEP_COUNT_DELTA);
467 
468                 stepsResult.setResultCallback(this);
469             }
470         }
471 
472         @Override
onConnected(Bundle connectionHint)473         public void onConnected(Bundle connectionHint) {
474             if (Log.isLoggable(TAG, Log.DEBUG)) {
475                 Log.d(TAG, "mGoogleApiAndFitCallbacks.onConnected: " + connectionHint);
476             }
477             mStepsRequested = false;
478 
479             // The subscribe step covers devices that do not have Google Fit installed.
480             subscribeToSteps();
481 
482             getTotalSteps();
483         }
484 
485         /*
486          * Subscribes to step count (for phones that don't have Google Fit app).
487          */
subscribeToSteps()488         private void subscribeToSteps() {
489             Fitness.RecordingApi.subscribe(mGoogleApiClient, DataType.TYPE_STEP_COUNT_DELTA)
490                     .setResultCallback(new ResultCallback<Status>() {
491                         @Override
492                         public void onResult(Status status) {
493                             if (status.isSuccess()) {
494                                 if (status.getStatusCode()
495                                         == FitnessStatusCodes.SUCCESS_ALREADY_SUBSCRIBED) {
496                                     Log.i(TAG, "Existing subscription for activity detected.");
497                                 } else {
498                                     Log.i(TAG, "Successfully subscribed!");
499                                 }
500                             } else {
501                                 Log.i(TAG, "There was a problem subscribing.");
502                             }
503                         }
504                     });
505         }
506 
507         @Override
onConnectionSuspended(int cause)508         public void onConnectionSuspended(int cause) {
509             if (Log.isLoggable(TAG, Log.DEBUG)) {
510                 Log.d(TAG, "mGoogleApiAndFitCallbacks.onConnectionSuspended: " + cause);
511             }
512         }
513 
514         @Override
onConnectionFailed(ConnectionResult result)515         public void onConnectionFailed(ConnectionResult result) {
516             if (Log.isLoggable(TAG, Log.DEBUG)) {
517                 Log.d(TAG, "mGoogleApiAndFitCallbacks.onConnectionFailed: " + result);
518             }
519         }
520 
521         @Override
onResult(DailyTotalResult dailyTotalResult)522         public void onResult(DailyTotalResult dailyTotalResult) {
523             if (Log.isLoggable(TAG, Log.DEBUG)) {
524                 Log.d(TAG, "mGoogleApiAndFitCallbacks.onResult(): " + dailyTotalResult);
525             }
526 
527             mStepsRequested = false;
528 
529             if (dailyTotalResult.getStatus().isSuccess()) {
530 
531                 List<DataPoint> points = dailyTotalResult.getTotal().getDataPoints();;
532 
533                 if (!points.isEmpty()) {
534                     mStepsTotal = points.get(0).getValue(Field.FIELD_STEPS).asInt();
535                     Log.d(TAG, "steps updated: " + mStepsTotal);
536                 }
537             } else {
538                 Log.e(TAG, "onResult() failed! " + dailyTotalResult.getStatus().getStatusMessage());
539             }
540         }
541     }
542 }
543