com.google.android.apps.santatracker.presentquest.PlacesIntentService.java Source code

Java tutorial

Introduction

Here is the source code for com.google.android.apps.santatracker.presentquest.PlacesIntentService.java

Source

/*
 * 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);

    }
}