com.halseyburgund.rwframework.core.RWService.java Source code

Java tutorial

Introduction

Here is the source code for com.halseyburgund.rwframework.core.RWService.java

Source

/*
ROUNDWARE
   a participatory, location-aware media platform
   Android client library
  Copyright (C) 2008-2012 Halsey Solutions, LLC
   with contributions by Rob Knapen (shuffledbits.com) and Dan Latham
   http://roundware.org | contact@roundware.org
    
   This program is free software: you can redistribute it and/or modify
   it under the terms of the GNU General Public License as published by
   the Free Software Foundation, either version 3 of the License, or
   (at your option) any later version.
    
This program is distributed in the hope that it will be useful,
   but WITHOUT ANY WARRANTY; without even the implied warranty of
   MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
GNU General Public License for more details.
    
You should have received a copy of the GNU General Public License
along with this program.  If not, see <http://www.gnu.org/licenses/>.
 */
package com.halseyburgund.rwframework.core;

import java.io.File;
import java.io.PrintWriter;
import java.io.StringWriter;
import java.io.Writer;
import java.net.UnknownHostException;
import java.util.Observable;
import java.util.Observer;
import java.util.Timer;
import java.util.TimerTask;

import org.apache.http.HttpException;
import org.apache.http.HttpStatus;
import org.json.JSONException;
import org.json.JSONObject;

import android.app.Notification;
import android.app.NotificationManager;
import android.app.PendingIntent;
import android.app.Service;
import android.content.BroadcastReceiver;
import android.content.Context;
import android.content.Intent;
import android.content.IntentFilter;
import android.location.Location;
import android.media.AudioManager;
import android.media.MediaPlayer;
import android.net.ConnectivityManager;
import android.net.NetworkInfo;
import android.os.AsyncTask;
import android.os.Binder;
import android.os.IBinder;
import android.os.PowerManager;
import android.util.Log;

import com.halseyburgund.rwframework.R;
import com.halseyburgund.rwframework.util.RWList;
import com.halseyburgund.rwframework.util.RWSharedPrefsHelper;

/**
 * Service for background playback of Roundware sound stream and handle the
 * various server calls. Calls can either be queued that is checked at a
 * regular interval, or sent directly. 
 * 
 * Notes:
 * High level Roundware server communication related methods are all
 * prefixed with 'rw' (rwRequestStream, rwModifyStream, rwSubmit, etc.).
 * 
 * High level methods for controlling sound playback all are prefix with
 * 'playback' (playbackStart, playbackStop, playbackFadeIn, etc.).
 * 
 * @author Rob Knapen
 */
public class RWService extends Service implements Observer {

    // debugging
    private final static String TAG = "RWService";
    private final static boolean D = false;

    // playback notification
    private final static int NOTIFICATION_ID = 10001;

    /**
     * Connection states of the Roundware session.
     */
    public enum SessionState {
        /**
         * RWService session has not yet been successfully initialized. 
         */
        UNINITIALIZED,

        /**
         * Session is on-line; project configuration has been retrieved
         * and a session ID is available.
         */
        ON_LINE,

        /**
         * Session is off-line; last tried server operation timed out. 
         */
        OFF_LINE
    };

    /**
     * Types of server messages that can be decoded.
     */
    private enum ServerMessageType {
        ERROR, USER, SHARING, TRACEBACK
    };

    // service binder
    private final IBinder mBinder = new RWServiceBinder();

    // fields
    private RWActionFactory mActionFactory;
    private MediaPlayer mPlayer;
    private Timer mQueueTimer;
    private long mLastRequestMsec;
    private long mLastStateChangeMsec;

    private RWStreamAssetsTracker mAssetTracker;

    private PendingIntent mNotificationPendingIntent;
    private Notification mRwNotification;
    private String mNotificationTitle;
    private String mNotificationDefaultText;
    private int mNotificationIconId;
    private Class<?> mNotificationActivity;

    private String mServerUrl;
    private String mStreamUrl;
    private boolean mShowDetailedMessages = false;
    private boolean mStartPlayingWhenReady = false;
    private boolean mOnlyConnectOverWiFi = false;
    private int mVolumeLevel = 0;
    private int mMinVolumeLevel = 0;
    private int mMaxVolumeLevel = 50;
    private float mVolumeStepMultiplier = 0.95f; // 1 dB = 0.89

    private SessionState mSessionState = SessionState.UNINITIALIZED;

    private RWConfiguration configuration;
    private RWTags tags;

    /**
     * Service binder used to communicate with the Roundware service.
     * 
     * @author Rob Knapen
     */
    public class RWServiceBinder extends Binder {
        public RWService getService() {
            return RWService.this;
        }
    }

    /**
     * Async Task to retrieve the configuration settings for a project from
     * the server. When the request is successful it will also start the timer
     * that controls the queue processing and sending idle pings when playing
     * a stream and there is no other activity. 
     */
    private class RetrieveConfigurationTask extends AsyncTask<Void, Void, String> {

        private Context context = null;
        private String deviceId = null;
        private String projectId = null;

        public RetrieveConfigurationTask(Context context, String deviceId, String projectId) {
            this.context = context;
            this.deviceId = deviceId;
            this.projectId = projectId;
        }

        @Override
        protected String doInBackground(Void... params) {
            if (D) {
                Log.d(TAG, "Retrieving configuration for project", null);
            }
            return perform(mActionFactory.createRetrieveProjectConfigurationAction(deviceId, projectId), true);
        }

        @Override
        protected void onPostExecute(String result) {
            super.onPostExecute(result);
            if (D) {
                Log.d(TAG, "Retrieve project configuration result: " + result, null);
            }

            // try to use cache when no server data received
            boolean usingCache = false;
            if (result == null) {
                result = RWSharedPrefsHelper.loadJSONArray(context, RW.PROJECT_CONFIG_CACHE, projectId);
                usingCache = true;
            } else {
                // cache current data
                RWSharedPrefsHelper.saveJSONArray(context, RW.PROJECT_CONFIG_CACHE, projectId, result);
            }

            if (result == null) {
                Log.i(TAG, "Could not retrieve configuration data from server and no cached data available!");
                broadcast(RW.NO_CONFIGURATION);
            } else {
                configuration.assignFromJsonServerResponse(result, usingCache);
                if (usingCache) {
                    configuration.setSessionId("-1");
                }
                broadcast(RW.CONFIGURATION_LOADED);
            }
        }
    }

    /**
     * Async Task to retrieve the configuration settings for a project from
     * the server. When the request is successful it will also start the timer
     * that controls the queue processing and sending idle pings when playing
     * a stream and there is no other activity. 
     */
    private class RetrieveTagsTask extends AsyncTask<Void, Void, String> {

        private Context context = null;
        private String projectId = null;

        public RetrieveTagsTask(Context context, String projectId) {
            this.context = context;
            this.projectId = projectId;
        }

        @Override
        protected String doInBackground(Void... params) {
            if (D) {
                Log.d(TAG, "Retrieving tags for project", null);
            }
            return perform(mActionFactory.createRetrieveTagsForProjectAction(projectId), true);
        }

        @Override
        protected void onPostExecute(String result) {
            super.onPostExecute(result);
            if (D) {
                Log.d(TAG, "Retrieve project tags result: " + result, null);
            }

            // try to use cache when no server data received
            boolean usingCache = false;
            if (result == null) {
                result = RWSharedPrefsHelper.loadJSONObject(context, RW.PROJECT_TAGS_CACHE, projectId);
                usingCache = true;
            } else {
                // cache current data
                RWSharedPrefsHelper.saveJSONObject(context, RW.PROJECT_TAGS_CACHE, projectId, result);
            }

            if (result == null) {
                Log.w(TAG, "Could not retrieve tags data from server and no cached data available!");
                broadcast(RW.NO_TAGS);
            } else {
                tags.assignFromJsonServerResponse(result, usingCache);
                broadcast(RW.TAGS_LOADED);
            }
        }
    }

    /**
     * Async Task to start play back of a sound stream from the server. When the
     * request is successful it will also start the ping timer that sends heart
     * beats back to the server.
     */
    private class StartPlaybackTask extends AsyncTask<Void, Void, String> {

        private RWList selections;

        public StartPlaybackTask(RWList tags) {
            this.selections = tags;
        }

        @Override
        protected String doInBackground(Void... params) {
            // start initial stream without selection
            if (D) {
                Log.d(TAG, "Starting Playback from Service", null);
            }
            return perform(mActionFactory.createRequestStreamAction(selections), true);
        }

        @Override
        protected void onPostExecute(String result) {
            super.onPostExecute(result);
            if (D) {
                Log.d(TAG, "Starting Playback from Service result: " + result, null);
            }
            // check for errors
            mStreamUrl = null;
            if (result == null) {
                Log.e(TAG, "Operation failed, no response available to start audio stream from.", null);
                broadcast(RW.UNABLE_TO_PLAY);
            } else {
                String streamUrlKey = getString(R.string.rw_key_stream_url);
                try {
                    JSONObject jsonObj = new JSONObject(result);
                    mStreamUrl = jsonObj.optString(streamUrlKey, null);
                } catch (JSONException e) {
                    Log.e(TAG, "Invalid response from server", e);
                    broadcast(RW.UNABLE_TO_PLAY);
                }

                if ((mStreamUrl == null) || (mStreamUrl.length() == 0)) {
                    broadcast(RW.UNABLE_TO_PLAY);
                } else {
                    if (D) {
                        Log.d(TAG, "Starting MediaPlayer for stream: " + mStreamUrl, null);
                    }
                    try {
                        mPlayer.reset();
                        mPlayer.setAudioStreamType(AudioManager.STREAM_MUSIC);
                        mPlayer.setDataSource(mStreamUrl);
                        mPlayer.prepareAsync();

                        // send log message about stream started
                        rwSendLogEvent(R.string.rw_et_start_listen, null, null, true);
                    } catch (Exception ex) {
                        Log.e(TAG, ex.toString());
                        broadcast(RW.UNABLE_TO_PLAY);

                        // broadcast error message
                        Intent intent = new Intent();
                        String message = getString(R.string.roundware_error_mediaplayer_problem);
                        message = message + "\n\nException: " + ex.getMessage();
                        intent.setAction(RW.ERROR_MESSAGE);
                        intent.putExtra(RW.EXTRA_SERVER_MESSAGE, message);
                        if (D) {
                            Log.d(TAG, "Going to send broadcast event, error message = " + message, null);
                        }
                        sendBroadcast(intent);
                    }
                }
            }
        }
    }

    /**
     * Receiver for connectivity state events. Used to manage the session
     * on-line and off-line state.
     */
    private BroadcastReceiver connectivityReceiver = new BroadcastReceiver() {
        @Override
        public void onReceive(Context context, Intent intent) {
            boolean noConnectivity = intent.getBooleanExtra(ConnectivityManager.EXTRA_NO_CONNECTIVITY, false);
            if (noConnectivity) {
                if (mSessionState == SessionState.ON_LINE) {
                    manageSessionState(SessionState.OFF_LINE);
                }
            } else {
                if (mSessionState == SessionState.OFF_LINE) {
                    if (!mOnlyConnectOverWiFi) {
                        manageSessionState(SessionState.ON_LINE);
                    } else {
                        NetworkInfo currentNetworkInfo = (NetworkInfo) intent
                                .getParcelableExtra(ConnectivityManager.EXTRA_NETWORK_INFO);
                        if (currentNetworkInfo != null) {
                            if ((currentNetworkInfo.isConnected())
                                    && (currentNetworkInfo.getType() == ConnectivityManager.TYPE_WIFI)) {
                                manageSessionState(SessionState.ON_LINE);
                            }
                        }
                    }
                }
            }
        }
    };

    /**
     * Receiver for own Roundware broadcasts. Used to manage the session
     * on-line and off-line state.
     */
    private BroadcastReceiver rwReceiver = new BroadcastReceiver() {
        @Override
        public void onReceive(Context context, Intent intent) {
            // configuration loaded - switch from uninitialized to on-line or off-line
            // depending on using server or cached configuration data
            if (RW.CONFIGURATION_LOADED.equalsIgnoreCase(intent.getAction())) {
                if (mSessionState == SessionState.UNINITIALIZED) {
                    if (configuration.getDataSource() != RWConfiguration.FROM_SERVER) {
                        manageSessionState(SessionState.OFF_LINE);
                    } else {
                        manageSessionState(SessionState.ON_LINE);
                    }
                } else {
                    // refresh tags (now that the session id is known)
                    if (tags.getDataSource() != RWTags.FROM_SERVER) {
                        new RetrieveTagsTask(context, configuration.getProjectId()).execute();
                    }
                }
            } else if (RW.NO_CONFIGURATION.equalsIgnoreCase(intent.getAction())) {
                // loading configuration failed - switch to uninitialized if needed
                if (mSessionState != SessionState.UNINITIALIZED) {
                    manageSessionState(SessionState.UNINITIALIZED);
                }
            }

            // operation failed, if due to timeout (UknownHostException) switch to
            // from on-line to off-line
            if (intent.getAction().endsWith(RW.BROADCAST_FAILURE_POSTFIX)) {
                Throwable e = (Throwable) intent.getExtras().get(RW.EXTRA_FAILURE_EXCEPTION);
                if (e instanceof UnknownHostException) {
                    if (mSessionState == SessionState.ON_LINE) {
                        manageSessionState(SessionState.OFF_LINE);
                    }
                } else {
                    // send error log back to server for later analysis
                    final Writer result = new StringWriter();
                    final PrintWriter printWriter = new PrintWriter(result);
                    e.printStackTrace(printWriter);
                    rwSendLogEvent(R.string.rw_et_client_error, null, result.toString(), true);
                }
            }

            // operation successful, if off-line switch to on-line
            // fail-safe, probably should never happen this way since
            // connectivity will be restored first and state already updated
            if (intent.getAction().endsWith(RW.BROADCAST_SUCCESS_POSTFIX)) {
                if (mSessionState == SessionState.OFF_LINE) {
                    NetworkInfo currentNetworkInfo = (NetworkInfo) intent
                            .getParcelableExtra(ConnectivityManager.EXTRA_NETWORK_INFO);
                    if (currentNetworkInfo != null) {
                        if ((!mOnlyConnectOverWiFi)
                                || (currentNetworkInfo.getType() == ConnectivityManager.TYPE_WIFI)) {
                            manageSessionState(SessionState.ON_LINE);
                        }
                    }
                }
            }
        }
    };

    /**
     * Adds to the specified IntentFilter the actions for the SUCCESS,
     * FAILURE, and QUEUED intent broadcasts for the server calls with the
     * given operationNames.
     * 
     * @param filter IntentFilter to add actions to
     * @param operationNames to create action filters for
     * @return updated IntentFilter
     */
    public static IntentFilter addOperationsToIntentFilter(IntentFilter filter, String... operationNames) {
        for (String opName : operationNames) {
            filter.addAction(RW.BROADCAST_PREFIX + opName + RW.BROADCAST_FAILURE_POSTFIX);
            filter.addAction(RW.BROADCAST_PREFIX + opName + RW.BROADCAST_SUCCESS_POSTFIX);
            filter.addAction(RW.BROADCAST_PREFIX + opName + RW.BROADCAST_QUEUED_POSTFIX);
        }

        return filter;
    }

    /**
     * Creates an IntentFilter for the SUCCESS, FAILURE and QUEUED broadcast
     * intents for all the current server calls.
     * 
     * @return IntentFilter to receive low level server call broadcasts
     */
    public IntentFilter createOperationsIntentFilter() {
        IntentFilter filter = new IntentFilter();
        return addOperationsToIntentFilter(filter, getString(R.string.rw_op_add_asset_to_envelope),
                getString(R.string.rw_op_create_envelope), getString(R.string.rw_op_get_config),
                getString(R.string.rw_op_get_stream), getString(R.string.rw_op_get_tags),
                getString(R.string.rw_op_heartbeat), getString(R.string.rw_op_log_event),
                getString(R.string.rw_op_modify_stream));
    }

    @Override
    public void onCreate() {
        super.onCreate();

        // create default configuration and tags
        configuration = new RWConfiguration(this);
        tags = new RWTags();

        // get default server url from resources and reset other critical vars
        mServerUrl = getString(R.string.rw_spec_server_url);
        mStreamUrl = null;

        // create a factory for actions
        mActionFactory = new RWActionFactory(this);

        // create a queue for actions
        RWActionQueue.instance().init(this);

        // listen to connectivity state broadcasts
        registerReceiver(connectivityReceiver, new IntentFilter(ConnectivityManager.CONNECTIVITY_ACTION));
        // listen to own server calls success and failure broadcasts
        IntentFilter filter = createOperationsIntentFilter();
        filter.addAction(RW.CONFIGURATION_LOADED);
        filter.addAction(RW.NO_CONFIGURATION);
        registerReceiver(rwReceiver, filter);

        // setup for GPS callback
        RWLocationTracker.instance().init(this);
        RWLocationTracker.instance().addObserver(this);

        // setup a tracker for assets streamed by the server
        mAssetTracker = new RWStreamAssetsTracker(this);
    }

    /**
     * Checks if the device's GPS is available and switched on.
     * 
     * @return true when GPS is enabled
     */
    public boolean isGpsEnabled() {
        return RWLocationTracker.instance().isGpsEnabled();
    }

    /**
     * Checks if the device's Network based location service is
     * available and may be used.
     * 
     * @return true when Network location service is enabled
     */
    public boolean isNetworkLocationEnabled() {
        return RWLocationTracker.instance().isNetworkLocationEnabled();
    }

    /**
     * Request location updates from the most accurate available location
     * service (GPS, Network).
     */
    private void startLocationUpdates() {
        if ((configuration != null) && (configuration.isUsingLocation())) {
            RWLocationTracker.instance().startLocationUpdates(configuration.getMinLocationUpdateTimeMSec(),
                    (float) configuration.getMinLocationUpdateDistanceMeter());
        }
    }

    /**
     * Stop receiving location updates, and allows the device to shutdown the
     * location service providers to reduce power consumption.
     */
    private void stopLocationUpdates() {
        RWLocationTracker.instance().stopLocationUpdates();
    }

    /**
     * Retrieves the last known location by the location services of the
     * device. This does not force updating and might be out of date.
     * 
     * @return Location, last known by the location services
     */
    public Location getLastKnownLocation() {
        return RWLocationTracker.instance().getLastLocation();
    }

    @Override
    public IBinder onBind(Intent intent) {
        return mBinder;
    }

    @Override
    public int onStartCommand(Intent intent, int flags, int startId) {
        // intent will be null on restart!
        if (intent != null) {
            getSettingsFromIntent(intent);
        }

        // create a pending intent to start the specified activity from the notification
        Intent ovIntent = new Intent(this, mNotificationActivity);
        mNotificationPendingIntent = PendingIntent.getActivity(this, 0, ovIntent, Intent.FLAG_ACTIVITY_NEW_TASK);

        // create a notification and move service to foreground
        mRwNotification = new Notification(mNotificationIconId, "Roundware Service Started",
                System.currentTimeMillis());
        mRwNotification.number = 1;
        mRwNotification.flags = mRwNotification.flags | Notification.FLAG_FOREGROUND_SERVICE
                | Notification.FLAG_ONGOING_EVENT | Notification.FLAG_NO_CLEAR;
        setNotificationText("");

        startForeground(NOTIFICATION_ID, mRwNotification);

        // try to go on-line, this will attempt to get the configuration and tags
        manageSessionState(SessionState.ON_LINE);

        return Service.START_STICKY;
    }

    /**
     * When not already playing, create a music player and start its
     * initialization in the background. Use a broadcast receiver to
     * be notified of completion or errors. 
     * 
     * @param tags of tags options for the audio
     */
    public void playbackStart(RWList tags) {
        if (!isPlaying()) {
            createPlayer();
            new StartPlaybackTask(tags).execute();
        }
    }

    /**
     * Stops music playback and releases the music player used, allowing the
     * device to free up the memory. Call this method when no longer interested
     * in listening to the audio stream.
     */
    public void playbackStop() {
        releasePlayer();
    }

    private void getSettingsFromIntent(Intent intent) {
        if ((intent != null) && (intent.getExtras() != null)) {
            // get device id from intent
            String deviceId = intent.getExtras().getString(RW.EXTRA_DEVICE_ID);
            if (deviceId != null) {
                configuration.setDeviceId(deviceId);
            }

            // get project id from intent
            String projectId = intent.getExtras().getString(RW.EXTRA_PROJECT_ID);
            if (projectId != null) {
                configuration.setProjectId(projectId);
            }

            // server url override (can be null)
            String serverUrlOverride = intent.getExtras().getString(RW.EXTRA_SERVER_URL_OVERRIDE);
            if ((serverUrlOverride != null) && (serverUrlOverride.length() > 0)) {
                mServerUrl = serverUrlOverride;
            }

            // notification icon and handling class
            mNotificationTitle = intent.getExtras().getString(RW.EXTRA_NOTIFICATION_TITLE);
            if (mNotificationTitle == null) {
                mNotificationTitle = "Roundware";
            }
            mNotificationDefaultText = intent.getExtras().getString(RW.EXTRA_NOTIFICATION_DEFAULT_TEXT);
            if (mNotificationDefaultText == null) {
                mNotificationDefaultText = "Return to app";
            }
            mNotificationIconId = intent.getExtras().getInt(RW.EXTRA_NOTIFICATION_ICON_ID, R.drawable.status_icon);
            String className = intent.getExtras().getString(RW.EXTRA_NOTIFICATION_ACTIVITY_CLASS_NAME);
            try {
                if (className != null) {
                    mNotificationActivity = Class.forName(className);
                }
            } catch (Exception e) {
                Log.e(TAG, "Unknown class specificied for handling " + "notification: " + className);
                mNotificationActivity = null;
            }
        }
    }

    @Override
    public void onDestroy() {
        stopService();
        stopLocationUpdates();
        unregisterReceiver(connectivityReceiver);
        unregisterReceiver(rwReceiver);
        super.onDestroy();
    }

    /**
     * Shut down the RWService instance. Makes sure the location updates
     * are stopped, the background processing of the action queue is stopped,
     * the music player is stopped and released, and finally the service
     * itself is terminated. 
     */
    public void stopService() {
        stopQueueTimer();
        releasePlayer();
        stopSelf();
    }

    /**
     * Receives and handles location updates from RWLocationTracker.
     */
    @Override
    public void update(Observable observable, Object data) {
        Location location = RWLocationTracker.instance().getLastLocation();
        if (location == null) {
            if (D) {
                Log.d(TAG, getString(R.string.roundware_no_location_info), null);
            }
        } else {
            double lat = location.getLatitude();
            double lon = location.getLongitude();
            if (D) {
                Log.d(TAG,
                        String.format(
                                "session id '%s': New position info lat=%.6f lon=%" + ".6f"
                                        + " provider=%s accuracy=%.6fm",
                                configuration.getSessionId(), lat, lon, location.getProvider(),
                                location.getAccuracy()),
                        null);
            }
            rwSendMoveListener(true);
            broadcastLocationUpdate(lat, lon, location.getProvider(), location.getAccuracy());
        }
    }

    /**
     * Updates the text in the RWService notification placed in the Android
     * notification bar on the screen of the device.
     * 
     * @param message to be displayed
     */
    public void setNotificationText(String message) {
        if ((mRwNotification != null) && (mNotificationPendingIntent != null)) {
            NotificationManager nm = (NotificationManager) getSystemService(Context.NOTIFICATION_SERVICE);
            if (nm != null) {
                if (message != null) {
                    boolean debugMsg = message.startsWith(".");
                    String msg = debugMsg ? message.subSequence(1, message.length()).toString() : message;

                    boolean defaultMsg = message.equalsIgnoreCase(mNotificationDefaultText);

                    if ((!debugMsg) || (mShowDetailedMessages)) {
                        mRwNotification.setLatestEventInfo(this, mNotificationTitle, msg,
                                mNotificationPendingIntent);
                        if (!defaultMsg) {
                            mRwNotification.tickerText = msg;
                        } else {
                            mRwNotification.tickerText = "";
                        }
                    }
                }

                mRwNotification.when = System.currentTimeMillis();
                mRwNotification.number = RWActionQueue.instance().count();
                nm.notify(NOTIFICATION_ID, mRwNotification);
            }
        }
    }

    /**
     * Access the factory that can be used to create RWAction instances
     * that are usable with this RWService instance and the project it is
     * configured for.
     * 
     * @return RWActionFactory for creating RWAction instances
     */
    public RWActionFactory getActionFactory() {
        return mActionFactory;
    }

    /**
     * Access the configuration information of this RWService instance.
     * When it is started and initialized the first thing the RWService
     * does is to read the configuration info for a project from the
     * Roundware server. Various project specific settings are then made
     * available through this RWConfiguration instance.
     * 
     * Note: Use a broadcast receiver for the RW_SESSION_INITIALISED
     * intent to be notified when the configuration has been fully
     * received from the server.
     * 
     * @return RWConfiguration with Roundware project settings
     */
    public RWConfiguration getConfiguration() {
        return configuration;
    }

    /**
     * Access the tags information of this RWService instance. After
     * initializing and retrieving the project configuration from the
     * server, the RWService also retrieves the tags for the project.
     * Tags can be used to mark recordings, specify selections, etc.
     * 
     * Note: Use a broadcast receiver for the RW_TAGS_LOADED intent
     * to be notified when the tags have been fully received from the
     * server.
     * 
     * @return RWTags with the Roundware project tags
     */
    public RWTags getTags() {
        return tags;
    }

    /**
     * Returns the currently used URL to access the Roundware server.
     * 
     * @return URL for Roundware server
     */
    public String getServerUrl() {
        return mServerUrl;
    }

    /**
     * Returns the URL of the current audio stream (if any).
     * 
     * @return URL of current audio stream
     */
    public String getStreamUrl() {
        return mStreamUrl;
    }

    /**
     * Checks if the current session id indicates that the media player is
     * playing the static soundtrack from the server. This stream is e.g.
     * returned when the user starts the app outside the geographical range of
     * the project.
     * 
     * @return true when the media player is playing the static soundtrack
     */
    public boolean isPlayingStaticSoundtrack() {
        String val = getString(R.string.rw_spec_static_soundtrack_session_id);
        String sessionId = configuration.getSessionId();
        if ((val != null) && (sessionId != null) && (sessionId.equals(val))) {
            return true;
        } else {
            return false;
        }
    }

    /**
     * Returns true if the music player is currently playing the audio stream
     * and is not muted.
     * 
     * @return true if the player is playing and not muted
     */
    public boolean isPlaying() {
        if (mPlayer == null) {
            return false;
        } else {
            return mPlayer.isPlaying() && (mVolumeLevel > 0);
        }
    }

    /**
     * Returns true if the music player is currently playing the audio stream,
     * but the sound is muted.
     * 
     * Note: although resource inefficient muting the player to simulated a
     * paused state is currently easier than stopping the stream and later
     * trying to regain it with some kind of buffering in between. In the
     * future more advanced solutions might be implemented. 
     * 
     * @return true if the player is playing and the volume is muted
     */
    public boolean isPlayingMuted() {
        if (mPlayer == null) {
            return false;
        } else {
            return mPlayer.isPlaying() && (mVolumeLevel == 0);
        }
    }

    /**
     * Returns true if the RWService is displaying more detailed messages,
     * e.g. in its notifications. This is mostly for debugging purposes or
     * for advanced users.
     * 
     * @return true if detailed messages are being displayed
     */
    public boolean getShowDetailedMessages() {
        return mShowDetailedMessages;
    }

    /**
     * Specifies if the RWService should display more detailed messages,
     * e.g. in its notifications. This is mostly for debugging purposes or
     * for advanced users.
     * 
     * @param state set to true for detailed messages
     */
    public void setShowDetailedMessages(boolean state) {
        mShowDetailedMessages = state;
    }

    /**
     * Specifies if the RWService should only use WiFi connection, and not
     * mobile data connections for communicating with the server.
     * 
     * @param state set to true to only allow WiFi connection
     */
    public void setOnlyConnectOverWifi(boolean state) {
        mOnlyConnectOverWiFi = state;

        // check current connection and if not WiFi go to off-line (if on-line)
        if (mSessionState == SessionState.ON_LINE) {
            if (!isConnected()) {
                manageSessionState(SessionState.OFF_LINE);
            }
        }
    }

    /**
     * Returns the average audio buffer length (in ms) current used by the
     * RWService to estimate the delay between detecting a new asset starting
     * to be streamed by the server and it actually becoming audible on the
     * device.
     * 
     * Note: This estimation is needed because currently no good ways exist
     * in Android OS versions to work with audio stream metadata tags. Future
     * versions of the OS might do better and the functionality of detecting
     * streaming assets (mainly done by the RWStreamAssetsTracker class) then
     * can be improved. 
     *  
     * @return average audio stream buffer length in ms
     */
    public int getAverageStreamBufferLength() {
        return mAssetTracker.getAverageStreamBufferLength();
    }

    /**
     * Sets the average audio buffer length (in ms) current used by the
     * RWService to estimate the delay between detecting a new asset starting
     * to be streamed by the server and it actually becoming audible on the
     * device.
     * 
     * Note: This estimation is needed because currently no good ways exist
     * in Android OS versions to work with audio stream metadata tags. Future
     * versions of the OS might do better and the functionality of detecting
     * streaming assets (mainly done by the RWStreamAssetsTracker class) then
     * can be improved. 
     *  
     * @param averageBufferLengthMsec average audio stream buffer length in ms
     */
    public void setAverageStreamBufferLength(int averageBufferLengthMsec) {
        mAssetTracker.setAverageStreamBufferLength(averageBufferLengthMsec);
    }

    /**
     * Checks if data connectivity is available, honoring the flag 
     * mOnlyConnectOverWifi to accept only WiFi and not mobile data
     * connections.
     * 
     * @return true if data connectivity is available
     */
    public boolean isConnected() {
        ConnectivityManager cm = (ConnectivityManager) getSystemService(CONNECTIVITY_SERVICE);
        NetworkInfo ni = cm.getActiveNetworkInfo();
        if ((ni != null) && (ni.isConnected())) {
            if ((mOnlyConnectOverWiFi) && (!(ni.getType() == ConnectivityManager.TYPE_WIFI))) {
                return false;
            }
            return true;
        }
        return false;
    }

    /**
     * Fixes the location of the device at the specified coordinates. Updates
     * from the GPS or other internal location providers will no longer have
     * effect, until releaseMockLocation is called. 
     */
    public void setMockLocation(String latitude, String longitude) {
        releaseMockLocation();
        if (!isEmptyOrNAInput(latitude) && !isEmptyOrNAInput(longitude)) {
            try {
                Double lat = Double.valueOf(latitude);
                Double lon = Double.valueOf(longitude);
                RWLocationTracker.instance().fixLocationAt(lat, lon);
            } catch (Exception e) {
                String msg = "Cannot set mock location to lat=" + latitude + ", lon=" + longitude + " : "
                        + e.getMessage();
                Log.w(TAG, msg);
            }
        }
    }

    /**
     * Checks if the specified input string is empty or contains "N/A".
     * 
     * @param input to check
     * @return true if it is empty or "N/A" (case insensitive)
     */
    public boolean isEmptyOrNAInput(String input) {
        return (input == null) || (input.length() == 0) || ("N/A".equalsIgnoreCase(input));
    }

    /**
     * When a mock location has been set for the device, release it and return
     * to the normal location updates (e.g. GPS, Network).
     */
    public void releaseMockLocation() {
        if (RWLocationTracker.instance().isUsingFixedLocation()) {
            RWLocationTracker.instance().releaseFixedLocation();
        }
    }

    /**
     * Attempts to remove the persisted queue of pending server calls.
     * 
     * @return true when successful
     */
    public boolean deleteQueue() {
        return RWActionQueue.instance().deleteQueue();
    }

    /**
     * Returns the number of items currently in the queue awaiting
     * processing.
     * 
     * @return queue size
     */
    public int getQueueSize() {
        return RWActionQueue.instance().count();
    }

    private void startQueueTimer() {
        if (mQueueTimer != null) {
            stopQueueTimer();
        }

        if (D) {
            Log.d(TAG, "Starting queue processing", null);
        }
        mQueueTimer = new Timer();
        TimerTask task = new TimerTask() {
            public void run() {
                queueCheck();
            }
        };
        mQueueTimer.scheduleAtFixedRate(task, 0, configuration.getQueueCheckIntervalSec() * 1000);
    }

    private void stopQueueTimer() {
        if (mQueueTimer != null) {
            if (D) {
                Log.d(TAG, "Stopping queue processing", null);
            }
            mQueueTimer.cancel();
            mQueueTimer.purge();
            mQueueTimer = null;
        }
    }

    /**
     * Sends a call to the Roundware server with information about the
     * current location (latitude, longitude) of the device.
     * 
     * @param now True to sent immediately, false for queued processing 
     * @return server response, empty string when queued
     */
    public String rwSendMoveListener(boolean now) {
        if ((configuration.getSessionId() != null) && (isPlaying()) && (!isPlayingMuted())) {
            return perform(mActionFactory.createModifyStreamAction(), now);
        }
        return null;
    }

    /**
     * Retrieves information about the asset currently being streamed by the
     * Roundware server, if the project supports metadata and audio is playing
     * and not playing the static soundtrack.
     * 
     * Note that the Android buffers streaming audio so the asset streamed by
     * the server is not directly audible on the device.
     *  
     * @return server response, see the Roundware protocol documentation
     */
    public String rwGetCurrentStreamingAsset() {
        if (configuration.isStreamMetadataEnabled() && isPlaying() && !isPlayingStaticSoundtrack()) {
            return perform(mActionFactory.createRetrieveLastStreamedAssetInfoAction(), true);
        }
        return null;
    }

    /**
     * Retrieves information about the specified asset from the Roundware
     * server, if it exists.
     *  
     * @param assetId of asset to retrieve information for
     * @return server response, see the Roundware protocol documentation
     */
    public String rwGetAssetInfo(int assetId) {
        return perform(mActionFactory.createRetrieveAssetInfoAction(assetId), true);
    }

    /**
     * Sends a heartbeat call to the Roundware server, to let it know we
     * are still active. Can be used when no other server calls have been
     * made for a while to prevent the server from closing our session and
     * starting cleaning it up.
     * 
     * @return server response
     */
    public String rwSendHeartbeat() {
        if (configuration.getSessionId() != null) {
            String response = perform(mActionFactory.createHeartbeatAction(), true);
            broadcast(RW.HEARTBEAT_SENT);
            return response;
        }
        return null;
    }

    /**
     * Sends a call to the Roundware server to notify it of a specific
     * event (user started listening, user started a recording, etc.).
     * 
     * @param eventTypeResId of type of log event 
     * @param tags to include in log event, may be null
     * @param data to include in log event, may be null
     * @param now True to sent immediately, false for queued processing 
     * @return server response, empty string when queued
     */
    public String rwSendLogEvent(int eventTypeResId, RWList tags, String data, boolean now) {
        return perform(mActionFactory.createLogEventAction(eventTypeResId, tags, data), now);
    }

    /**
     * Sends a call to the Roundware server for voting on a specific asset.
     * For valid vote type and vote value see the Roundware protocol 
     * description. Typical vote types might be 'like' and 'flag. The vote
     * value is optional and can be specified as null depending on the vote
     * type.
     * 
     * @param assetId of asset to vote on
     * @param voteType to apply
     * @param voteValue to apply, can be null
     * @param now True to sent immediately, false for queued processing 
     * @return server response, empty string when queued
     */
    public String rwSendVoteAsset(int assetId, String voteType, String voteValue, boolean now) {
        return perform(mActionFactory.createVoteAssetAction(assetId, voteType, voteValue), now);
    }

    /**
     * Sends a call to the Roundware server to request an audio stream
     * based on the specified selections of tags options.
     * 
     * @param tags of tags options for the audio
     * @param now True to sent immediately, false for queued processing 
     * @return server response, empty string when queued
     */
    public String rwRequestStream(RWList tags, boolean now) {
        return perform(mActionFactory.createRequestStreamAction(tags), now);
    }

    /**
     * Sends a call to the Roundware server to request modifying of the
     * already streaming audio stream based on the specified selections
     * of tags options.
     * 
     * @param tags of tags options for the audio
     * @param now True to sent immediately, false for queued processing 
     * @return server response, empty string when queued
     */
    public String rwModifyStream(RWList tags, boolean now) {
        return perform(mActionFactory.createModifyStreamAction(tags), now);
    }

    /**
     * Sends a call to the Roundware server to request skipping ahead to
     * the next asset and start playing it in the audio stream.
     * 
     * @return server response, see the Roundware protocol documentation
     */
    public String rwSkipAhead() {
        if (configuration.isStreamMetadataEnabled() && isPlaying() && !isPlayingStaticSoundtrack()) {
            mAssetTracker.reset();
        }
        return perform(mActionFactory.createSkipAheadAction(), true);
    }

    /**
     * Sends a call to the Roundware server to request inserting the
     * specified asset into the audio stream.
      * 
      * @param assetId of recording to be inserted into the stream
     * @return server response, see the Roundware protocol documentation
     */
    public String rwPlayAssetInStream(int assetId) {
        if (configuration.isStreamMetadataEnabled() && isPlaying() && !isPlayingStaticSoundtrack()) {
            mAssetTracker.reset();
        }
        return perform(mActionFactory.createPlayAssetInStreamAction(assetId), true);
    }

    /**
     * Sends calls to the Roundware server needed to announce and upload
     * the specified file, marked by the given selections of tags options.
     * 
     * An RW_SHARING_MESSAGE intent will be broadcasted after the operation
     * is completed and both the now and sharingBroadcast arguments are set
     * to true.
     * 
     * @param tags of tags options for the audio
     * @param filename of file to be uploaded to the server
      * @param submitted for stream (Y) or not (N), null to ignore
     * @param now True to sent immediately, false for queued processing
     * @param sharingBroadcast True to broadcast an RW_SHARING_MESSAGE
     * @return server response, empty string when queued
     * @throws Exception when temporary file could not be created
     */
    public String rwSubmit(RWList tags, String filename, String submitted, boolean now, boolean sharingBroadcast)
            throws Exception {
        // create an action to create an asset envelope and perform it directly
        int envelopeId = -1;

        // create a temporary copy of the recording file
        File queueFile = RWActionQueue.instance().createTemporaryQueueFile(filename);

        // try to open an envelope on the server
        RWAction createEnvelopeAction = mActionFactory.createCreateEnvelopeAction(tags);
        String jsonResponse = perform(createEnvelopeAction, true);
        if (jsonResponse != null) {
            String envelopeKey = getString(R.string.rw_key_envelope_id);
            JSONObject jsonObj = new JSONObject(jsonResponse);
            envelopeId = jsonObj.optInt(envelopeKey, -1);
        }

        // create an upload asset action
        RWAction addAssetAction = mActionFactory.createAddAssetToEnvelopeAction(tags, envelopeId,
                queueFile.getAbsolutePath(), submitted);

        if (envelopeId == -1) {
            // envelope could not be created, queue action for later processing
            return perform(addAssetAction, false);
        } else {
            // when submitting directly, send out a sharing broadcast to apps
            if ((now) && (sharingBroadcast)) {
                String envId = String.valueOf(envelopeId);
                String msg = configuration.getSharingMessage();
                String url = "";

                // url might be encoded in the sharing message
                if ((msg != null) && (msg.contains("|"))) {
                    String[] parts = msg.split("\\|");
                    // use first part as message and last as url
                    msg = parts[0];
                    url = parts[parts.length - 1];
                } else {
                    url = configuration.getSharingUrl();
                }

                // replace placeholder with evelope id
                url = url.replace("[id]", envId);

                // get location details
                Double lat = addAssetAction.getLatitude();
                Double lon = addAssetAction.getLongitude();
                Double acc = addAssetAction.getAccuracy();

                // send the broadcast message
                broadcastSharingMessage(msg, url, envId, lat, lon, acc);
            }
            // start the actual file upload, or place in queue
            return perform(addAssetAction, now);
        }
    }

    /**
     * Performs a server call for the specified RWAction instance. It can
     * either be handled directly (i.e. the request is created, sent to the
     * server, and the response is waited for), or put in the queue first.
     * This queue is processed in the background based on regular intervals,
     * at which the top item of the queue is 'performed'. Actions that are
     * performed directly and fail will not automatically be placed in the
     * queue. Actions that are placed in the queue and fail will stay in the
     * queue when it concerns file uploads.
     * 
     * @param action to perform as Roundware server call
     * @param now True to sent immediately, false for queued processing 
     * @return server response, empty string when queued
     */
    protected String perform(RWAction action, boolean now) {
        if (now) {
            return perform(action);
        } else {
            RWActionQueue.instance().add(action.getProperties());
            setNotificationText(null);

            // broadcast operation QUEUED intent
            String msg = "Action placed in queued";
            Log.i(TAG, msg, null);
            broadcastActionQueued(action, TAG + ": " + msg, null);

            return "";
        }
    }

    /**
     * Handles the setting of notification texts and broadcasting intents
     * surround the calling of the action.perform() method (that does the
     * actual calling of the server).
     * 
     * @param action to be executed
     * @return server response
     */
    protected String perform(RWAction action) {
        try {
            // update last request time
            mLastRequestMsec = System.currentTimeMillis();

            try {
                setNotificationText(action.getCaption());
            } catch (Exception e) {
                Log.e(TAG, "Could not update notification text!", e);
            }

            // no point in trying when not connected
            if (!isConnected()) {
                throw new UnknownHostException("No connectivity");
            }

            // always perform actions for the current session ID
            action.setSessionId(configuration.getSessionId());

            // create an envelope ID for a file upload if the action has none yet (created in off-line mode)
            if ((action.getFilename() != null) && ("-1".equals(action.getEnvelopeId()))) {
                // create an action to create an asset envelope and perform it directly
                RWAction createEnvelopeAction = mActionFactory
                        .createCreateEnvelopeAction(action.getSelectedTagsOptions());
                String jsonResponse = createEnvelopeAction.perform(configuration.getHttpTimeOutSec());
                String envelopeKey = getString(R.string.rw_key_envelope_id);
                JSONObject jsonObj = new JSONObject(jsonResponse);
                int envelopeId = jsonObj.optInt(envelopeKey, -1);
                if (envelopeId == -1) {
                    throw new UnknownHostException("Just in time creation of envelope ID for file upload failed");
                } else {
                    action.setEnvelopeId(String.valueOf(envelopeId));
                }
            }

            // actually perform the action
            String result = action.perform(configuration.getHttpTimeOutSec());

            // when action is an upload a log event needs to be send now
            if (action.getFilename() != null) {
                rwSendLogEvent(R.string.rw_et_stop_upload, null, "true", true);
            }

            setNotificationText(mNotificationDefaultText);

            // broadcast operation SUCCESS intent
            broadcastActionSuccess(action, result);

            return broadcastServerMessages(result);
        } catch (UnknownHostException e) {
            String msg = "Unknown host error: " + e.getMessage();
            Log.e(TAG, msg, e);
            // broadcast operation FAILED intent
            broadcastActionFailure(action, TAG + ": " + msg, e);
            return null;
        } catch (HttpException e) {
            // expect http status code in exception message
            String msg = "HTTP error: " + e.getMessage();
            Log.e(TAG, msg, e);
            // broadcast operation FAILED intent
            // on server time out pass it as an UnknownHostException
            if (isHttpTimeOut(Integer.valueOf(e.getMessage()))) {
                broadcastActionFailure(action, TAG + ": " + msg, new UnknownHostException(msg));
            } else {
                broadcastActionFailure(action, TAG + ": " + msg, e);
            }
            return null;
        } catch (Exception e) {
            String msg = "Error: " + e.getMessage();
            Log.e(TAG, msg, e);
            // when action is an upload a log event needs to be send now
            if (action.getFilename() != null) {
                rwSendLogEvent(R.string.rw_et_stop_upload, null, "false", true);
            }
            // broadcast operation FAILED intent
            broadcastActionFailure(action, TAG + ": " + msg, e);
            return null;
        }
    }

    private boolean isHttpTimeOut(int httpStatusResponse) {
        if ((HttpStatus.SC_GATEWAY_TIMEOUT == httpStatusResponse)
                || (HttpStatus.SC_REQUEST_TIMEOUT == httpStatusResponse)
                || (HttpStatus.SC_SERVICE_UNAVAILABLE == httpStatusResponse)) {
            return true;
        }
        return false;
    }

    private String retrieveServerMessage(ServerMessageType messageType, String response) {
        // get keyword for message type
        String key = null;
        switch (messageType) {
        case ERROR:
            key = getString(R.string.rw_key_server_error_message);
            break;
        case TRACEBACK:
            key = getString(R.string.rw_key_server_error_traceback);
            break;
        case USER:
            key = getString(R.string.rw_key_server_result);
            break;
        case SHARING:
            key = getString(R.string.rw_key_server_sharing_message);
            break;
        }

        if (key != null) {
            try {
                JSONObject jsonObj = new JSONObject(response);
                if (jsonObj.has(key)) {
                    return jsonObj.getString(key);
                }
            } catch (JSONException e) {
                Log.w(TAG,
                        "Could not get server message from response, probably a JSON array instead of a JSON object.");
            }
        }

        return null;
    }

    private String broadcastServerMessages(String response) {
        // check if response can be json
        if ((response == null) || (response.length() == 0)) {
            return response;
        }
        if (!(response.startsWith("{") || response.startsWith("["))) {
            return response;
        }
        if (!(response.endsWith("}") || response.endsWith("]"))) {
            return response;
        }

        String message;
        Intent intent = new Intent();

        // process none critical messages first

        message = retrieveServerMessage(ServerMessageType.USER, response);
        if (message != null) {
            intent.setAction(RW.USER_MESSAGE);
            intent.putExtra(RW.EXTRA_SERVER_MESSAGE, message);
            if (D) {
                Log.i(TAG, "Going to send broadcast event, user message = " + message, null);
            }
            sendBroadcast(intent);
        }

        // process critical messages that stop further handling of the response

        message = retrieveServerMessage(ServerMessageType.ERROR, response);
        if (message != null) {
            // see if there is additional traceback info
            String traceback = retrieveServerMessage(ServerMessageType.TRACEBACK, response);
            if (traceback != null) {
                message = message + "\n\nTraceback: " + traceback;
            }
            intent.setAction(RW.ERROR_MESSAGE);
            intent.putExtra(RW.EXTRA_SERVER_MESSAGE, message);
            if (D) {
                Log.i(TAG, "Going to send broadcast event, error message = " + message, null);
            }
            sendBroadcast(intent);

            // return null to avoid further processing of server response
            return null;
        }

        return response;
    }

    /**
     * Broadcast an intent for a sharing message. ALl relevant info is based
     * as extras in the intent.
     * 
     * @param message to be included in the intent
     * @param url to be included in the intent
     * @param envelopeId to be included in the intent
     * @param latitude (decimal degrees) to be included in the intent
     * @param longitude (decimal degrees) to be included in the intent
     * @param accuracy (in meters) of location to be included in the intent
     */
    public void broadcastSharingMessage(String message, String url, String envelopeId, Double latitude,
            Double longitude, Double accuracy) {
        if (message != null) {
            Intent intent = new Intent();
            intent.setAction(RW.SHARING_MESSAGE);
            intent.putExtra(RW.EXTRA_SERVER_MESSAGE, message + " - " + url);
            intent.putExtra(RW.EXTRA_SHARING_MESSAGE, message);
            intent.putExtra(RW.EXTRA_SHARING_URL, url);
            intent.putExtra(RW.EXTRA_ENVELOPE_ID, envelopeId);

            if ((!Double.isNaN(latitude)) && (!Double.isNaN(longitude))) {
                intent.putExtra(RW.EXTRA_LOCATION_LAT, latitude);
                intent.putExtra(RW.EXTRA_LOCATION_LON, longitude);
                if (!Double.isNaN(accuracy)) {
                    intent.putExtra(RW.EXTRA_LOCATION_ACCURACY_M, accuracy);
                }
            }
            if (D) {
                Log.d(TAG, "Going to send broadcast event, sharing message = " + message + " url = " + url, null);
            }
            sendBroadcast(intent);
        }
    }

    /**
     * Manage changing of the current session state to the indicated new
     * state. Intents will be broadcasted to notify receivers of the state
     * changes.
     * 
     * @param newState session state to switch to
     */
    public void manageSessionState(SessionState newState) {
        if (mSessionState.equals(newState)) {
            return;
        }
        mSessionState = newState;

        if (D) {
            Log.d(TAG, "Changing session state to: " + mSessionState, null);
        }

        switch (mSessionState) {
        case UNINITIALIZED:
            stopQueueTimer();
            stopLocationUpdates();
            mAssetTracker.stop();
            playbackStop();
            break;
        case ON_LINE:
            // refresh configuration after threshold time so session ID can be refreshed
            long millis = System.currentTimeMillis();
            if ((millis - mLastStateChangeMsec) > (configuration.getHeartbeatTimerSec() * 5)) {
                // project ID assumed to be already in configuration and not changing!
                new RetrieveConfigurationTask(this, configuration.getDeviceId(), configuration.getProjectId())
                        .execute();
            }
            // refresh tags
            if (tags.getDataSource() != RWTags.FROM_SERVER) {
                new RetrieveTagsTask(this, configuration.getProjectId()).execute();
            }
            startQueueTimer();
            startLocationUpdates();
            mAssetTracker.start();
            broadcast(RW.SESSION_ON_LINE);
            break;
        case OFF_LINE:
            // keep queue timer running to periodically retry the connection
            startQueueTimer();
            playbackStop();
            broadcast(RW.SESSION_OFF_LINE);
            break;
        }

        mLastStateChangeMsec = System.currentTimeMillis();
    }

    /**
     * Broadcast an intent with the specified action.
     * 
     * @param action intent action (not a RWAction)
     */
    private void broadcast(String action) {
        Intent intent = new Intent();
        intent.setAction(action);
        if (D) {
            Log.d(TAG, "Going to send broadcast event, action=" + action, null);
        }
        sendBroadcast(intent);
    }

    private void broadcastActionSuccess(RWAction action, String result) {
        Intent intent = new Intent();
        String actionName = RW.BROADCAST_PREFIX + action.getOperation().toLowerCase()
                + RW.BROADCAST_SUCCESS_POSTFIX;
        intent.setAction(actionName);
        intent.putExtra(RW.EXTRA_ACTION_PROPERTIES, action.getProperties());
        intent.putExtra(RW.EXTRA_SUCCESS_RESULT, result);
        if (D) {
            Log.d(TAG, "Going to send broadcast event, action = " + actionName, null);
        }
        sendBroadcast(intent);
    }

    private void broadcastActionFailure(RWAction action, String reason, Throwable e) {
        Intent intent = new Intent();
        String actionName = RW.BROADCAST_PREFIX + action.getOperation().toLowerCase()
                + RW.BROADCAST_FAILURE_POSTFIX;
        intent.setAction(actionName);
        intent.putExtra(RW.EXTRA_ACTION_PROPERTIES, action.getProperties());
        intent.putExtra(RW.EXTRA_FAILURE_REASON, reason);
        if (e != null) {
            intent.putExtra(RW.EXTRA_FAILURE_EXCEPTION, e);
        }
        if (D) {
            Log.d(TAG, "Going to send broadcast event, action = " + actionName, null);
        }
        sendBroadcast(intent);
    }

    private void broadcastActionQueued(RWAction action, String reason, Throwable e) {
        Intent intent = new Intent();
        String actionName = RW.BROADCAST_PREFIX + action.getOperation().toLowerCase() + RW.BROADCAST_QUEUED_POSTFIX;
        intent.setAction(actionName);
        intent.putExtra(RW.EXTRA_ACTION_PROPERTIES, action.getProperties());
        intent.putExtra(RW.EXTRA_FAILURE_REASON, reason);
        if (e != null) {
            intent.putExtra(RW.EXTRA_FAILURE_EXCEPTION, e);
        }
        if (D) {
            Log.d(TAG, "Going to send broadcast event, action = " + actionName, null);
        }
        sendBroadcast(intent);
    }

    private void broadcastLocationUpdate(double latitude, double longitude, String provider, float accuracy) {
        if (D) {
            Log.d(TAG, String.format("Going to send broadcast event, location updated lat=%.6f lon=%.6f "
                    + "provider=%s accuracy=%.6fm", latitude, longitude, provider, accuracy), null);
        }
        Intent intent = new Intent();
        intent.setAction(RW.LOCATION_UPDATED);
        intent.putExtra(RW.EXTRA_LOCATION_LAT, latitude);
        intent.putExtra(RW.EXTRA_LOCATION_LON, longitude);
        intent.putExtra(RW.EXTRA_LOCATION_PROVIDER, provider);
        intent.putExtra(RW.EXTRA_LOCATION_ACCURACY_M, accuracy);
        sendBroadcast(intent);
    }

    private void queueCheck() {
        int count = RWActionQueue.instance().count();
        if (count == 0) {
            // nothing to do, send ping if idle time threshold exceeded - also used to check on-line status
            long currentMillis = System.currentTimeMillis();
            if ((currentMillis - mLastRequestMsec) > (configuration.getHeartbeatTimerSec() * 1000)) {
                if ((SessionState.OFF_LINE.equals(mSessionState))
                        || (SessionState.ON_LINE.equals(mSessionState) && isPlaying())) {
                    rwSendHeartbeat();
                }
            }

            setNotificationText(null);
            return;
        }

        RWAction action = RWActionQueue.instance().get();
        if (action != null) {
            if (perform(action) != null) {
                RWActionQueue.instance().delete(action);
                setNotificationText(null);
            } else {
                setNotificationText(getString(R.string.roundware_notification_request_failed));
                // remove failing action from queue, unless it is a file upload
                if (action.getFilename() == null) {
                    RWActionQueue.instance().delete(action);
                }
            }
        }
    }

    /**
     * Creates a media player for sound playback, with initial volume of 0.
     */
    private void createPlayer() {
        if (mPlayer == null) {
            mPlayer = new MediaPlayer();
            mPlayer.setWakeMode(this, PowerManager.PARTIAL_WAKE_LOCK);

            float volume = (float) 0.0;
            mPlayer.setVolume(volume, volume);
            mVolumeLevel = 0;
            mPlayer.pause();

            mPlayer.setOnPreparedListener(new MediaPlayer.OnPreparedListener() {
                @Override
                public void onPrepared(MediaPlayer mp) {
                    if (D) {
                        Log.d(TAG, "MediaPlayer prepared event");
                    }
                    broadcast(RW.READY_TO_PLAY);
                    if (mStartPlayingWhenReady) {
                        playbackFadeIn(mVolumeLevel);
                    }
                    mAssetTracker.start();
                }
            });

            mPlayer.setOnInfoListener(new MediaPlayer.OnInfoListener() {
                @Override
                public boolean onInfo(MediaPlayer mp, int what, int extra) {
                    if (D) {
                        Log.i(TAG, "MediaPlayer info event");
                        if (MediaPlayer.MEDIA_INFO_METADATA_UPDATE == what) {
                            Log.i(TAG, "MediaPlayer metadata updated, extra = " + extra);
                        }
                    }
                    return true;
                }
            });

            mPlayer.setOnErrorListener(new MediaPlayer.OnErrorListener() {
                @Override
                public boolean onError(MediaPlayer mp, int what, int extra) {
                    if (D) {
                        Log.d(TAG, "MediaPlayer error event");
                    }
                    mAssetTracker.reset();
                    return true;
                }
            });

            mPlayer.setOnCompletionListener(new MediaPlayer.OnCompletionListener() {
                @Override
                public void onCompletion(MediaPlayer mp) {
                    if (D) {
                        Log.d(TAG, "MediaPlayer completion event");
                    }
                    mAssetTracker.stop();
                    mp.stop();
                    broadcast(RW.PLAYBACK_FINISHED);
                }
            });

            mPlayer.setOnBufferingUpdateListener(new MediaPlayer.OnBufferingUpdateListener() {
                @Override
                public void onBufferingUpdate(MediaPlayer mp, int percent) {
                    if (D) {
                        Log.d(TAG, "MediaPlayer buffering event, %=" + percent);
                    }
                }
            });
        }
    }

    /**
     * Releases the media player after fading out the sounds.
     */
    private void releasePlayer() {
        mAssetTracker.stop();
        if (mPlayer != null) {
            playbackFadeOut();
            mPlayer.release();
            mPlayer = null;
        }
    }

    /**
     * Fade out the volume of the music player, until it reaches 0.
     */
    public void playbackFadeOut() {
        // let server know user is no longer listening
        rwSendLogEvent(R.string.rw_et_stop_listen, null, null, true);

        setVolumeLevel(0, true);
        mStartPlayingWhenReady = false;
        setNotificationText(".Audio muted");
    }

    /**
     * Fade in the volume of the music player, until it reaches the level
     * specified.
     * 
     * @param endVolumeLevel (0 - 100)
     */
    public void playbackFadeIn(int endVolumeLevel) {
        mStartPlayingWhenReady = true;
        if (mPlayer != null) {
            try {
                mPlayer.start();
            } catch (Exception ex) {
                Log.i(TAG, "Fade in to volume level " + endVolumeLevel + " caused "
                        + "MediaPlayer exception, delaying!", ex);
                setVolumeLevel(endVolumeLevel, true);
            }

            // let server know user started listening
            rwSendLogEvent(R.string.rw_et_start_listen, null, null, true);

            setVolumeLevel(endVolumeLevel, true);
            setNotificationText(".Audio unmuted");
        } else {
            Log.i(TAG, "Fade in to volume level " + endVolumeLevel + " ignored, " + "MediaPlayer not initialized!",
                    null);
            setVolumeLevel(endVolumeLevel, true);
        }
    }

    private float calcVolumeScalar(int volumeLevel) {
        float volume = 1.0f;
        if (volumeLevel < mMinVolumeLevel) {
            volume = 0.0f;
        } else {
            if (volumeLevel < mMaxVolumeLevel) {
                for (int i = mMaxVolumeLevel; i > volumeLevel; i--) {
                    volume *= mVolumeStepMultiplier;
                }
            }
        }
        return volume;
    }

    /**
     * Returns the current volume level of the music player.
     * 
     * @return volume level (0 - 100)
     */
    public int getVolumeLevel() {
        return mVolumeLevel;
    }

    /**
     * Sets a new volume level for the music player. The change in volume
     * level can be made abruptly or through fading.
     * 
     * @param newVolumeLevel for the music player
     * @param fade change level by fading or not
     */
    public void setVolumeLevel(int newVolumeLevel, boolean fade) {
        int oldVolumeLevel = mVolumeLevel;
        mVolumeLevel = newVolumeLevel;
        if (mVolumeLevel < mMinVolumeLevel) {
            mVolumeLevel = mMinVolumeLevel;
        } else if (mVolumeLevel > mMaxVolumeLevel) {
            mVolumeLevel = mMaxVolumeLevel;
        }

        float oldVolume = calcVolumeScalar(oldVolumeLevel);
        float newVolume = calcVolumeScalar(mVolumeLevel);

        // fail-safe when volume should be totally muted
        if (mVolumeLevel == 0) {
            newVolume = 0.0f;
        }

        // gradually modify the volume when playing and set to fade
        if (mPlayer != null) {
            if (D) {
                String msg = String.format("Changing volume from level %d (%1.5f) to level %d (%1.5f)",
                        oldVolumeLevel, oldVolume, mVolumeLevel, newVolume);
                Log.d(TAG, msg);
            }
            if (fade) {
                if (oldVolume < newVolume) {
                    for (float v = oldVolume; v < newVolume; v += 0.001f) {
                        mPlayer.setVolume(v, v);
                    }
                } else {
                    for (float v = oldVolume; v > newVolume; v -= 0.001f) {
                        mPlayer.setVolume(v, v);
                    }
                }
                // make sure to reach the final new volume
                mPlayer.setVolume(newVolume, newVolume);
            } else {
                mPlayer.setVolume(newVolume, newVolume);
            }
        } else {
            if (D) {
                String msg = String.format("Volume set to level %d (%1.5f) but MediaPlayer not initialized!",
                        mVolumeLevel, newVolume);
                Log.d(TAG, msg);
            }
        }
    }

}