/* * 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.service; import android.database.sqlite.SQLiteDatabase; import android.util.Log; import com.google.android.apps.santatracker.data.DestinationDbHelper; import com.google.android.apps.santatracker.data.GameDisabledState; import com.google.android.apps.santatracker.data.SantaPreferences; import com.google.android.apps.santatracker.data.StreamDbHelper; import com.google.android.apps.santatracker.data.Switches; import com.google.android.apps.santatracker.util.SantaLog; import org.json.JSONArray; import org.json.JSONException; import org.json.JSONObject; import java.io.BufferedReader; import java.io.IOException; import java.io.InputStream; import java.io.InputStreamReader; import java.util.Arrays; /** * Abstracts access to the Santa API. * This class handles the processing and interpretation of received data from the API, including * parsing the result into {@link org.json.JSONObject}s and calling the * {@link com.google.android.apps.santatracker.service.APIProcessor.APICallback} * with any changed values. */ public abstract class APIProcessor { private static final String TAG = "SantaCommunicator"; // JSON field names for parsing protected final static String FIELD_ROUTEOFFSET = "routeOffset"; protected final static String FIELD_NOW = "now"; protected final static String FIELD_TIMEOFFSET = "timeOffset"; protected final static String FIELD_FINGERPRINT = "fingerprint"; protected final static String FIELD_SWITCHOFF = "switchOff"; protected final static String FIELD_REFRESH = "refresh"; protected final static String FIELD_DISABLE_CASTBUTTON = "DisableCastButton"; protected final static String FIELD_DISABLE_PHOTO = "DisableDestinationPhoto"; protected final static String FIELD_DISABLE_GUMBALLGAME = "DisableGumballGame"; protected final static String FIELD_DISABLE_JETPACKGAME = "DisableJetpackGame"; protected final static String FIELD_DISABLE_MEMORYGAME = "DisableMemoryGame"; protected final static String FIELD_DISABLE_ROCKETGAME = "DisableRocketGame"; protected final static String FIELD_DISABLE_DANCERGAME = "DisableDancerGame"; protected final static String FIELD_DISABLE_SNOWDOWNGAME = "DisableSnowdownGame"; protected final static String FIELD_DISABLE_SWIMMINGGAME = "DisableSwimmingGame"; protected final static String FIELD_DISABLE_BMXGAME = "DisableBmxGame"; protected final static String FIELD_DISABLE_RUNNINGGAME = "DisableRunningGame"; protected final static String FIELD_DISABLE_TENNISGAME = "DisableTennisGame"; protected final static String FIELD_DISABLE_WATERPOLOGAME = "DisableWaterpoloGame"; protected final static String FIELD_DISABLE_CITY_QUIZ = "DisableCityQuiz"; protected final static String FIELD_DISABLE_PRESENTQUEST = "DisablePresentQuest"; protected final static String FIELD_VIDEO_1 = "Video1"; protected final static String FIELD_VIDEO_15 = "Video15"; protected final static String FIELD_VIDEO_23 = "Video23"; protected static final String FIELD_DESTINATIONS = "destinations"; protected static final String FIELD_STATUS = "status"; protected static final String FIELD_IDENTIFIER = "id"; protected static final String FIELD_ARRIVAL = "arrival"; protected static final String FIELD_DEPARTURE = "departure"; protected static final String FIELD_DETAILS_CITY = "city"; protected static final String FIELD_DETAILS_REGION = "region"; protected static final String FIELD_DETAILS_COUNTRY = "country"; protected static final String FIELD_DETAILS_LOCATION = "location"; protected static final String FIELD_DETAILS_LOCATION_LAT = "lat"; protected static final String FIELD_DETAILS_LOCATION_LNG = "lng"; protected static final String FIELD_DETAILS_PRESENTSDELIVERED = "presentsDelivered"; protected static final String FIELD_DETAILS_DETAILS = "details"; protected static final String FIELD_DETAILS_ALTITUDE = "altitude"; protected static final String FIELD_DETAILS_TIMEZONE = "timezone"; protected static final String FIELD_DETAILS_PHOTOS = "photos"; protected static final String FIELD_DETAILS_WEATHER = "weather"; protected static final String FIELD_DETAILS_STREETVIEW = "streetView"; protected static final String FIELD_DETAILS_GMMSTREETVIEW = "gmmStreetView"; public static final String FIELD_PHOTO_URL = "url"; public static final String FIELD_PHOTO_ATTRIBUTIONHTML= "attributionHtml"; public static final String FIELD_WEATHER_URL = "url"; public static final String FIELD_WEATHER_TEMPC = "tempC"; public static final String FIELD_WEATHER_TEMPF = "tempF"; public static final String FIELD_STREETVIEW_ID = "id"; public static final String FIELD_STREETVIEW_LATITUDE = "latitude"; public static final String FIELD_STREETVIEW_LONGITUDE = "longitude"; public static final String FIELD_STREETVIEW_HEADING = "heading"; public static final String FIELD_STREAM = "stream"; public static final String FIELD_STREAMOFFSET = "streamOffset"; public static final String FIELD_NOTIFICATIONSTREAM = "notificationStream"; public static final String FIELD_STREAM_TIMESTAMP = "timestamp"; public static final String FIELD_STREAM_STATUS = "status"; public static final String FIELD_STREAM_DIDYOUKNOW = "didyouknow"; public static final String FIELD_STREAM_IMAGEURL = "imageUrl"; public static final String FIELD_STREAM_YOUTUBEID = "youtubeId"; protected static final String FIELD_STATUS_OK = "OK"; public static final long ERROR_CODE = Long.MIN_VALUE; private static final String EMPTY_STRING = ""; // Preferences protected SantaPreferences mPreferences; // DB helpers protected DestinationDbHelper mDestinationDBHelper = null; protected StreamDbHelper mStreamDBHelper = null; // Callback private APICallback mCallback; public APIProcessor(SantaPreferences mPreferences, DestinationDbHelper mDBHelper, StreamDbHelper mStreamDBHelper, APICallback mCallback) { this.mPreferences = mPreferences; this.mDestinationDBHelper = mDBHelper; this.mStreamDBHelper = mStreamDBHelper; this.mCallback = mCallback; } /** * Load data from the API from the given URL and parse the returned data into a JSONObject. * * Implementations may ignore the URL parameter. */ protected abstract JSONObject loadApi(String url); /** Loads remotely configurable switches and flags. */ protected abstract Switches getSwitches(); /** * Access the API from a URL and process its data. * If any values have changed, the appropriate callbacks in * {@link com.google.android.apps.santatracker.service.APIProcessor.APICallback} are called. * Returns {@link #ERROR_CODE} if the data could not be loaded or processed. * Returns the delay to the next API access if the access was successful. */ public long accessAPI(String url) { SantaLog.d(TAG, "URL=" + url); // Get current values from mPreferences long offsetPref = mPreferences.getOffset(); String fingerprintPref = mPreferences.getFingerprint(); boolean switchOffPref = mPreferences.getSwitchOff(); // load data as JSON JSONObject json = loadApi(url); if (json == null) { Log.d(TAG, "Santa Communication Error 3"); return ERROR_CODE; } try { // Error if the status is not OK if (!FIELD_STATUS_OK.equals(json.getString(FIELD_STATUS))) { Log.d(TAG, "Santa Communication Error 4"); return ERROR_CODE; } final int routeOffset = json.getInt(FIELD_ROUTEOFFSET); final long now = json.getLong(FIELD_NOW); final long offset = json.getLong(FIELD_TIMEOFFSET); final String fingerprint = json.getString(FIELD_FINGERPRINT); final long refresh = json.getLong(FIELD_REFRESH); final boolean switchOff = json.getBoolean(FIELD_SWITCHOFF); final JSONArray locations = json.getJSONArray(FIELD_DESTINATIONS); final int streamOffset = json.getInt(FIELD_STREAMOFFSET); final JSONArray stream = json.getJSONArray(FIELD_STREAM); // Notification stream parameters are optional final JSONArray notificationStream = json.has(FIELD_NOTIFICATIONSTREAM) ? json.getJSONArray(FIELD_NOTIFICATIONSTREAM) : null; // Fingerprint has changed, remove route and stream from db if (!fingerprint.equals(fingerprintPref)) { mCallback.notifyRouteUpdating(); //empty the database and reset preferences mDestinationDBHelper.emptyDestinationTable(); mStreamDBHelper.emptyCardTable(); mPreferences.invalidateData(); } // Destinations if (locations != null && locations.length() > 0) { int processedLocations = processRoute(locations); if (processedLocations > 0) { final int newOffset = routeOffset + processedLocations; mCallback.onNewRouteLoaded(); mPreferences.setFingerprint(fingerprint); mPreferences.setRouteOffset(newOffset); SantaLog.d(TAG, "Processed route - new details: " + newOffset + ", " + fingerprint); } } // Stream if (stream != null && stream.length() > 0) { // process non-notification cards int processedCards = processStream(stream, false); if (processedCards > 0) { final int newOffset = streamOffset + processedCards; mCallback.onNewStreamLoaded(); mPreferences.setStreamOffset(newOffset); SantaLog.d(TAG, "Processed stream - new details: " + newOffset); } } // Notification Stream if (notificationStream != null && notificationStream.length() > 0) { // process notification cards int processedCards = processStream(notificationStream, true); if (processedCards > 0) { mCallback.onNewNotificationStreamLoaded(); SantaLog.d(TAG, "Processed notification stream - count: " + processedCards); } } // Offset final long newOffset = now - System.currentTimeMillis() + offset; if (offsetPref != newOffset) { mPreferences.setOffset(newOffset); SantaLog.d(TAG, "New offset: " + newOffset + ", current=" + System.currentTimeMillis() + ", new Santa=" + SantaPreferences.getCurrentTime()); // Log.d(TAG, "new offset: new="+newOffset+", now="+now+", offset="+offset+", // prefOffset="+offsetPref+", time="+System.currentTimeMillis()); // Notify only if offset varies significantly if ((newOffset > offsetPref + SantaPreferences.OFFSET_ACCEPTABLE_RANGE_DIFFERENCE || newOffset < offsetPref - SantaPreferences.OFFSET_ACCEPTABLE_RANGE_DIFFERENCE)) { mCallback.onNewOffset(); } } if (switchOffPref != switchOff) { mPreferences.setSwitchOff(switchOff); mCallback.onNewSwitchOffState(switchOff); } // Check Switches for Changes checkSwitchesDiff(getSwitches()); if (!fingerprint.equals(fingerprintPref)) { // new data has been processed and locations have been stored mCallback.onNewFingerprint(); } return refresh; } catch (JSONException e) { Log.d(TAG, "Santa Communication Error 5"); SantaLog.d(TAG, "JSON Exception", e); return ERROR_CODE; } } /** * Compare Switches to SharedPreferences and notify clients of any changes. */ protected void checkSwitchesDiff(Switches s) { if (mPreferences.getCastDisabled() != s.disableCastButton) { // set cast preference mPreferences.setCastDisabled(s.disableCastButton); mCallback.onNewCastState(s.disableCastButton); } if (mPreferences.getDestinationPhotoDisabled() != s.disableDestinationPhoto) { // set destination photo preference mPreferences.setDestinationPhotoDisabled(s.disableDestinationPhoto); mCallback.onNewDestinationPhotoState(s.disableDestinationPhoto); } // Games if (!mPreferences.gameDisabledStateConsistent(s.gameState)) { // Overwrite game disabled state from Switches mPreferences.setGamesDisabled(s.gameState); // Notify of new game state mCallback.onNewGameState(s.gameState); } // Videos boolean videosConsistent = Arrays.equals( new String[]{s.video1, s.video15, s.video23}, mPreferences.getVideos()); if (!videosConsistent) { mPreferences.setVideos(s.video1, s.video15, s.video23); mCallback.onNewVideos(s.video1, s.video15, s.video23); } } private int processRoute(JSONArray json) { SQLiteDatabase db = mDestinationDBHelper.getWritableDatabase(); db.beginTransaction(); try { // loop over each destination long previousPresents = mPreferences.getTotalPresents(); int i; for (i = 0; i < json.length(); i++) { JSONObject dest = json.getJSONObject(i); JSONObject location = dest .getJSONObject(FIELD_DETAILS_LOCATION); long presentsTotal = dest .getLong(FIELD_DETAILS_PRESENTSDELIVERED); long presents = presentsTotal - previousPresents; previousPresents = presentsTotal; // Name String city = dest.getString(FIELD_DETAILS_CITY); String region = null; String country = null; if (dest.has(FIELD_DETAILS_REGION)) { region = dest.getString(FIELD_DETAILS_REGION); if (region.length() < 1) { region = null; } } if (dest.has(FIELD_DETAILS_COUNTRY)) { country = dest.getString(FIELD_DETAILS_COUNTRY); if (country.length() < 1) { country = null; } } // if (mDebugLog) { // Log.d(TAG, "Location: " + city); // } // Detail fields JSONObject details = dest.getJSONObject(FIELD_DETAILS_DETAILS); long timezone = details.isNull(FIELD_DETAILS_TIMEZONE) ? 0L : details.getLong(FIELD_DETAILS_TIMEZONE); long altitude = details.getLong(FIELD_DETAILS_ALTITUDE); String photos = details.has(FIELD_DETAILS_PHOTOS) ? details .getString(FIELD_DETAILS_PHOTOS) : EMPTY_STRING; String weather = details.has(FIELD_DETAILS_WEATHER) ? details .getString(FIELD_DETAILS_WEATHER) : EMPTY_STRING; String streetview = details.has(FIELD_DETAILS_STREETVIEW) ? details .getString(FIELD_DETAILS_STREETVIEW) : EMPTY_STRING; String gmmStreetview = details.has(FIELD_DETAILS_GMMSTREETVIEW) ? details .getString(FIELD_DETAILS_GMMSTREETVIEW) : EMPTY_STRING; try { // All parsed, insert into DB mDestinationDBHelper.insertDestination(db, dest.getString(FIELD_IDENTIFIER), dest.getLong(FIELD_ARRIVAL), dest.getLong(FIELD_DEPARTURE), city, region, country, location.getDouble(FIELD_DETAILS_LOCATION_LAT), location.getDouble(FIELD_DETAILS_LOCATION_LNG), presentsTotal, presents, timezone, altitude, photos, weather, streetview, gmmStreetview); } catch (android.database.sqlite.SQLiteConstraintException e) { // ignore duplicate locations } } db.setTransactionSuccessful(); // Update mPreferences mPreferences.setDBTimestamp(System.currentTimeMillis()); mPreferences.setTotalPresents(previousPresents); return i; } catch (JSONException e) { Log.d(TAG, "Santa location tracking error 30"); SantaLog.d(TAG, "JSON Exception", e); } finally { db.endTransaction(); } return 0; } private int processStream(JSONArray json, boolean isWear) { SQLiteDatabase db = mStreamDBHelper.getWritableDatabase(); db.beginTransaction(); try { // loop over each card int i; for (i = 0; i < json.length(); i++) { JSONObject card = json.getJSONObject(i); final long timestamp = card .getLong(FIELD_STREAM_TIMESTAMP); final String status = getExistingJSONString(card, FIELD_STREAM_STATUS); final String didYouKnow = getExistingJSONString(card, FIELD_STREAM_DIDYOUKNOW); final String imageUrl = getExistingJSONString(card, FIELD_STREAM_IMAGEURL); final String youtubeId = getExistingJSONString(card, FIELD_STREAM_YOUTUBEID); // if (mDebugLog) { // Log.d(TAG, "Notification: " + timestamp); // } try { // All parsed, insert into DB mStreamDBHelper.insert(db, timestamp, status, didYouKnow, imageUrl, youtubeId, isWear); } catch (android.database.sqlite.SQLiteConstraintException e) { // ignore duplicate cards } } db.setTransactionSuccessful(); return i; } catch (JSONException e) { Log.d(TAG, "Santa location tracking error 31"); SantaLog.d(TAG, "JSON Exception", e); } finally { db.endTransaction(); } return 0; } // Reads an InputStream and converts it to a String. protected static StringBuilder read(InputStream stream) throws IOException { BufferedReader reader; StringBuilder builder = new StringBuilder(); reader = new BufferedReader(new InputStreamReader(stream, "UTF-8")); for (String line; (line = reader.readLine()) != null; ) { builder.append(line); } return builder; } /** * Returns the value of the JSON field identified by the name. Returns null if the field does * not exist. */ public static String getExistingJSONString(JSONObject json, String name) throws JSONException { if (json.has(name)) { return json.getString(name); } else { return null; } } /** * Returns the double of the JSON object identified by the name. Returns {@link * java.lang.Double#MAX_VALUE} if the field does not exist. */ public static double getExistingJSONDouble(JSONObject json, String name) throws JSONException { if (json.has(name)) { return json.getDouble(name); } else { return Double.MAX_VALUE; } } interface APICallback { void onNewSwitchOffState(boolean isOn); /** * Called when a new fingerprint has been detected and stored data * will be cleared to process the new route. * * @see #onNewRouteLoaded() */ void onNewFingerprint(); void onNewOffset(); /** * Called when new data has been processed. */ void onNewRouteLoaded(); void onNewStreamLoaded(); void onNewNotificationStreamLoaded(); void notifyRouteUpdating(); void onNewCastState(boolean disableCast); void onNewGameState(GameDisabledState state); void onNewVideos(String video1, String video15, String video23); void onNewDestinationPhotoState(boolean disableDestinationPhoto); void onNewApiDataAvailable(); } }