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