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