Home | History | Annotate | Download | only in com.example.android.wearable.watchface
      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
     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
    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
    223         public void onDestroy() {
    224             mUpdateTimeHandler.removeMessages(MSG_UPDATE_TIME);
    225             super.onDestroy();
    226         }
    227 
    228         private Paint createTextPaint(int color) {
    229             return createTextPaint(color, NORMAL_TYPEFACE);
    230         }
    231 
    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
    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 
    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 
    277         private void unregisterReceiver() {
    278             if (!mRegisteredReceiver) {
    279                 return;
    280             }
    281             mRegisteredReceiver = false;
    282             FitStepsWatchFaceService.this.unregisterReceiver(mReceiver);
    283         }
    284 
    285         @Override
    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
    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
    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
    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 
    363         private String formatTwoDigitNumber(int hour) {
    364             return String.format("%02d", hour);
    365         }
    366 
    367         private String getAmPmString(int amPm) {
    368             return amPm == Calendar.AM ? mAmString : mPmString;
    369         }
    370 
    371         @Override
    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          */
    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          */
    448         private boolean shouldUpdateTimeHandlerBeRunning() {
    449             return isVisible() && !isInAmbientMode();
    450         }
    451 
    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
    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          */
    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
    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
    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
    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