/* * Copyright (C) 2016 Google Inc. All Rights Reserved. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package com.google.android.apps.santatracker.presentquest; import android.app.IntentService; import android.content.BroadcastReceiver; import android.content.Context; import android.content.Intent; import android.content.IntentFilter; import android.content.pm.PackageManager; import android.content.pm.Signature; import android.support.annotation.NonNull; import android.support.annotation.Nullable; import android.support.annotation.WorkerThread; import android.support.v4.content.LocalBroadcastManager; import android.text.TextUtils; import android.util.Log; import com.google.android.apps.santatracker.presentquest.model.Place; import com.google.android.apps.santatracker.presentquest.model.Present; import com.google.android.apps.santatracker.presentquest.util.Config; import com.google.android.apps.santatracker.presentquest.util.PreferencesUtil; import com.google.android.gms.maps.model.LatLng; import org.json.JSONArray; import org.json.JSONObject; import java.io.BufferedReader; import java.io.ByteArrayInputStream; import java.io.InputStream; import java.io.InputStreamReader; import java.net.URL; import java.security.MessageDigest; import java.security.cert.CertificateFactory; import java.security.cert.X509Certificate; import java.util.ArrayList; import java.util.Collections; import java.util.Comparator; import java.util.HashSet; import java.util.List; import java.util.Random; import java.util.Set; import java.util.concurrent.atomic.AtomicInteger; import javax.net.ssl.HttpsURLConnection; public class PlacesIntentService extends IntentService { private static final String TAG = "PQ(PlacesService)"; private static final String ACTION_SEARCH_NEARBY = "ACTION_SEARCH_NEARBY"; private static final String EXTRA_LAT_LNG = "extra_lat_lng"; private static final String EXTRA_RADIUS = "extra_radius"; private static final String EXTRA_PLACE_RESULT = "extra_place_result"; private static final int MAX_QUERIES_IN_PROGRESS = 1; private AtomicInteger mQueriesInProgress = new AtomicInteger(0); private String mAppSignature; // Shared Prefs private PreferencesUtil mPreferences; // Firebase Config private Config mConfig; public PlacesIntentService() { super(TAG); } @NonNull public static IntentFilter getNearbySearchIntentFilter() { return new IntentFilter(ACTION_SEARCH_NEARBY); } public static void startNearbySearch(Context context, LatLng center, int radius) { Log.d(TAG, "startNearbySearch: radius=" + radius); Intent intent = new Intent(context, PlacesIntentService.class); intent.setAction(ACTION_SEARCH_NEARBY); intent.putExtra(EXTRA_LAT_LNG, center); intent.putExtra(EXTRA_RADIUS, radius); context.startService(intent); } @Override protected void onHandleIntent(Intent intent) { if (intent != null) { final String action = intent.getAction(); switch (action) { case ACTION_SEARCH_NEARBY: // Don't allow more than X queries at once. if (mQueriesInProgress.get() >= MAX_QUERIES_IN_PROGRESS) { Log.d(TAG, "Dropping excess query"); return; } // Mark query started mQueriesInProgress.incrementAndGet(); if (mPreferences == null) { mPreferences = new PreferencesUtil(this); } if (mConfig == null) { mConfig = new Config(); } // Perform query final LatLng center = intent.getParcelableExtra(EXTRA_LAT_LNG); final int radius = intent.getIntExtra(EXTRA_RADIUS, 0); getPlaceAndBroadcast(center, radius); // Mark query finished mQueriesInProgress.decrementAndGet(); break; default: Log.w(TAG, "Unknown action: " + action); } } } @WorkerThread private void getPlaceAndBroadcast(LatLng center, int radius) { long now = System.currentTimeMillis(); boolean useCache = now - mPreferences.getLastPlacesApiRequest() < mConfig.CACHE_REFRESH_MS; // Try and retrieve place from DB cache if CACHE_REFRESH_MS has not elapsed // since last API request. Place place = null; if (useCache) { place = getCachedPlace(center, radius); } // If CACHE_REFRESH_MS has elapsed, or no nearby places in cache, fetch from API and // cache the results, then return one of them. Guaranteed to have results since we // back-fill with random locations if too few returned from Places API. if (place == null) { // Set the last API request time with a +/- 30 sec jitter. int jitter = ((new Random()).nextInt(60) - 30) * 1000; mPreferences.setLastPlacesApiRequest(now + jitter); Log.d(TAG, "getPlaceAndBroadcast: " + (useCache ? "cache miss" : "cache refresh elapsed")); place = fetchPlacesAndGetCached(center, radius); } else { Log.d(TAG, "getPlaceAndBroadcast: cache hit"); } // If the place is STILL null, just bail if (place == null) { Log.w(TAG, "getPlaceAndBroadcast: total cache failure"); return; } // Log some stats about the place picked int distance = Distance.between(center, place.getLatLng()); Log.d(TAG, "getPlaceAndBroadcast: distance=" + distance + ", used=" + place.used); // Create result intent and broadcast the result. Intent intent = new Intent(); intent.setAction(ACTION_SEARCH_NEARBY); intent.putExtra(EXTRA_PLACE_RESULT, place.getLatLng()); boolean received = LocalBroadcastManager.getInstance(this).sendBroadcast(intent); Log.d(TAG, "getPlaceAndBroadcast: received=" + received); if (received) { // Increments usage counter. place.use(); } } /** * Compares cached places by their distance from the requesting center, weighing * their distance by the number of times the place has been used. */ private static class PlaceComparator implements Comparator<Place> { LatLng center; int radius; double usedPlaceRadiusWeight; PlaceComparator(LatLng center, int radius, double usedPlaceRadiusWeight) { this.center = center; this.radius = radius; this.usedPlaceRadiusWeight = usedPlaceRadiusWeight; } private int weightedDistanceTo(Place place) { return Distance.between(center, place.getLatLng()) + (int) (place.used * radius * usedPlaceRadiusWeight); } @Override public int compare(Place a, Place b) { return weightedDistanceTo(a) - weightedDistanceTo(b); } } @Nullable private Place getCachedPlace(LatLng center, int radius) { // Build a set of present locations we can use to check that we // don't choose a place that already exists as a present. Set<LatLng> presents = new HashSet<>(); for (Present present : Present.listAll(Present.class)) { presents.add(present.getLatLng()); } // Sort all places by distance, and filter down to the top X in correct proximity. // We'll then choose one of these randomly as the result. List<Place> allPlaces = Place.listAll(Place.class); Collections.sort(allPlaces, new PlaceComparator(center, radius, mConfig.USED_PLACE_RADIUS_WEIGHT)); List<Place> potentialPlaces = new ArrayList<>(); for (Place place : allPlaces) { int distance = Distance.between(center, place.getLatLng()); boolean closeEnough = distance <= radius; boolean farEnough = distance > mConfig.REACHABLE_RADIUS_METERS; if (closeEnough && farEnough && !presents.contains(place.getLatLng())) { potentialPlaces.add(place); if (potentialPlaces.size() >= mConfig.MAX_CACHE_RANDOM_SAMPLE_SIZE) { break; } } } // Choose a random place from the possible results, or null for a cache miss. if (!potentialPlaces.isEmpty()) { return potentialPlaces.get(new Random().nextInt(potentialPlaces.size())); } else { return null; } } /** * Fetches places from the Places API, back-filling with random locations if too few * returned, and caches them all for future use. * * @param center * @param radius * @return */ private Place fetchPlacesAndGetCached(LatLng center, int radius) { // Before we start, mark if this is the first run boolean firstRun = Place.count(Place.class) == 0; // Make places API request using double the radius, to have cached items while travelling. ArrayList<LatLng> places = fetchPlacesFromAPI(center, radius * 2); int numFetched = places.size(); Log.d(TAG, "fetchPlaces: API returned " + numFetched + " place(s)"); // Build set of locations that Places API returned and are already cached, which // we can check against before caching a new location from Places API. Set<LatLng> cached = new HashSet<>(); if (numFetched > 0) { String[] query = new String[numFetched]; String[] queryArgs = new String[numFetched * 2]; for (int i = 0; i < numFetched; i++) { query[i] = "(lat = ? AND lng = ?)"; LatLng placeLatLng = places.get(i); int argsIndex = i * 2; queryArgs[argsIndex] = String.valueOf(placeLatLng.latitude); queryArgs[argsIndex + 1] = String.valueOf(placeLatLng.longitude); } // eg: SELECT * FROM places WHERE (lat = 1 AND lng = 2) OR (lat = 3 AND lng = 4); for (Place place : Place.find(Place.class, TextUtils.join(" OR ", query), queryArgs)) { cached.add(place.getLatLng()); } } Log.d(TAG, "fetchPlaces: " + cached.size() + " place(s) are already cached"); // Back-fill with random locations to ensure up to MIN_CACHED_PLACES places. // We reduce radius to half for these, to decrease the likelihood of // adding an inaccessible location. int fill = mConfig.MIN_CACHED_PLACES - numFetched; if (fill > 0) { Log.d(TAG, "fetchPlaces: back-filling with " + fill + " random places"); for (int i = 0; i < fill; i++) { LatLng randomLatLng = randomLatLng(center, radius / 2); places.add(randomLatLng); } } // Save results to cache. Log.d(TAG, "fetchPlaces: caching " + places.size()); for (LatLng latLng : places) { Place place = new Place(latLng); // Check that the place isn't already in the cache, which is very likely since // if the rate limit elapses and the user hasn't moved, duplicates will be returned. if (!cached.contains(place.getLatLng())) { place.save(); } else { Log.d(TAG, "Location already cached, discarding: " + latLng); } } // Cull the cache if too large. int cull = Math.max((int) Place.count(Place.class) - mConfig.MAX_CACHED_PLACES, 0); Log.d(TAG, "fetchPlaces: culling " + cull + " cached places"); if (cull > 0) { String[] emptyArgs = {}; int i = 0; // Get the list of oldest cached places we want to cull, and use its highest ID // as the arg to delete. // eg: SELECT FROM places ORDER BY id LIMIT 20; List<Place> oldestPlaces = Place.find(Place.class, "", emptyArgs, "", "id", String.valueOf(cull)); Place newestOfOldest = oldestPlaces.get(oldestPlaces.size() - 1); // eg: DELETE FROM places WHERE ID <= 20; Place.deleteAll(Place.class, "ID <= ?", String.valueOf(newestOfOldest.getId())); } // If it's the first run, try to return a particularly well-suited place if (firstRun) { Place firstRunPlace = getCachedFirstPlace(center); if (firstRunPlace != null) { return firstRunPlace; } } // Now that the cache is populated, use the logic to get a cached place and return it return getCachedPlace(center, radius); } /** * Get a place from the cache that's particularly suited for the first drop. */ @Nullable private Place getCachedFirstPlace(LatLng center) { // Try to find one in the cache List<Place> places = Place.listAll(Place.class); for (Place place : places) { if (isValidFirstPlace(center, place.getLatLng())) { Log.d(TAG, "getCachedFirstPlace: cache hit"); return place; } } // If that didn't work, try to randomly generate one, we will search within // 1.5x the radius we want so that we have a better chance of a random hit. int maxSearchRadius = (int) (1.5 * mConfig.FIRST_PLACE_RADIUS_WEIGHT * mConfig.REACHABLE_RADIUS_METERS); int maxRandomTries = 100; for (int i = 0; i < maxRandomTries; i++) { LatLng latLng = randomLatLng(center, maxSearchRadius); if (isValidFirstPlace(center, latLng)) { Log.d(TAG, "getCachedFirstPlace: got random, attempt " + i); Log.d(TAG, "getCachedFirstPlace: distance is " + Distance.between(center, latLng)); // Save place and return Place place = new Place(latLng); place.save(); return place; } } // We got really, really unlucky Log.d(TAG, "getCachedFirstPlace: no hits"); return null; } private boolean isValidFirstPlace(LatLng center, LatLng placeLatLng) { int distance = Distance.between(center, placeLatLng); int minDistance = mConfig.REACHABLE_RADIUS_METERS; int maxDistance = (int) (mConfig.REACHABLE_RADIUS_METERS * mConfig.FIRST_PLACE_RADIUS_WEIGHT); return distance > minDistance && distance < maxDistance; } private ArrayList<LatLng> fetchPlacesFromAPI(LatLng center, int radius) { ArrayList<LatLng> places = new ArrayList<>(); radius = Math.min(radius, 50000); // Max accepted radius is 50km. try { InputStream is = null; URL url = new URL(getString(R.string.places_api_url) + "?location=" + center.latitude + "," + center.longitude + "&radius=" + radius); HttpsURLConnection conn = (HttpsURLConnection) url.openConnection(); conn.setReadTimeout(10000); conn.setConnectTimeout(15000); conn.setRequestMethod("GET"); conn.setDoInput(true); // Pass package name and signature as part of request String packageName = getPackageName(); String signature = getAppSignature(); conn.setRequestProperty("X-App-Package", packageName); conn.setRequestProperty("X-App-Signature", signature); conn.connect(); int response = conn.getResponseCode(); if (response != 200) { Log.e(TAG, "Places API HTTP error: " + response + " / " + url); } else { BufferedReader reader; StringBuilder builder = new StringBuilder(); reader = new BufferedReader(new InputStreamReader(conn.getInputStream(), "UTF-8")); for (String line; (line = reader.readLine()) != null; ) { builder.append(line); } JSONArray resultsJson = (new JSONObject(builder.toString())).getJSONArray("results"); for (int i = 0; i < resultsJson.length(); i++) { JSONObject latLngJson = ((JSONObject) resultsJson.get(i)) .getJSONObject("geometry").getJSONObject("location"); places.add(new LatLng(latLngJson.getDouble("lat"), latLngJson.getDouble("lng"))); } } } catch (Exception e) { Log.e(TAG, "Exception parsing places API: " + e.toString()); } return places; } private LatLng randomLatLng(LatLng center, int radius) { // Based on http://gis.stackexchange.com/questions/25877/how-to-generate-random-locations-nearby-my-location Random random = new Random(); double radiusInDegrees = radius / 111000f; double u = random.nextDouble(); double v = random.nextDouble(); double w = radiusInDegrees * Math.sqrt(u); double t = 2 * Math.PI * v; double x = w * Math.cos(t); double y = w * Math.sin(t); double new_x = x / Math.cos(center.latitude); return new LatLng(y + center.latitude, new_x + center.longitude); } @Nullable private String getAppSignature() { // Cache this so we don't need to calculate the signature on every request if (mAppSignature != null) { return mAppSignature; } try { // Get signatures for the package Signature[] sigs = getPackageManager().getPackageInfo(getPackageName(), PackageManager.GET_SIGNATURES).signatures; // There should only be one signature, anything else is suspicious if (sigs == null || sigs.length > 1 || sigs.length == 0) { Log.w(TAG, "Either 0 or >1 signatures, returning null"); return null; } byte[] certBytes = sigs[0].toByteArray(); InputStream input = new ByteArrayInputStream(certBytes); CertificateFactory cf = CertificateFactory.getInstance("X509"); X509Certificate cert = (X509Certificate) cf.generateCertificate(input); MessageDigest md = MessageDigest.getInstance("SHA1"); byte[] publicKey = md.digest(cert.getEncoded()); // Build a hex string of the SHA1 Digest StringBuilder hexString = new StringBuilder(); for (byte aPublicKey : publicKey) { // Convert each byte to hex String appendString = Integer.toHexString(0xFF & aPublicKey); if (appendString.length() == 1) { hexString.append("0"); } // Convert to upper case and add ":" separators so it matches keytool output appendString = appendString.toUpperCase() + ":"; hexString.append(appendString); } // Convert to string, chop off trailing colon String signature = hexString.toString(); if (signature.endsWith(":")) { signature = signature.substring(0, signature.length() -1); } // Set and return mAppSignature = signature; return mAppSignature; } catch (Exception e) { Log.e(TAG, "getSignature", e); } return null; } /** * BroadcastReceiver to get result of nearby search. */ public abstract static class NearbyResultReceiver extends BroadcastReceiver { @Override public void onReceive(Context context, Intent intent) { LatLng place = intent.getParcelableExtra(EXTRA_PLACE_RESULT); onResult(place); } /** * Called when a new result is returned. * @param place resulting {@link LatLng}. */ public abstract void onResult(LatLng place); } }