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 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
     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
    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
    226         public void onDestroy() {
    227             mUpdateTimeHandler.removeMessages(MSG_UPDATE_TIME);
    228             super.onDestroy();
    229         }
    230 
    231         private Paint createTextPaint(int color) {
    232             return createTextPaint(color, NORMAL_TYPEFACE);
    233         }
    234 
    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
    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 
    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 
    279         private void unregisterReceiver() {
    280             if (!mRegisteredReceiver) {
    281                 return;
    282             }
    283             mRegisteredReceiver = false;
    284             FitDistanceWatchFaceService.this.unregisterReceiver(mReceiver);
    285         }
    286 
    287         @Override
    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
    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
    334         public void onTimeTick() {
    335             super.onTimeTick();
    336             Log.d(TAG, "onTimeTick: ambient = " + isInAmbientMode());
    337             getTotalDistance();
    338             invalidate();
    339         }
    340 
    341         @Override
    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 
    362         private String formatTwoDigitNumber(int hour) {
    363             return String.format("%02d", hour);
    364         }
    365 
    366         private String getAmPmString(int amPm) {
    367             return amPm == Calendar.AM ? mAmString : mPmString;
    368         }
    369 
    370         @Override
    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          */
    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          */
    446         private boolean shouldUpdateTimeHandlerBeRunning() {
    447             return isVisible() && !isInAmbientMode();
    448         }
    449 
    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
    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          */
    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
    508         public void onConnectionSuspended(int cause) {
    509             Log.d(TAG, "mGoogleApiAndFitCallbacks.onConnectionSuspended: " + cause);
    510         }
    511 
    512         @Override
    513         public void onConnectionFailed(ConnectionResult result) {
    514             Log.d(TAG, "mGoogleApiAndFitCallbacks.onConnectionFailed: " + result);
    515         }
    516 
    517         @Override
    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 }