1 /* 2 * Copyright 2015 Google Inc. All rights reserved. 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.xyztouristattractions.service; 18 19 import android.app.IntentService; 20 import android.app.Notification; 21 import android.app.PendingIntent; 22 import android.content.Context; 23 import android.content.Intent; 24 import android.content.IntentFilter; 25 import android.graphics.Bitmap; 26 import android.location.Location; 27 import android.support.v4.app.NotificationCompat; 28 import android.support.v4.app.NotificationManagerCompat; 29 import android.support.v4.content.LocalBroadcastManager; 30 import android.util.Log; 31 32 import com.bumptech.glide.Glide; 33 import com.bumptech.glide.load.engine.DiskCacheStrategy; 34 import com.example.android.xyztouristattractions.R; 35 import com.example.android.xyztouristattractions.common.Attraction; 36 import com.example.android.xyztouristattractions.common.Constants; 37 import com.example.android.xyztouristattractions.common.Utils; 38 import com.example.android.xyztouristattractions.provider.TouristAttractions; 39 import com.example.android.xyztouristattractions.ui.DetailActivity; 40 import com.google.android.gms.common.ConnectionResult; 41 import com.google.android.gms.common.api.GoogleApiClient; 42 import com.google.android.gms.location.FusedLocationProviderApi; 43 import com.google.android.gms.location.Geofence; 44 import com.google.android.gms.location.GeofencingEvent; 45 import com.google.android.gms.location.LocationRequest; 46 import com.google.android.gms.location.LocationServices; 47 import com.google.android.gms.maps.model.LatLng; 48 import com.google.android.gms.wearable.DataApi; 49 import com.google.android.gms.wearable.DataMap; 50 import com.google.android.gms.wearable.PutDataMapRequest; 51 import com.google.android.gms.wearable.PutDataRequest; 52 import com.google.android.gms.wearable.Wearable; 53 54 import java.util.ArrayList; 55 import java.util.Date; 56 import java.util.HashMap; 57 import java.util.Iterator; 58 import java.util.List; 59 import java.util.concurrent.ExecutionException; 60 import java.util.concurrent.TimeUnit; 61 62 import static com.example.android.xyztouristattractions.provider.TouristAttractions.ATTRACTIONS; 63 import static com.google.android.gms.location.LocationServices.FusedLocationApi; 64 import static com.google.android.gms.location.LocationServices.GeofencingApi; 65 66 /** 67 * A utility IntentService, used for a variety of asynchronous background 68 * operations that do not necessarily need to be tied to a UI. 69 */ 70 public class UtilityService extends IntentService { 71 private static final String TAG = UtilityService.class.getSimpleName(); 72 73 public static final String ACTION_GEOFENCE_TRIGGERED = "geofence_triggered"; 74 private static final String ACTION_LOCATION_UPDATED = "location_updated"; 75 private static final String ACTION_REQUEST_LOCATION = "request_location"; 76 private static final String ACTION_ADD_GEOFENCES = "add_geofences"; 77 private static final String ACTION_CLEAR_NOTIFICATION = "clear_notification"; 78 private static final String ACTION_CLEAR_REMOTE_NOTIFICATIONS = "clear_remote_notifications"; 79 private static final String ACTION_FAKE_UPDATE = "fake_update"; 80 private static final String EXTRA_TEST_MICROAPP = "test_microapp"; 81 82 public static IntentFilter getLocationUpdatedIntentFilter() { 83 return new IntentFilter(UtilityService.ACTION_LOCATION_UPDATED); 84 } 85 86 public static void triggerWearTest(Context context, boolean microApp) { 87 Intent intent = new Intent(context, UtilityService.class); 88 intent.setAction(UtilityService.ACTION_FAKE_UPDATE); 89 intent.putExtra(EXTRA_TEST_MICROAPP, microApp); 90 context.startService(intent); 91 } 92 93 public static void addGeofences(Context context) { 94 Intent intent = new Intent(context, UtilityService.class); 95 intent.setAction(UtilityService.ACTION_ADD_GEOFENCES); 96 context.startService(intent); 97 } 98 99 public static void requestLocation(Context context) { 100 Intent intent = new Intent(context, UtilityService.class); 101 intent.setAction(UtilityService.ACTION_REQUEST_LOCATION); 102 context.startService(intent); 103 } 104 105 public static void clearNotification(Context context) { 106 Intent intent = new Intent(context, UtilityService.class); 107 intent.setAction(UtilityService.ACTION_CLEAR_NOTIFICATION); 108 context.startService(intent); 109 } 110 111 public static Intent getClearRemoteNotificationsIntent(Context context) { 112 Intent intent = new Intent(context, UtilityService.class); 113 intent.setAction(UtilityService.ACTION_CLEAR_REMOTE_NOTIFICATIONS); 114 return intent; 115 } 116 117 public UtilityService() { 118 super(TAG); 119 } 120 121 @Override 122 protected void onHandleIntent(Intent intent) { 123 String action = intent != null ? intent.getAction() : null; 124 if (ACTION_ADD_GEOFENCES.equals(action)) { 125 addGeofencesInternal(); 126 } else if (ACTION_GEOFENCE_TRIGGERED.equals(action)) { 127 geofenceTriggered(intent); 128 } else if (ACTION_REQUEST_LOCATION.equals(action)) { 129 requestLocationInternal(); 130 } else if (ACTION_LOCATION_UPDATED.equals(action)) { 131 locationUpdated(intent); 132 } else if (ACTION_CLEAR_NOTIFICATION.equals(action)) { 133 clearNotificationInternal(); 134 } else if (ACTION_CLEAR_REMOTE_NOTIFICATIONS.equals(action)) { 135 clearRemoteNotifications(); 136 } else if (ACTION_FAKE_UPDATE.equals(action)) { 137 LatLng currentLocation = Utils.getLocation(this); 138 139 // If location unknown use test city, otherwise use closest city 140 String city = currentLocation == null ? TouristAttractions.TEST_CITY : 141 TouristAttractions.getClosestCity(currentLocation); 142 143 showNotification(city, 144 intent.getBooleanExtra(EXTRA_TEST_MICROAPP, Constants.USE_MICRO_APP)); 145 } 146 } 147 148 /** 149 * Add geofences using Play Services 150 */ 151 private void addGeofencesInternal() { 152 Log.v(TAG, ACTION_ADD_GEOFENCES); 153 GoogleApiClient googleApiClient = new GoogleApiClient.Builder(this) 154 .addApi(LocationServices.API) 155 .build(); 156 157 // It's OK to use blockingConnect() here as we are running in an 158 // IntentService that executes work on a separate (background) thread. 159 ConnectionResult connectionResult = googleApiClient.blockingConnect( 160 Constants.GOOGLE_API_CLIENT_TIMEOUT_S, TimeUnit.SECONDS); 161 162 if (connectionResult.isSuccess() && googleApiClient.isConnected()) { 163 PendingIntent pendingIntent = PendingIntent.getBroadcast( 164 this, 0, new Intent(this, UtilityReceiver.class), 0); 165 GeofencingApi.addGeofences(googleApiClient, 166 TouristAttractions.getGeofenceList(), pendingIntent); 167 googleApiClient.disconnect(); 168 } else { 169 Log.e(TAG, String.format(Constants.GOOGLE_API_CLIENT_ERROR_MSG, 170 connectionResult.getErrorCode())); 171 } 172 } 173 174 /** 175 * Called when a geofence is triggered 176 */ 177 private void geofenceTriggered(Intent intent) { 178 Log.v(TAG, ACTION_GEOFENCE_TRIGGERED); 179 180 // Check if geofences are enabled 181 boolean geofenceEnabled = Utils.getGeofenceEnabled(this); 182 183 // Extract the geofences from the intent 184 GeofencingEvent event = GeofencingEvent.fromIntent(intent); 185 List<Geofence> geofences = event.getTriggeringGeofences(); 186 187 if (geofenceEnabled && geofences != null && geofences.size() > 0) { 188 if (event.getGeofenceTransition() == Geofence.GEOFENCE_TRANSITION_ENTER) { 189 // Trigger the notification based on the first geofence 190 showNotification(geofences.get(0).getRequestId(), Constants.USE_MICRO_APP); 191 } else if (event.getGeofenceTransition() == Geofence.GEOFENCE_TRANSITION_EXIT) { 192 // Clear notifications 193 clearNotificationInternal(); 194 clearRemoteNotifications(); 195 } 196 } 197 UtilityReceiver.completeWakefulIntent(intent); 198 } 199 200 /** 201 * Called when a location update is requested 202 */ 203 private void requestLocationInternal() { 204 Log.v(TAG, ACTION_REQUEST_LOCATION); 205 GoogleApiClient googleApiClient = new GoogleApiClient.Builder(this) 206 .addApi(LocationServices.API) 207 .build(); 208 209 // It's OK to use blockingConnect() here as we are running in an 210 // IntentService that executes work on a separate (background) thread. 211 ConnectionResult connectionResult = googleApiClient.blockingConnect( 212 Constants.GOOGLE_API_CLIENT_TIMEOUT_S, TimeUnit.SECONDS); 213 214 if (connectionResult.isSuccess() && googleApiClient.isConnected()) { 215 216 Intent locationUpdatedIntent = new Intent(this, UtilityService.class); 217 locationUpdatedIntent.setAction(ACTION_LOCATION_UPDATED); 218 219 // Send last known location out first if available 220 Location location = FusedLocationApi.getLastLocation(googleApiClient); 221 if (location != null) { 222 Intent lastLocationIntent = new Intent(locationUpdatedIntent); 223 lastLocationIntent.putExtra( 224 FusedLocationProviderApi.KEY_LOCATION_CHANGED, location); 225 startService(lastLocationIntent); 226 } 227 228 // Request new location 229 LocationRequest mLocationRequest = new LocationRequest() 230 .setPriority(LocationRequest.PRIORITY_BALANCED_POWER_ACCURACY); 231 FusedLocationApi.requestLocationUpdates( 232 googleApiClient, mLocationRequest, 233 PendingIntent.getService(this, 0, locationUpdatedIntent, 0)); 234 235 googleApiClient.disconnect(); 236 } else { 237 Log.e(TAG, String.format(Constants.GOOGLE_API_CLIENT_ERROR_MSG, 238 connectionResult.getErrorCode())); 239 } 240 } 241 242 /** 243 * Called when the location has been updated 244 */ 245 private void locationUpdated(Intent intent) { 246 Log.v(TAG, ACTION_LOCATION_UPDATED); 247 248 // Extra new location 249 Location location = 250 intent.getParcelableExtra(FusedLocationProviderApi.KEY_LOCATION_CHANGED); 251 252 if (location != null) { 253 LatLng latLngLocation = new LatLng(location.getLatitude(), location.getLongitude()); 254 255 // Store in a local preference as well 256 Utils.storeLocation(this, latLngLocation); 257 258 // Send a local broadcast so if an Activity is open it can respond 259 // to the updated location 260 LocalBroadcastManager.getInstance(this).sendBroadcast(intent); 261 } 262 } 263 264 /** 265 * Clears the local device notification 266 */ 267 private void clearNotificationInternal() { 268 Log.v(TAG, ACTION_CLEAR_NOTIFICATION); 269 NotificationManagerCompat.from(this).cancel(Constants.MOBILE_NOTIFICATION_ID); 270 } 271 272 /** 273 * Clears remote device notifications using the Wearable message API 274 */ 275 private void clearRemoteNotifications() { 276 Log.v(TAG, ACTION_CLEAR_REMOTE_NOTIFICATIONS); 277 GoogleApiClient googleApiClient = new GoogleApiClient.Builder(this) 278 .addApi(Wearable.API) 279 .build(); 280 281 // It's OK to use blockingConnect() here as we are running in an 282 // IntentService that executes work on a separate (background) thread. 283 ConnectionResult connectionResult = googleApiClient.blockingConnect( 284 Constants.GOOGLE_API_CLIENT_TIMEOUT_S, TimeUnit.SECONDS); 285 286 if (connectionResult.isSuccess() && googleApiClient.isConnected()) { 287 288 // Loop through all nodes and send a clear notification message 289 Iterator<String> itr = Utils.getNodes(googleApiClient).iterator(); 290 while (itr.hasNext()) { 291 Wearable.MessageApi.sendMessage( 292 googleApiClient, itr.next(), Constants.CLEAR_NOTIFICATIONS_PATH, null); 293 } 294 googleApiClient.disconnect(); 295 } 296 } 297 298 299 /** 300 * Show the notification. Either the regular notification with wearable features 301 * added to enhance, or trigger the full micro app on the wearable. 302 * 303 * @param cityId The city to trigger the notification for 304 * @param microApp If the micro app should be triggered or just enhanced notifications 305 */ 306 private void showNotification(String cityId, boolean microApp) { 307 308 List<Attraction> attractions = ATTRACTIONS.get(cityId); 309 310 if (microApp) { 311 // If micro app we first need to transfer some data over 312 sendDataToWearable(attractions); 313 } 314 315 // The first (closest) tourist attraction 316 Attraction attraction = attractions.get(0); 317 318 // Limit attractions to send 319 int count = attractions.size() > Constants.MAX_ATTRACTIONS ? 320 Constants.MAX_ATTRACTIONS : attractions.size(); 321 322 // Pull down the tourist attraction images from the network and store 323 HashMap<String, Bitmap> bitmaps = new HashMap<>(); 324 try { 325 for (int i = 0; i < count; i++) { 326 bitmaps.put(attractions.get(i).name, 327 Glide.with(this) 328 .load(attractions.get(i).imageUrl) 329 .asBitmap() 330 .diskCacheStrategy(DiskCacheStrategy.SOURCE) 331 .into(Constants.WEAR_IMAGE_SIZE, Constants.WEAR_IMAGE_SIZE) 332 .get()); 333 } 334 } catch (InterruptedException | ExecutionException e) { 335 Log.e(TAG, "Error fetching image from network: " + e); 336 } 337 338 // The intent to trigger when the notification is tapped 339 PendingIntent pendingIntent = PendingIntent.getActivity(this, 0, 340 DetailActivity.getLaunchIntent(this, attraction.name), 341 PendingIntent.FLAG_UPDATE_CURRENT); 342 343 // The intent to trigger when the notification is dismissed, in this case 344 // we want to clear remote notifications as well 345 PendingIntent deletePendingIntent = 346 PendingIntent.getService(this, 0, getClearRemoteNotificationsIntent(this), 0); 347 348 // Construct the main notification 349 NotificationCompat.Builder builder = new NotificationCompat.Builder(this) 350 .setStyle(new NotificationCompat.BigPictureStyle() 351 .bigPicture(bitmaps.get(attraction.name)) 352 .setBigContentTitle(attraction.name) 353 .setSummaryText(getString(R.string.nearby_attraction)) 354 ) 355 .setLocalOnly(microApp) 356 .setContentTitle(attraction.name) 357 .setContentText(getString(R.string.nearby_attraction)) 358 .setSmallIcon(R.drawable.ic_stat_maps_pin_drop) 359 .setContentIntent(pendingIntent) 360 .setDeleteIntent(deletePendingIntent) 361 .setColor(getResources().getColor(R.color.colorPrimary)) 362 .setCategory(Notification.CATEGORY_RECOMMENDATION) 363 .setAutoCancel(true); 364 365 if (!microApp) { 366 // If not a micro app, create some wearable pages for 367 // the other nearby tourist attractions. 368 ArrayList<Notification> pages = new ArrayList<Notification>(); 369 for (int i = 1; i < count; i++) { 370 371 // Calculate the distance from current location to tourist attraction 372 String distance = Utils.formatDistanceBetween( 373 Utils.getLocation(this), attractions.get(i).location); 374 375 // Construct the notification and add it as a page 376 pages.add(new NotificationCompat.Builder(this) 377 .setContentTitle(attractions.get(i).name) 378 .setContentText(distance) 379 .setSmallIcon(R.drawable.ic_stat_maps_pin_drop) 380 .extend(new NotificationCompat.WearableExtender() 381 .setBackground(bitmaps.get(attractions.get(i).name)) 382 ) 383 .build()); 384 } 385 builder.extend(new NotificationCompat.WearableExtender().addPages(pages)); 386 } 387 388 // Trigger the notification 389 NotificationManagerCompat.from(this).notify( 390 Constants.MOBILE_NOTIFICATION_ID, builder.build()); 391 } 392 393 /** 394 * Transfer the required data over to the wearable 395 * @param attractions list of attraction data to transfer over 396 */ 397 private void sendDataToWearable(List<Attraction> attractions) { 398 GoogleApiClient googleApiClient = new GoogleApiClient.Builder(this) 399 .addApi(Wearable.API) 400 .build(); 401 402 // It's OK to use blockingConnect() here as we are running in an 403 // IntentService that executes work on a separate (background) thread. 404 ConnectionResult connectionResult = googleApiClient.blockingConnect( 405 Constants.GOOGLE_API_CLIENT_TIMEOUT_S, TimeUnit.SECONDS); 406 407 // Limit attractions to send 408 int count = attractions.size() > Constants.MAX_ATTRACTIONS ? 409 Constants.MAX_ATTRACTIONS : attractions.size(); 410 411 ArrayList<DataMap> attractionsData = new ArrayList<>(count); 412 413 for (int i = 0; i < count; i++) { 414 Attraction attraction = attractions.get(i); 415 416 Bitmap image = null; 417 Bitmap secondaryImage = null; 418 419 try { 420 // Fetch and resize attraction image bitmap 421 image = Glide.with(this) 422 .load(attraction.imageUrl) 423 .asBitmap() 424 .diskCacheStrategy(DiskCacheStrategy.SOURCE) 425 .into(Constants.WEAR_IMAGE_SIZE_PARALLAX_WIDTH, Constants.WEAR_IMAGE_SIZE) 426 .get(); 427 428 secondaryImage = Glide.with(this) 429 .load(attraction.secondaryImageUrl) 430 .asBitmap() 431 .diskCacheStrategy(DiskCacheStrategy.SOURCE) 432 .into(Constants.WEAR_IMAGE_SIZE_PARALLAX_WIDTH, Constants.WEAR_IMAGE_SIZE) 433 .get(); 434 } catch (InterruptedException | ExecutionException e) { 435 Log.e(TAG, "Exception loading bitmap from network"); 436 } 437 438 if (image != null && secondaryImage != null) { 439 440 DataMap attractionData = new DataMap(); 441 442 String distance = Utils.formatDistanceBetween( 443 Utils.getLocation(this), attraction.location); 444 445 attractionData.putString(Constants.EXTRA_TITLE, attraction.name); 446 attractionData.putString(Constants.EXTRA_DESCRIPTION, attraction.description); 447 attractionData.putDouble( 448 Constants.EXTRA_LOCATION_LAT, attraction.location.latitude); 449 attractionData.putDouble( 450 Constants.EXTRA_LOCATION_LNG, attraction.location.longitude); 451 attractionData.putString(Constants.EXTRA_DISTANCE, distance); 452 attractionData.putString(Constants.EXTRA_CITY, attraction.city); 453 attractionData.putAsset(Constants.EXTRA_IMAGE, 454 Utils.createAssetFromBitmap(image)); 455 attractionData.putAsset(Constants.EXTRA_IMAGE_SECONDARY, 456 Utils.createAssetFromBitmap(secondaryImage)); 457 458 attractionsData.add(attractionData); 459 } 460 } 461 462 if (connectionResult.isSuccess() && googleApiClient.isConnected() 463 && attractionsData.size() > 0) { 464 465 PutDataMapRequest dataMap = PutDataMapRequest.create(Constants.ATTRACTION_PATH); 466 dataMap.getDataMap().putDataMapArrayList(Constants.EXTRA_ATTRACTIONS, attractionsData); 467 dataMap.getDataMap().putLong(Constants.EXTRA_TIMESTAMP, new Date().getTime()); 468 PutDataRequest request = dataMap.asPutDataRequest(); 469 470 // Send the data over 471 DataApi.DataItemResult result = 472 Wearable.DataApi.putDataItem(googleApiClient, request).await(); 473 474 if (!result.getStatus().isSuccess()) { 475 Log.e(TAG, String.format("Error sending data using DataApi (error code = %d)", 476 result.getStatus().getStatusCode())); 477 } 478 479 } else { 480 Log.e(TAG, String.format(Constants.GOOGLE_API_CLIENT_ERROR_MSG, 481 connectionResult.getErrorCode())); 482 } 483 googleApiClient.disconnect(); 484 } 485 } 486