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