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