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 }