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