/*
* 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.app.Service;
import android.content.Intent;
import android.content.res.Resources;
import android.os.Environment;
import android.os.Handler;
import android.os.HandlerThread;
import android.os.IBinder;
import android.os.Message;
import android.os.Messenger;
import android.os.RemoteException;
import android.util.Log;
import android.widget.Toast;
import com.google.android.apps.santatracker.R;
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.util.SantaLog;
import java.io.BufferedReader;
import java.io.File;
import java.io.FileReader;
import java.lang.ref.WeakReference;
import java.util.ArrayList;
import java.util.Locale;
import java.util.TimeZone;
import java.util.concurrent.LinkedBlockingDeque;
import java.util.concurrent.ThreadPoolExecutor;
import java.util.concurrent.TimeUnit;
public class SantaService extends Service implements APIProcessor.APICallback {
private static final String TAG = "SantaCommunicator";
private static final String FILENAME_OVERRIDE = "santa_config.txt";
// Parameters from config resources
private String API_URL;
private String API_CLIENT;
private String LANGUAGE;
public int INITIAL_BACKOFF_TIME;
public int MAX_BACKOFF_TIME;
public float BACKOFF_FACTOR;
private static final int TIMEZONE = TimeZone.getDefault().getRawOffset();
private long mBackoff;
private SantaPreferences mPreferences;
private DestinationDbHelper mDbHelper;
// current state of the service
private int mState = SantaServiceMessages.STATUS_IDLE_NODATA;
private ArrayList<Messenger> mClients = new ArrayList<>(2);
private final ArrayList<Messenger> mPendingClients = new ArrayList<>(2);
private Messenger mIncomingMessenger;
private APIProcessor mApiProcessor;
private static final ThreadPoolExecutor EXECUTOR = new ThreadPoolExecutor(2, 4, 60L,
TimeUnit.SECONDS, new LinkedBlockingDeque<Runnable>());
private Handler mHandler = null;
private HandlerThread mApiThread = new HandlerThread("ApiThread");
private Runnable mApiRunnable = new Runnable() {
@Override
public void run() {
// Prevent clients from being added during thread execution
synchronized (mPendingClients) {
// Sanity check to ensure that next access timestamp has been met
if (System.currentTimeMillis() < mPreferences.getNextInfoAPIAccess()) {
SantaLog.d(TAG, "Did not run API thread, next API access not expired: next="
+ mPreferences.getNextInfoAPIAccess() + " ,current=" + System
.currentTimeMillis() +
", diff=" + (mPreferences.getNextInfoAPIAccess() - System
.currentTimeMillis()));
//reschedule
scheduleApiAccess();
// skip if no clients are registered
} else if (!mClients.isEmpty() || !mPendingClients.isEmpty()) {
sendPendingState();
long delay = accessInfoAPI();
sendPendingState();
mPreferences.setNextInfoAPIAccess(delay + System.currentTimeMillis());
SantaLog.d(TAG, "delay=" + delay + ", next access=" + mPreferences
.getNextInfoAPIAccess() + " current time=" + System.currentTimeMillis()
+ " diff=" + (mPreferences.getNextInfoAPIAccess() - System
.currentTimeMillis()));
// do not reschedule unless there are clients registered
if (!mClients.isEmpty() || !mPendingClients.isEmpty()) {
scheduleApiAccess();
} else {
SantaLog.d(TAG, "No clients registered, access not scheduled.");
}
}
}
}
};
private void scheduleApiAccess() {
mHandler.removeCallbacksAndMessages(null);
final long nextAccess = mPreferences.getNextInfoAPIAccess() - System.currentTimeMillis();
if (nextAccess <= 0) {
SantaLog.d(TAG, "schedule: negative, post now.");
// run straight away
mHandler.post(mApiRunnable);
} else {
SantaLog.d(TAG, "schedule: positive, postDelayed in: " + nextAccess);
mHandler.postDelayed(mApiRunnable, nextAccess);
}
}
@Override
public void onCreate() {
super.onCreate();
mIncomingMessenger = new Messenger(new IncomingHandler(this));
mState = SantaServiceMessages.STATUS_IDLE_NODATA;
startHandlerThread();
// initialise config values
final Resources res = getResources();
INITIAL_BACKOFF_TIME = res.getInteger(R.integer.backoff_initital);
MAX_BACKOFF_TIME = res.getInteger(R.integer.backoff_max);
BACKOFF_FACTOR = ((float) res.getInteger(R.integer.backoff_factor)) / 100f;
mBackoff = INITIAL_BACKOFF_TIME;
LANGUAGE = Locale.getDefault().getLanguage();
mPreferences = new SantaPreferences(getApplicationContext());
mDbHelper = DestinationDbHelper.getInstance(getApplicationContext());
StreamDbHelper streamDbHelper = StreamDbHelper.getInstance(getApplicationContext());
// invalidate all data if database has been upgraded (or started for the
// first time)
if (mPreferences.getDestDBVersion() != DestinationDbHelper.DATABASE_VERSION ||
mPreferences.getStreamDBVersion() != StreamDbHelper.DATABASE_VERSION) {
SantaLog.d(TAG, "Data is invalid - reinitialising.");
mDbHelper.reinitialise();
streamDbHelper.reinitialise();
mPreferences.invalidateData();
mPreferences.setDestDBVersion(DestinationDbHelper.DATABASE_VERSION);
mPreferences.setStreamDBVersion(StreamDbHelper.DATABASE_VERSION);
}
// ensure a valid rand value is stored
if (mPreferences.getRandValue() < 0) {
// invalid rand value, generate new value and update preference
float rand = (float) Math.random();
mPreferences.setRandValue(rand);
}
// Read in the URL and CLIENT values from the sdcard if the file exists, otherwise use
// defaults from resources
if (!setOverrideConfigValues()) {
API_URL = res.getString(R.string.api_url);
API_CLIENT = res.getString(R.string.config_api_client);
}
// Initialise the ApiProcessor. If the client is "local" use the special debug processor for
// a local file.
if (API_CLIENT.equals("local")) {
Toast.makeText(this, "Using Local API file!", Toast.LENGTH_SHORT).show();
// For a local data file, remove all existing data first when the file is initialised
mApiProcessor = new LocalApiProcessor(mPreferences, mDbHelper, streamDbHelper, this);
} else {
// Default processor that accesses the remote api via HTTPS.
mApiProcessor = new RemoteApiProcessor(mPreferences, mDbHelper, streamDbHelper, this);
}
// Check state of data - is it up to date?
if (haveValidData()) {
mState = SantaServiceMessages.STATUS_IDLE;
} else {
mState = SantaServiceMessages.STATUS_IDLE_NODATA;
}
}
@Override
public void onDestroy() {
mIncomingMessenger = null;
super.onDestroy();
}
/**
* Attempt to read in a file from external storage with config options.
* The file needs to be located in {@link android.os.Environment#getExternalStorageDirectory()}
* and named {@link #FILENAME_OVERRIDE}. It contains one line of text: the client name,
* followed
* by the API URL.
*/
private boolean setOverrideConfigValues() {
File f = new File(Environment.getExternalStorageDirectory(), FILENAME_OVERRIDE);
if (f.exists()) {
try {
BufferedReader br = new BufferedReader(new FileReader(f));
String line = br.readLine();
br.close();
// parse the line
final int commaPosition = line.indexOf(',');
if (commaPosition > 0) {
final String client = line.substring(0, commaPosition);
final String url = line.substring(commaPosition + 1);
if (!(client.length() == 0) && !(url.length() == 0)) {
Log.d(TAG, "Config Override: client=" + client + " , url=" + url);
API_URL = url;
API_CLIENT = client;
Toast.makeText(this, "API Client Override: " + API_CLIENT,
Toast.LENGTH_LONG)
.show();
return true;
}
}
} catch (Exception e) {
// ignore
}
}
return false;
}
private boolean haveValidData() {
// Need valid preference data and more destinations
return mPreferences.hasValidData() &&
mDbHelper.getLastDeparture() > SantaPreferences.getCurrentTime();
}
/**
* Access the INFO API, returns the delay in ms when the API should be accessed again
*/
private long accessInfoAPI() {
// Access the Info API
mState = SantaServiceMessages.STATUS_PROCESSING;
// Construct URL
final String url = String.format(Locale.US, API_URL, API_CLIENT,
mPreferences.getRandValue(), mPreferences.getRouteOffset(), mPreferences.getStreamOffset(), TIMEZONE, LANGUAGE,
mPreferences.getFingerprint());
Log.d(TAG, "Tracking Santa.");
long result = mApiProcessor.accessAPI(url);
if (result < 0) {
// API access was unsuccessful, back-off and try again later
// Calculate delay, up to the max backoff time
long delay = (long) Math.min((mBackoff * BACKOFF_FACTOR), MAX_BACKOFF_TIME);
Log.d(TAG, "Couldn't communicate with Santa, trying again in: " + delay);
mBackoff = delay;
// Notify clients that there was an error and set state
if (haveValidData()) {
mState = SantaServiceMessages.STATUS_ERROR;
sendMessage(Message.obtain(null, SantaServiceMessages.MSG_ERROR));
} else {
mState = SantaServiceMessages.STATUS_ERROR_NODATA;
sendMessage(Message.obtain(null, SantaServiceMessages.MSG_ERROR_NODATA));
}
return delay;
} else {
SantaLog.d(TAG, "Accessed API, next access in: " + result);
// reset back-off time
mBackoff = INITIAL_BACKOFF_TIME;
// Notify clients that API access was successful
sendMessage(Message.obtain(null, SantaServiceMessages.MSG_SUCCESS));
mState = SantaServiceMessages.STATUS_IDLE;
return result;
}
}
@Override
public void onNewSwitchOffState(boolean isOff) {
sendMessage(SantaServiceMessages.getSwitchOffMessage(isOff));
}
@Override
public void onNewFingerprint() {
sendMessage(Message.obtain(null, SantaServiceMessages.MSG_UPDATED_FINGERPRINT));
}
@Override
public void onNewOffset() {
sendMessage(getTimeUpdateMessage());
}
@Override
public void onNewRouteLoaded() {
sendMessage(Message.obtain(null, SantaServiceMessages.MSG_UPDATED_ROUTE));
// Send a time update message, to ensure we don't leave the client in a state where it
// thinks it has a route but no timestamp information.
onNewOffset();
}
@Override
public void onNewStreamLoaded() {
sendMessage(Message.obtain(null, SantaServiceMessages.MSG_UPDATED_STREAM));
}
@Override
public void onNewNotificationStreamLoaded() {
sendMessage(Message.obtain(null, SantaServiceMessages.MSG_UPDATED_WEARSTREAM));
}
@Override
public void notifyRouteUpdating() {
sendMessage(Message.obtain(null, SantaServiceMessages.MSG_INPROGRESS_UPDATE_ROUTE));
}
@Override
public void onNewCastState(boolean isDisabled) {
sendMessage(SantaServiceMessages.getCastDisabledMessage(isDisabled));
}
@Override
public void onNewGameState(GameDisabledState state) {
sendMessage(SantaServiceMessages.getGamesMessage(state));
}
@Override
public void onNewVideos(String video1, String video15, String video23) {
sendMessage(SantaServiceMessages.getVideosMessage(video1, video15, video23));
}
@Override
public void onNewDestinationPhotoState(boolean isDisabled) {
sendMessage(SantaServiceMessages.getDestinationPhotoMessage(isDisabled));
}
@Override
public void onNewApiDataAvailable() {
// Force an API update immediately.
mPreferences.setNextInfoAPIAccess(-1);
scheduleApiAccess();
}
private void sendMessage(Message msg) {
for (int i = 0; i < mClients.size(); i++) {
try {
// TODO - Message below is duplicated to avoid
// IllegalStateException regarding queued messages.
// Is there a cleaner way to do this or avoid altogether?
Message target = new Message();
target.copyFrom(msg);
mClients.get(i).send(target);
} catch (RemoteException e) {
// Could not communicate with client, remove
mClients.remove(i);
}
}
}
@Override
public int onStartCommand(Intent intent, int flags, int startId) {
startHandlerThread();
scheduleApiAccess();
return super.onStartCommand(intent, flags, startId);
}
@Override
public IBinder onBind(Intent intent) {
startHandlerThread();
scheduleApiAccess();
return mIncomingMessenger.getBinder();
}
@Override
public boolean onUnbind(Intent intent) {
if (mPendingClients.isEmpty() && mClients.isEmpty()) {
// No clients connected, remove scheduled API execution
if (mHandler != null) {
mHandler.removeCallbacksAndMessages(null);
SantaLog.d(TAG, "last client unbind, removed scheduled threads");
}
}
return super.onUnbind(intent);
}
private void startHandlerThread() {
if (mHandler == null || !mApiThread.isAlive()) {
SantaLog.d(TAG, "startHandlerThread");
mApiThread.start();
mHandler = new Handler(mApiThread.getLooper());
}
}
private void startUpdateConfig() {
// Using a ThreadPoolExecutor here because I could not figure out how to get the
// Handler to execute this runnable. Calling mHandler.post(...) never called run().
EXECUTOR.execute(new Runnable() {
@Override
public void run() {
accessInfoAPI();
}
});
}
private Message getTimeUpdateMessage() {
final long offset = mPreferences.getOffset();
final long firstDeparture = mDbHelper.getFirstDeparture();
final long finalArrival = mDbHelper.getLastArrival();
final long finalDeparture = mDbHelper.getLastDeparture();
return SantaServiceMessages.getTimeUpdateMessage(
offset, firstDeparture, finalArrival, finalDeparture);
}
/**
* Send the current state of the application to all pending clients.
*/
private synchronized void sendPendingState() {
if (!mPendingClients.isEmpty()) {
final Message[] messages = new Message[]{
SantaServiceMessages.getBeginFullStateMessage(),
SantaServiceMessages.getSwitchOffMessage(mPreferences.getSwitchOff()),
getTimeUpdateMessage(),
SantaServiceMessages.getCastDisabledMessage(mPreferences.getCastDisabled()),
SantaServiceMessages.getGamesMessage(new GameDisabledState(mPreferences)),
SantaServiceMessages
.getDestinationPhotoMessage(mPreferences.getDestinationPhotoDisabled()),
SantaServiceMessages.getStateMessage(mState),
SantaServiceMessages.getVideosMessage(mPreferences.getVideos())
};
for (int i = 0; i < mPendingClients.size(); i++) {
final Messenger messenger = mPendingClients.get(i);
try {
for (Message msg : messages) {
messenger.send(msg);
}
// mark client as active
mClients.add(messenger);
} catch (RemoteException e) {
// client is dead, ignore client
}
mPendingClients.remove(i);
}
}
}
/**
* Handler for communication from a client to this Service. Registers and unregisters clients.
*/
static class IncomingHandler extends Handler {
private final WeakReference<SantaService> mServiceRef;
IncomingHandler(SantaService service) {
mServiceRef = new WeakReference<>(service);
}
@Override
public void handleMessage(Message msg) {
SantaService service = mServiceRef.get();
if (service == null) {
return;
}
switch (msg.what) {
case SantaServiceMessages.MSG_SERVICE_REGISTER_CLIENT:
Messenger m = msg.replyTo;
service.mPendingClients.add(m);
if (service.mState != SantaServiceMessages.STATUS_PROCESSING) {
// send data if the background process is not currently running
synchronized (service.mPendingClients) {
service.sendPendingState();
}
service.scheduleApiAccess();
} else {
// Other state, notify client right away
try {
m.send(SantaServiceMessages.getStateMessage(service.mState));
} catch (RemoteException e) {
// Could not contact client, remove from pending list
service.mPendingClients.remove(m);
}
}
break;
case SantaServiceMessages.MSG_SERVICE_UNREGISTER_CLIENT:
// Attempt to remove client from active list, alternatively from pending list
if (!service.mClients.remove(msg.replyTo)) {
service.mPendingClients.remove(msg.replyTo);
}
break;
case SantaServiceMessages.MSG_SERVICE_FORCE_SYNC:
// Attempt to sync right now (for debugging purposes)
Toast.makeText(service, "Starting sync.", Toast.LENGTH_SHORT).show();
service.startUpdateConfig();
break;
default:
super.handleMessage(msg);
break;
}
}
}
}