ac.robinson.bettertogether.hotspot.HotspotManagerService.java Source code

Java tutorial

Introduction

Here is the source code for ac.robinson.bettertogether.hotspot.HotspotManagerService.java

Source

/*
 * Copyright (C) 2017 The Better Together Toolkit
 *
 * 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 ac.robinson.bettertogether.hotspot;

import android.app.Service;
import android.bluetooth.BluetoothAdapter;
import android.bluetooth.BluetoothDevice;
import android.content.BroadcastReceiver;
import android.content.Context;
import android.content.Intent;
import android.content.IntentFilter;
import android.net.NetworkInfo;
import android.net.wifi.WifiConfiguration;
import android.net.wifi.WifiInfo;
import android.net.wifi.WifiManager;
import android.os.Build;
import android.os.Bundle;
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.support.annotation.NonNull;
import android.support.annotation.Nullable;
import android.support.v4.content.LocalBroadcastManager;
import android.text.TextUtils;
import android.util.Log;

import org.greenrobot.eventbus.EventBus;
import org.greenrobot.eventbus.Subscribe;
import org.greenrobot.eventbus.ThreadMode;

import java.io.IOException;
import java.lang.ref.WeakReference;
import java.lang.reflect.InvocationTargetException;
import java.util.ArrayList;
import java.util.UUID;

import ac.robinson.bettertogether.BetterTogetherUtils;
import ac.robinson.bettertogether.api.messaging.BroadcastMessage;
import ac.robinson.bettertogether.api.messaging.PluginIntent;
import ac.robinson.bettertogether.event.ClientConnectionErrorEvent;
import ac.robinson.bettertogether.event.ClientConnectionSuccessEvent;
import ac.robinson.bettertogether.event.ClientMessageErrorEvent;
import ac.robinson.bettertogether.event.MessageReceivedEvent;
import ac.robinson.bettertogether.event.ServerConnectionSuccessEvent;
import ac.robinson.bettertogether.event.ServerErrorEvent;
import ac.robinson.bettertogether.event.ServerMessageErrorEvent;
import edu.umd.cs.findbugs.annotations.SuppressFBWarnings;

public class HotspotManagerService extends Service {

    private static final String TAG = "HotspotManagerService";

    // with Android N and later, apps can't start access points - see: https://code.google.com/p/android/issues/detail?id=234003
    private static final boolean CREATE_WIFI_HOTSPOT_SUPPORTED = Build.VERSION.SDK_INT < Build.VERSION_CODES.N;
    private static final String HOTSPOT_STATE_FILTER = "android.net.wifi.WIFI_AP_STATE_CHANGED"; // private access in source

    // for tracking server/client state (note: MESSAGE_ID_SIZE minimum of 8 is for Wifi password length requirements)
    public static final int MESSAGE_ID_SIZE = 8; // number of digits to use for hotspot / server / message id - * minimum: 8 *
    public static final String SERVER_MESSAGE_ID = "BTplhost"; // must be constant over all clients
    public static final UUID BLUETOOTH_SERVER_UUID = UUID.fromString("07eb2627-3de9-4ae4-b6a9-cbb282a6363f"); // must be constant
    public static final int MESSAGE_BUFFER_SIZE = 2048;
    public static final int MESSAGE_PART_COUNT_SIZE = 2; // up to 10^n parts of MESSAGE_BUFFER_SIZE (when Base64-encoded)
    public static final int MESSAGE_HEADER_SIZE = MESSAGE_ID_SIZE + (2 * MESSAGE_PART_COUNT_SIZE); // id; part count; part num
    public static final int MESSAGE_PAYLOAD_SIZE = MESSAGE_BUFFER_SIZE - MESSAGE_HEADER_SIZE - 1; // 1 char for \n
    public static final byte MESSAGE_DELIMITER_BYTE = (byte) '\f'; // have to use \f as \r and \n are part of base64
    public static final String MESSAGE_DELIMITER_STRING = Character.toString((char) MESSAGE_DELIMITER_BYTE);

    // for managing wifi/bluetooth connections
    private WifiManager mWifiManager;
    private BluetoothAdapter mBluetoothAdapter;

    // for trying to restore the previous state on exit
    private boolean mOriginalBluetoothStatus = false;
    private String mOriginalBluetoothName = null;
    private boolean mOriginalWifiStatus = false;
    private ConnectionOptions mOriginalHotspotConfiguration = null;

    // for managing connection states
    private ConnectionOptions mConnectionOptions = null;
    private int mHotspotId = -1; // separate so we can unlink after destroying the hotspot
    private WifiServer mWifiServer;
    private WifiClientConnection mWifiClient = null;
    private BluetoothServer mBluetoothServer;
    private BluetoothClientConnection mBluetoothClient;
    private boolean mHotspotMode;
    private boolean mIsConnected;

    // for tracking errors
    private ErrorHandler mConnectionErrorHandler = new ErrorHandler();
    private int mWifiConnectionErrorCount = 0;
    private int mBluetoothConnectionErrorCount = 0;
    private static final int WIFI_CONNECTION_TIMEOUT = 15000; // milliseconds
    private static final int BLUETOOTH_CONNECTION_TIMEOUT = 18000; // milliseconds
    private static final float CONNECTION_DELAY_INCREMENT_MULTIPLIER = 1.3f; // multiply delay by this on every failure
    private static final int MSG_WIFI_CONNECTION_ERROR = 1;
    private static final int MSG_BLUETOOTH_CONNECTION_ERROR = 2;
    private int mWifiConnectionTimeout = WIFI_CONNECTION_TIMEOUT;
    private int mBluetoothConnectionTimeout = BLUETOOTH_CONNECTION_TIMEOUT;

    // service messages and communication
    private boolean mIsBound = false;
    private final Messenger mMessenger;
    private ArrayList<Messenger> mClients = new ArrayList<>();
    private HandlerThread mMessageThread;
    private Handler mMessageThreadHandler;

    public static final int MSG_REGISTER_CLIENT = 1; // service management
    public static final int MSG_UNREGISTER_CLIENT = 2; // service management

    public static final int MSG_ENABLE_HOTSPOT = 3; // hotspot management
    public static final int MSG_DISABLE_HOTSPOT = 4; // hotspot management
    public static final int MSG_JOIN_HOTSPOT = 5; // hotspot management
    public static final int MSG_UPDATE_HOTSPOT = 6; // hotspot management
    public static final int MSG_REQUEST_STATUS = 7; // hotspot management

    public static final int MSG_BROADCAST = 8; // messages to remote clients

    public static final int EVENT_DEVICE_CONNECTED = 9; // events sent as messages to local clients
    public static final int EVENT_LOCAL_CLIENT_ERROR = 10;
    public static final int EVENT_REMOTE_CLIENT_ERROR = 11;
    public static final int EVENT_DEVICE_DISCONNECTED = 12;

    public static final int EVENT_CONNECTION_STATUS_UPDATE = 13; // events sent as messages to local clients
    public static final int EVENT_CONNECTION_INVALID_URL = 14;
    public static final int EVENT_SETTINGS_PERMISSION_ERROR = 15;

    public static final String ROLE_SERVER = "server";
    public static final String ROLE_CLIENT = "client";
    public static final String SYSTEM_BROADCAST_EVENT_SHOW_QR_CODE = "show_qr";

    public HotspotManagerService() {
        // initialise the handler - actual service is initialised in onBind as we can't get a context here
        mMessenger = new Messenger(new IncomingHandler(HotspotManagerService.this));
    }

    @SuppressFBWarnings("REC_CATCH_EXCEPTION")
    @Nullable
    @Override
    public IBinder onBind(Intent intent) {
        if (!mIsBound) {
            //TODO: check mBluetoothAdapter not null and/or check bluetooth is available - BluetoothUtils.isBluetoothAvailable()
            mBluetoothAdapter = BluetoothUtils.getBluetoothAdapter(HotspotManagerService.this);
            mOriginalBluetoothStatus = mBluetoothAdapter.isEnabled();
            mOriginalBluetoothName = mBluetoothAdapter.getName();

            //TODO: check mWifiManager not null and/or check bluetooth is available - WifiUtils.isWifiAvailable()
            // note we need the WifiManager for connecting to other hotspots regardless of whether we can create our own
            mWifiManager = (WifiManager) getApplicationContext().getSystemService(Context.WIFI_SERVICE);

            // try to get the original state to restore later
            int wifiState = mWifiManager.getWifiState();
            switch (wifiState) {
            case WifiManager.WIFI_STATE_ENABLED:
            case WifiManager.WIFI_STATE_ENABLING:
                mOriginalWifiStatus = true;
                break;
            case WifiManager.WIFI_STATE_DISABLED:
            case WifiManager.WIFI_STATE_DISABLING:
            case WifiManager.WIFI_STATE_UNKNOWN:
                mOriginalWifiStatus = false;
                break;
            default:
                break;
            }

            // try to save the existing hotspot state
            if (CREATE_WIFI_HOTSPOT_SUPPORTED) {
                try {
                    // TODO: is it possible to save/restore the original password? (WifiConfiguration doesn't hold the password)
                    WifiConfiguration wifiConfiguration = WifiUtils.getWifiHotspotConfiguration(mWifiManager);
                    mOriginalHotspotConfiguration = new ConnectionOptions();
                    mOriginalHotspotConfiguration.mName = wifiConfiguration.SSID;
                } catch (Exception ignored) {
                    // note - need to catch Exception rather than ReflectiveOperationException due to our API level (requires 19)
                }
            }

            // set up background thread for message sending - see: https://medium.com/@ali.muzaffar/dc8bf1540341
            mMessageThread = new HandlerThread("BTMessageThread");
            mMessageThread.start();
            mMessageThreadHandler = new Handler(mMessageThread.getLooper());

            // set up listeners for network/bluetooth state changes
            IntentFilter intentFilter = new IntentFilter();
            if (CREATE_WIFI_HOTSPOT_SUPPORTED) {
                intentFilter.addAction(HOTSPOT_STATE_FILTER); // Wifi hotspot states
            }
            intentFilter.addAction(WifiManager.WIFI_STATE_CHANGED_ACTION); // Wifi on/off
            intentFilter.addAction(WifiManager.NETWORK_STATE_CHANGED_ACTION); // network connection/disconnection
            intentFilter.addAction(BluetoothAdapter.ACTION_STATE_CHANGED); // Bluetooth on/off
            intentFilter.addAction(BluetoothDevice.ACTION_FOUND); // Bluetooth device found
            registerReceiver(mGlobalBroadcastReceiver, intentFilter);

            // listen for messages from our PluginMessageReceiver
            IntentFilter localIntentFilter = new IntentFilter();
            localIntentFilter.addAction(PluginIntent.ACTION_MESSAGE_RECEIVED);
            localIntentFilter.addAction(PluginIntent.ACTION_STOP_PLUGIN);
            LocalBroadcastManager.getInstance(HotspotManagerService.this).registerReceiver(mLocalBroadcastReceiver,
                    localIntentFilter);

            // listen for EventBus events (from wifi/bluetooth servers)
            if (!EventBus.getDefault().isRegistered(HotspotManagerService.this)) {
                EventBus.getDefault().register(HotspotManagerService.this);
            }

            mIsBound = true;
        }

        return mMessenger.getBinder();
    }

    @Override
    public void onDestroy() {
        // remove all event listeners
        EventBus.getDefault().unregister(HotspotManagerService.this);
        LocalBroadcastManager.getInstance(HotspotManagerService.this).unregisterReceiver(mLocalBroadcastReceiver);
        unregisterReceiver(mGlobalBroadcastReceiver);
        mMessageThread.quit(); // (we don't need quitSafely() as messages don't need to be delivered)

        destroyAllConnections();

        restoreOriginalWifiState();
        restoreOriginalBluetoothState();
    }

    private void restoreOriginalWifiState() {
        // we used to actually remove the network here - this is not done any more so that repeat wifi connections are faster
        try {
            mWifiManager.disableNetwork(mHotspotId); // on later Android versions, Wifi connections with no access do this anyway
            mWifiManager.setWifiEnabled(mOriginalWifiStatus);
            if (mOriginalWifiStatus) {
                mWifiManager.reconnect(); // TODO: do we need to save/restore the original network name, too?
                // TODO: (e.g., save current wifi connection ID before adding our own, then check its new ID and restore after)
            }
        } catch (SecurityException ignored) {
        }
    }

    @SuppressFBWarnings("REC_CATCH_EXCEPTION")
    private void restoreOriginalWifiHotspotState() {
        if (CREATE_WIFI_HOTSPOT_SUPPORTED) {
            // reset to original name; must leave disabled as we don't know the original password...
            if (WifiUtils.getHotspotEnabled(mWifiManager)) {
                try {
                    WifiConfiguration wifiConfiguration = null;
                    try {
                        // see: https://stackoverflow.com/questions/2140133/
                        wifiConfiguration = WifiUtils.getWifiHotspotConfiguration(mWifiManager);
                        WifiUtils.setConfigurationAttributes(wifiConfiguration, mOriginalHotspotConfiguration.mName,
                                mOriginalHotspotConfiguration.mPassword);
                    } catch (Exception ignored) {
                    }
                    // separate try/catch so we at least disable the hotspot even if we can't set the configuration
                    WifiUtils.setHotspotEnabled(mWifiManager, wifiConfiguration, false);
                } catch (Exception ignored) {
                    // note - need to catch Exception rather than ReflectiveOperationException due to our API level (requires 19)
                }
            }
        }
    }

    private void restoreOriginalBluetoothState() {
        // disable bluetooth if necessary
        try {
            mBluetoothAdapter.setName(mOriginalBluetoothName);
            if (!mOriginalBluetoothStatus) {
                mBluetoothAdapter.disable();
            }
        } catch (SecurityException ignored) {
        }
    }

    private void configureAndStartBluetoothHotspot(ConnectionOptions connectionOptions) {
        mBluetoothAdapter.setName(connectionOptions.mName);
        BluetoothUtils.setDiscoverable(HotspotManagerService.this, mBluetoothAdapter);
        startBluetoothServer();
    }

    private void startBluetoothServer() {
        mBluetoothServer = new BluetoothServer(mBluetoothAdapter);
        new Thread(mBluetoothServer).start();
    }

    private void configureAndStartWifiHotspot(ConnectionOptions connectionOptions)
            throws NoSuchMethodException, InvocationTargetException, IllegalAccessException {
        WifiConfiguration wifiConfiguration = null;
        try {
            // see: https://stackoverflow.com/questions/2140133/
            wifiConfiguration = WifiUtils.getWifiHotspotConfiguration(mWifiManager);
            WifiUtils.setConfigurationAttributes(wifiConfiguration, connectionOptions.mName,
                    connectionOptions.mPassword);
        } catch (Exception e) {
            e.printStackTrace(); // unable to get configuration
        }

        WifiUtils.setHotspotEnabled(mWifiManager, wifiConfiguration, true);
    }

    private void startWifiServer() {
        mWifiServer = new WifiServer(ConnectionOptions.DEFAULT_HOTSPOT_IP_ADDRESS,
                ConnectionOptions.DEFAULT_HOTSPOT_PORT);
        new Thread(mWifiServer).start();
    }

    // TODO: Wifi configuration seems to be ignored when turning off - does this matter?
    private void initialiseHotspots(@Nullable ConnectionOptions connectionOptions)
            throws NoSuchMethodException, InvocationTargetException, IllegalAccessException {

        mHotspotMode = true;

        if (CREATE_WIFI_HOTSPOT_SUPPORTED) {
            configureAndStartWifiHotspot(connectionOptions);
        }

        switch (mBluetoothAdapter.getState()) {
        case BluetoothAdapter.STATE_ON:
            configureAndStartBluetoothHotspot(connectionOptions);
            break;

        case BluetoothAdapter.STATE_TURNING_ON:
            break; // finish configuration in receiver

        case BluetoothAdapter.STATE_TURNING_OFF:
        case BluetoothAdapter.STATE_OFF:
        default:
            mBluetoothAdapter.enable(); // finish configuration in receiver
        }
    }

    private void destroyAllConnections() {
        mConnectionErrorHandler.removeMessages(MSG_WIFI_CONNECTION_ERROR);
        mConnectionErrorHandler.removeMessages(MSG_BLUETOOTH_CONNECTION_ERROR);
        mBluetoothAdapter.cancelDiscovery();
        mHotspotMode = false;
        mIsConnected = false;
        mWifiConnectionErrorCount = 0;
        mBluetoothConnectionErrorCount = 0;

        if (CREATE_WIFI_HOTSPOT_SUPPORTED) {
            // disconnect all servers/clients
            if (mWifiServer != null) {
                mWifiServer.closeAllConnections();
                mWifiServer = null;
            }
        }
        if (mWifiClient != null) {
            mWifiClient.closeConnection();
            mWifiClient = null;
        }
        if (mBluetoothServer != null) {
            mBluetoothServer.closeAllConnections();
            mBluetoothServer = null;
        }
        if (mBluetoothClient != null) {
            mBluetoothClient.closeConnection();
            mBluetoothClient = null;
        }

        restoreOriginalWifiHotspotState();

        mConnectionOptions = null;
    }

    private void connectWifiHotspot(@NonNull ConnectionOptions options) {
        Log.d(TAG, "Attempting connection via Wifi");
        switch (mWifiManager.getWifiState()) {
        case WifiManager.WIFI_STATE_ENABLED:
            Log.d(TAG, "Completing hotspot connection");
            finishConnectingWifiHotspot(options);
            break;

        case WifiManager.WIFI_STATE_ENABLING:
            Log.d(TAG, "Waiting for Wifi to be enabled");
            break; // will connect in receiver

        case WifiManager.WIFI_STATE_DISABLING:
        case WifiManager.WIFI_STATE_DISABLED:
        case WifiManager.WIFI_STATE_UNKNOWN:
        default:
            Log.d(TAG, "Enabling Wifi");
            mWifiManager.setWifiEnabled(true); // will connect in receiver
            break;
        }
    }

    private void connectBluetoothHotspot(@NonNull ConnectionOptions options) {
        Log.d(TAG, "Attempting connection via Bluetooth");
        switch (mBluetoothAdapter.getState()) {
        case BluetoothAdapter.STATE_ON:
            Log.d(TAG, "Starting Bluetooth discovery");
            mBluetoothAdapter.cancelDiscovery();
            mBluetoothAdapter.startDiscovery();
            setBluetoothErrorTimeout();
            break;

        case BluetoothAdapter.STATE_TURNING_ON:
            Log.d(TAG, "Waiting for Bluetooth to be enabled");
            break; // start discovery in receiver

        case BluetoothAdapter.STATE_OFF:
        case BluetoothAdapter.STATE_TURNING_OFF:
        default:
            Log.d(TAG, "Enabling Bluetooth / waiting for receiver to enable");
            mBluetoothAdapter.enable(); // start discovery in receiver
        }
    }

    private void finishConnectingWifiHotspot(@NonNull ConnectionOptions connectionOptions) {
        // set up new network - *must* be surrounded by " (see: https://stackoverflow.com/questions/2140133/)
        Log.d(TAG, "Connecting to Wifi network " + connectionOptions.mName);
        WifiConfiguration wifiConfiguration = new WifiConfiguration();
        WifiUtils.setConfigurationAttributes(wifiConfiguration, "\"" + connectionOptions.mName + "\"",
                "\"" + connectionOptions.mPassword + "\"");

        int savedNetworkId = WifiUtils.getWifiNetworkId(mWifiManager, wifiConfiguration.SSID);
        if (savedNetworkId >= 0) {
            Log.d(TAG, "Found saved Wifi network id");
            mHotspotId = savedNetworkId;
        } else {
            Log.d(TAG, "Adding Wifi network");
            mHotspotId = mWifiManager.addNetwork(wifiConfiguration);
            // mWifiManager.saveConfiguration(); // can change network IDs(!) - not really needed, so disabled
            if (mHotspotId < 0) {
                Log.d(TAG, "Couldn't add Wifi network");
                mWifiConnectionErrorCount += 1;
                retryWifiConnection();
                return;
            }
        }

        // if we're auto-connected to a previous network (unlikely!), continue straight away; if not, reconnect
        WifiInfo currentConnection = mWifiManager.getConnectionInfo();
        if (currentConnection != null && wifiConfiguration.SSID.equals(currentConnection.getSSID())) {
            Log.d(TAG, "Continuing with current Wifi connection");
            connectWifiClient(connectionOptions.mIPAddress, connectionOptions.mPort);
        } else {
            Log.d(TAG, "Enabling Wifi network");
            mWifiManager.disconnect();
            if (!mWifiManager.enableNetwork(mHotspotId, true)) { // connect to our network - handle connection in receiver
                Log.d(TAG, "Couldn't enable Wifi network");
                mWifiConnectionErrorCount += 1;
                retryWifiConnection();
            } else {
                Log.d(TAG, "Wifi network enabled");
                mWifiManager.reconnect();
                setWifiErrorTimeout();
            }
        }
    }

    private void connectWifiClient(@NonNull String ip, int port) {
        if (mWifiClient != null) {
            mWifiClient.closeConnection();
            mWifiClient = null;
        }

        sendSystemMessageToAllLocalClients(EVENT_CONNECTION_STATUS_UPDATE, "Connecting using Wifi...");
        Log.d(TAG, "Starting Wifi client connection");
        mWifiClient = new WifiClientConnection(ip, port);
        new Thread(mWifiClient).start();
        setWifiErrorTimeout(); // reset/increase the timeout to give the connection time to succeed
        Log.d(TAG, "Wifi client connection started");
    }

    private void connectBluetoothClient(@NonNull BluetoothDevice device) {
        mBluetoothAdapter.cancelDiscovery(); // no need to keep searching
        if (mBluetoothClient != null) {
            mBluetoothClient.closeConnection();
            mBluetoothClient = null;
        }

        sendSystemMessageToAllLocalClients(EVENT_CONNECTION_STATUS_UPDATE, "Connecting using Bluetooth...");
        Log.d(TAG, "Starting Bluetooth client connection");
        mBluetoothClient = new BluetoothClientConnection(device);
        new Thread(mBluetoothClient).start();
        setBluetoothErrorTimeout(); // reset/increase the timeout to give the connection time to succeed
        Log.d(TAG, "Bluetooth client connection started");
    }

    private BroadcastReceiver mGlobalBroadcastReceiver = new BroadcastReceiver() {
        @Override
        public void onReceive(Context context, Intent intent) {
            switch (intent.getAction()) {
            case HOTSPOT_STATE_FILTER: // see: http://stackoverflow.com/a/14681207
                int state = intent.getIntExtra(WifiManager.EXTRA_WIFI_STATE, 0);
                if (state % 10 == WifiManager.WIFI_STATE_ENABLED) { // TODO: should we detect manual disable events?
                    if (CREATE_WIFI_HOTSPOT_SUPPORTED) {
                        startWifiServer();
                    }
                }
                break;

            case WifiManager.WIFI_STATE_CHANGED_ACTION:
                Log.d(TAG, "Wifi state changed to: "
                        + intent.getIntExtra(WifiManager.EXTRA_WIFI_STATE, WifiManager.WIFI_STATE_UNKNOWN));
                switch (intent.getIntExtra(WifiManager.EXTRA_WIFI_STATE, WifiManager.WIFI_STATE_UNKNOWN)) {
                case WifiManager.WIFI_STATE_DISABLED:
                    // TODO: can we do this only when needed? (e.g., don't fight the user)
                    if (!mIsConnected && mConnectionOptions != null) {
                        mWifiManager.setWifiEnabled(true); // finish connection when turned on (below)
                    }
                    break;

                case WifiManager.WIFI_STATE_ENABLED:
                    Log.d(TAG, "Wifi enabled");
                    if (mHotspotMode) {
                        // nothing do do in hotspot mode - in fact, this shouldn't happen as most devices require Wifi
                        // to be off before starting a hotspot
                    } else {
                        if (!mIsConnected && mConnectionOptions != null) {
                            finishConnectingWifiHotspot(mConnectionOptions);
                        }
                    }
                    break;

                default:
                    break;
                }
                break;

            case BluetoothAdapter.ACTION_STATE_CHANGED:
                Log.d(TAG, "Bluetooth state changed to: " + intent.getIntExtra(BluetoothAdapter.EXTRA_STATE, -1));
                switch (intent.getIntExtra(BluetoothAdapter.EXTRA_STATE, -1)) {
                case BluetoothAdapter.STATE_OFF:
                    // TODO: can we do this only when needed? (e.g., don't fight the user)
                    if (!mIsConnected && mConnectionOptions != null) {
                        mBluetoothAdapter.enable(); // start discovery when turned on (below)
                    }
                    break;

                case BluetoothAdapter.STATE_ON:
                    Log.d(TAG, "Bluetooth enabled");
                    if (mHotspotMode) {
                        configureAndStartBluetoothHotspot(mConnectionOptions);
                    } else {
                        if (!mIsConnected && mConnectionOptions != null) {
                            Log.d(TAG, "Starting Bluetooth discovery (2)");
                            mBluetoothAdapter.cancelDiscovery();
                            mBluetoothAdapter.startDiscovery();
                            setBluetoothErrorTimeout();
                        }
                    }
                    break;

                default:
                    break;
                }
                break;

            case WifiManager.NETWORK_STATE_CHANGED_ACTION:
                Log.d(TAG, "Wifi network state changed");
                if (mHotspotMode) {
                    // nothing to do
                } else if (!mIsConnected && mConnectionOptions != null) {
                    boolean isConnected = false;
                    String networkName1 = null;
                    NetworkInfo networkInfo = intent.getParcelableExtra(WifiManager.EXTRA_NETWORK_INFO);
                    if (networkInfo != null) {
                        networkName1 = BetterTogetherUtils.trimQuotes(networkInfo.getExtraInfo());
                        isConnected = networkInfo.isConnected();
                    }
                    String networkName2 = null;
                    WifiInfo wifiInfo = mWifiManager.getConnectionInfo();
                    if (wifiInfo != null) {
                        networkName2 = BetterTogetherUtils.trimQuotes(wifiInfo.getSSID());
                        // isConnected = true; // not necessarily the case - could be connecting
                    }
                    Log.d(TAG, "(State change for network: " + networkName1 + " / " + networkName2 + "; connected: "
                            + isConnected + " - searching for " + mConnectionOptions.mName + ")");
                    if (isConnected && (mConnectionOptions.mName.equals(networkName1)
                            || mConnectionOptions.mName.equals(networkName2)) && mWifiClient == null) {
                        Log.d(TAG, "Continuing with current Wifi connection (2)");
                        connectWifiClient(mConnectionOptions.mIPAddress, mConnectionOptions.mPort);
                    }
                }
                break;

            case BluetoothDevice.ACTION_FOUND: // TODO: handle ACTION_BOND_STATE_CHANGED too?
                Log.d(TAG, "Bluetooth device found");
                if (mHotspotMode) {
                    // nothing to do
                } else if (!mIsConnected && mConnectionOptions != null) { // client mode
                    BluetoothDevice device = intent.getParcelableExtra(BluetoothDevice.EXTRA_DEVICE);
                    Log.d(TAG, "(Device name: " + device.getName() + " - searching for " + mConnectionOptions.mName
                            + ")");
                    if (mConnectionOptions.mName.equals(device.getName()) && mBluetoothClient == null) {
                        Log.d(TAG, "Connecting to Bluetooth device");
                        connectBluetoothClient(device);
                    }
                }
                break;

            default:
                break;
            }
        }
    };

    private class ErrorHandler extends Handler {
        @Override
        public void handleMessage(Message msg) {
            switch (msg.what) {
            case MSG_WIFI_CONNECTION_ERROR:
                Log.d(TAG, "Wifi connection error");
                mWifiConnectionErrorCount += 1;
                retryWifiConnection();
                break;

            case MSG_BLUETOOTH_CONNECTION_ERROR:
                Log.d(TAG, "Bluetooth connection error");
                mBluetoothConnectionErrorCount += 1;
                retryBluetoothConnection();
                break;

            default:
                break;
            }
        }
    }

    private void setWifiErrorTimeout() {
        Message message = Message.obtain(null, MSG_WIFI_CONNECTION_ERROR);
        mConnectionErrorHandler.sendMessageDelayed(message, mWifiConnectionTimeout);
        mWifiConnectionTimeout *= CONNECTION_DELAY_INCREMENT_MULTIPLIER;
    }

    private void setBluetoothErrorTimeout() {
        Message message = Message.obtain(null, MSG_BLUETOOTH_CONNECTION_ERROR);
        mConnectionErrorHandler.sendMessageDelayed(message, mBluetoothConnectionTimeout);
        mBluetoothConnectionTimeout *= CONNECTION_DELAY_INCREMENT_MULTIPLIER;
    }

    private void retryWifiConnection() {
        sendSystemMessageToAllLocalClients(EVENT_CONNECTION_STATUS_UPDATE,
                "Couldn't connect using Wifi  retrying " + "connection...");
        Log.d(TAG, "Retrying remote Wifi connection");

        mIsConnected = false;
        mConnectionErrorHandler.removeMessages(MSG_WIFI_CONNECTION_ERROR);

        if (mWifiClient != null) {
            mWifiClient.closeConnection();
            mWifiClient = null;
        }

        // after one failure, try restarting the adapters
        if (mWifiConnectionErrorCount > 0) {
            if (mHotspotId >= 0) {
                mWifiManager.removeNetwork(mHotspotId); // sometimes saved networks won't connect - remove and retry
                mWifiManager.saveConfiguration();
            }
            mWifiManager.setWifiEnabled(false);
        }

        connectWifiHotspot(mConnectionOptions);
    }

    private void retryBluetoothConnection() {
        sendSystemMessageToAllLocalClients(EVENT_CONNECTION_STATUS_UPDATE,
                "Couldn't connect using Bluetooth  retrying " + "connection...");
        Log.d(TAG, "Retrying remote Bluetooth connection");

        mIsConnected = false;
        mConnectionErrorHandler.removeMessages(MSG_BLUETOOTH_CONNECTION_ERROR);

        if (mBluetoothClient != null) {
            mBluetoothClient.closeConnection();
            mBluetoothClient = null;
        }

        if (mBluetoothConnectionErrorCount > 0) {
            mBluetoothAdapter.disable();
        }

        connectBluetoothHotspot(mConnectionOptions);
    }

    // handler for messages from local clients (e.g., activities that have connected to this service)
    private static class IncomingHandler extends Handler {
        private final WeakReference<HotspotManagerService> mServiceReference; // so we don't prevent garbage collection

        IncomingHandler(HotspotManagerService instance) {
            mServiceReference = new WeakReference<>(instance);
        }

        @Override
        public void handleMessage(Message msg) {
            HotspotManagerService mService = mServiceReference.get();
            if (mService == null) {
                // TODO: anything to do here?
                return;
            }

            switch (msg.what) {
            case MSG_REGISTER_CLIENT:
                mService.mClients.add(msg.replyTo);
                break;

            case MSG_UNREGISTER_CLIENT:
                mService.mClients.remove(msg.replyTo);
                if (mService.mClients.size() <= 0 && msg.arg1 != 1) { // arg1 == 1 means keep alive (activity is rotating)
                    mService.stopSelf(); // so we stop the hotspot and clean up when our final activity is killed
                }
                break;

            case MSG_ENABLE_HOTSPOT:
                mService.destroyAllConnections();
                String enableHotspotUrl = msg.getData().getString(PluginIntent.KEY_SERVICE_MESSAGE);
                if (!TextUtils.isEmpty(enableHotspotUrl)) {
                    mService.mConnectionOptions = ConnectionOptions.fromHotspotUrl(enableHotspotUrl);
                    if (mService.mConnectionOptions != null) {
                        try {
                            mService.initialiseHotspots(mService.mConnectionOptions);
                            break;
                        } catch (Exception e) {
                            e.printStackTrace(); // unable to start access point
                            mService.sendSystemMessageToAllLocalClients(EVENT_SETTINGS_PERMISSION_ERROR, null);
                            break;
                        }
                    }
                }
                mService.sendSystemMessageToAllLocalClients(EVENT_CONNECTION_INVALID_URL, null); // shouldn't happen...
                break;

            case MSG_DISABLE_HOTSPOT:
                mService.destroyAllConnections();
                mService.sendSystemMessageToAllLocalClients(EVENT_DEVICE_DISCONNECTED, null);
                break;

            case MSG_JOIN_HOTSPOT:
                mService.destroyAllConnections();
                String joinHotspotUrl = msg.getData().getString(PluginIntent.KEY_SERVICE_MESSAGE);
                if (!TextUtils.isEmpty(joinHotspotUrl)) {
                    mService.mConnectionOptions = ConnectionOptions.fromHotspotUrl(joinHotspotUrl);
                    if (mService.mConnectionOptions != null) {
                        mService.sendSystemMessageToAllLocalClients(EVENT_CONNECTION_STATUS_UPDATE, "Searching...");
                        mService.connectBluetoothHotspot(mService.mConnectionOptions);
                        mService.connectWifiHotspot(mService.mConnectionOptions);
                        break;
                    }
                }
                mService.sendSystemMessageToAllLocalClients(EVENT_CONNECTION_INVALID_URL, null);
                break;

            case MSG_UPDATE_HOTSPOT: // update connection options - currently just for package name
                String updateHotspotUrl = msg.getData().getString(PluginIntent.KEY_SERVICE_MESSAGE);
                if (!TextUtils.isEmpty(updateHotspotUrl)) {
                    ConnectionOptions updatedConnectionOptions = ConnectionOptions.fromHotspotUrl(updateHotspotUrl);
                    if (updatedConnectionOptions != null) {
                        mService.mConnectionOptions = updatedConnectionOptions;
                    }
                }
                break;

            case MSG_REQUEST_STATUS: // send a connection status update (normally used when screen rotates)
                Log.d(TAG, "Status update requested");
                if (mService.mHotspotMode) {
                    if (mService.mWifiServer != null || mService.mBluetoothServer != null) {
                        mService.sendSystemMessageToAllLocalClients(EVENT_DEVICE_CONNECTED, ROLE_SERVER);
                        Log.d(TAG, "Sending status: connected");
                    } else {
                        mService.sendSystemMessageToAllLocalClients(EVENT_DEVICE_DISCONNECTED, null);
                        Log.d(TAG, "Sending status: not connected");
                    }
                } else {
                    if (mService.mWifiClient != null || mService.mBluetoothClient != null) {
                        mService.sendSystemMessageToAllLocalClients(EVENT_DEVICE_CONNECTED, ROLE_CLIENT);
                        Log.d(TAG, "Sending status: connected");
                    } else {
                        mService.sendSystemMessageToAllLocalClients(EVENT_DEVICE_DISCONNECTED, null);
                        Log.d(TAG, "Sending status: not connected");
                    }
                }
                break;

            case MSG_BROADCAST: // messages received from our own activities
                Bundle data = msg.getData();
                BroadcastMessage message = (BroadcastMessage) data
                        .getSerializable(PluginIntent.KEY_BROADCAST_MESSAGE);

                // note: internal messages are dealt with by the service locally (but still forwarded to remote clients)
                if (message != null) {
                    if (message.isSystemMessage()) {
                        mService.handleSystemBroadcastMessage(message);
                    }
                    mService.sendBroadcastMessageToAllRemoteClients(message);
                }
                break;

            default:
                super.handleMessage(msg);
            }
        }
    }

    private BroadcastReceiver mLocalBroadcastReceiver = new BroadcastReceiver() {
        @Override
        public void onReceive(Context context, Intent intent) {
            // TODO: source is only necessary for internal plugins - remove later?
            if (HotspotManagerService.this.getPackageName()
                    .equals(intent.getStringExtra(PluginIntent.EXTRA_SOURCE))) {
                Log.d(TAG, "Ignoring message - source is self");
                return;
            }

            switch (intent.getAction()) {
            case PluginIntent.ACTION_MESSAGE_RECEIVED: // messages received from plugin activities
                BroadcastMessage message = (BroadcastMessage) intent
                        .getSerializableExtra(PluginIntent.KEY_BROADCAST_MESSAGE);

                // note: internal messages are dealt with by the service locally (but still forwarded to remote clients)
                if (message != null) {
                    if (message.isSystemMessage()) {
                        handleSystemBroadcastMessage(message);
                    }
                    sendBroadcastMessageToAllRemoteClients(message);
                }
                break;

            case PluginIntent.ACTION_STOP_PLUGIN:
                // nothing to do (yet)
                break;

            default:
                break;
            }
        }
    };

    // sends a broadcast message (e.g., something from remote clients) to all local clients
    @SuppressFBWarnings("ANDROID_BROADCAST")
    private void sendBroadcastMessageToAllLocalClients(BroadcastMessage msg) {
        for (int i = mClients.size() - 1; i >= 0; i--) {
            try {
                // local directly connected activities (i.e., ours)
                Messenger client = mClients.get(i);
                Message message = Message.obtain(null, MSG_BROADCAST);
                message.replyTo = mMessenger;
                Bundle bundle = new Bundle(1);
                bundle.putSerializable(PluginIntent.KEY_BROADCAST_MESSAGE, msg);
                message.setData(bundle);
                client.send(message);

                // local unconnected activities (i.e., plugins)
                Log.d(TAG,
                        "Sending broadcast message to all local clients with package "
                                + mConnectionOptions.mPluginPackage + " - type: " + msg.getType() + ", message: "
                                + msg.getMessage());
                Intent broadcastIntent = new Intent(PluginIntent.ACTION_MESSAGE_RECEIVED);
                broadcastIntent.setClassName(mConnectionOptions.mPluginPackage, PluginIntent.MESSAGE_RECEIVER);
                // TODO: source is only necessary for internal plugins - remove later?
                broadcastIntent.putExtra(PluginIntent.EXTRA_SOURCE, HotspotManagerService.this.getPackageName());
                broadcastIntent.putExtra(PluginIntent.KEY_BROADCAST_MESSAGE, msg);
                sendBroadcast(broadcastIntent);
            } catch (RemoteException e) {
                e.printStackTrace();
                mClients.remove(i); // client is dead - ok to remove here as we're reversing through the list
            }
        }
    }

    // sends a system message (e.g., HotspotManagerService events) to all local clients
    private void sendSystemMessageToAllLocalClients(int type, String data) {
        for (int i = mClients.size() - 1; i >= 0; i--) {
            try {
                Messenger client = mClients.get(i);
                Message message = Message.obtain(null, type);
                message.replyTo = mMessenger;
                if (data != null) {
                    Bundle bundle = new Bundle(1);
                    bundle.putString(PluginIntent.KEY_SERVICE_MESSAGE, data);
                    message.setData(bundle);
                }
                client.send(message);
            } catch (RemoteException e) {
                e.printStackTrace();
                mClients.remove(i); // client is dead - ok to remove here as we're reversing through the list
            }
        }
    }

    // sends a message to every connected remote device
    private void sendBroadcastMessageToAllRemoteClients(BroadcastMessage message) {
        try {
            if (mHotspotMode) { // we are in server mode
                message.setFrom(SERVER_MESSAGE_ID);
            }
            sendBroadcastMessageToAllRemoteClients(BroadcastMessage.toString(message), null);
        } catch (IOException e) {
            Log.d(TAG, "Broadcast message sending error: " + e.getLocalizedMessage());
        }
    }

    // send a message to all connected remote devices, optionally ignoring the client that sent the message
    // note - we use a handler thread so that our messages don't cause network events on the main thread
    private void sendBroadcastMessageToAllRemoteClients(final String message, @Nullable final String ignoreClient) {
        if (mWifiServer != null) {
            mMessageThreadHandler.post(new Runnable() {
                @Override
                public void run() {
                    mWifiServer.sendMessageToAll(message, ignoreClient);
                }
            });
        }
        if (mWifiClient != null) {
            mMessageThreadHandler.post(new Runnable() {
                @Override
                public void run() {
                    mWifiClient.sendMessage(message);
                }
            });
        }
        if (mBluetoothServer != null) {
            mMessageThreadHandler.post(new Runnable() {
                @Override
                public void run() {
                    mBluetoothServer.sendMessageToAll(message, ignoreClient);
                }
            });
        }
        if (mBluetoothClient != null) {
            mMessageThreadHandler.post(new Runnable() {
                @Override
                public void run() {
                    mBluetoothClient.sendMessage(message);
                }
            });
        }
    }

    // internal/system broadcast messages are sent to all clients, but not for plugin consumption
    private void handleSystemBroadcastMessage(BroadcastMessage message) {
        if (TextUtils.isEmpty(message.getMessage())) {
            return;
        }
        switch (message.getMessage()) {
        case SYSTEM_BROADCAST_EVENT_SHOW_QR_CODE:
            // if this is the server, must ensure that Bluetooth is still visible when we show the QR code (from any device)
            if (mHotspotMode) {
                BluetoothUtils.setDiscoverable(HotspotManagerService.this, mBluetoothAdapter);
            }
            break;

        default:
            break;
        }
    }

    // TODO: convert all this to handlers or locally broadcast intents rather than using EventBus?
    @Subscribe(threadMode = ThreadMode.MAIN)
    public void onServerError(ServerErrorEvent event) {
        Log.d(TAG, "Server error (event)");
        if (mConnectionOptions != null) {
            switch (event.mType) {
            case WIFI:
                if (mWifiConnectionErrorCount >= 10) { // TODO: improve this
                    Log.d(TAG, "Wifi server failed - restarting");
                    mWifiConnectionErrorCount += 1;
                    startWifiServer(); // TODO: this assumes that Wifi is still turned on, etc
                } else {
                    Log.d(TAG, "Wifi server failed repeatedly - aborting");
                }
                break;
            case BLUETOOTH:
                if (mBluetoothConnectionErrorCount >= 10) { // TODO: improve this
                    Log.d(TAG, "Bluetooth server failed - restarting");
                    mBluetoothConnectionErrorCount += 1;
                    startBluetoothServer(); // TODO: this assumes that Bluetooth is still turned on, etc
                } else {
                    Log.d(TAG, "Wifi server failed repeatedly - aborting");
                }
                break;
            case UNKNOWN:
            default:
                break; // TODO: is there anything we can do?
            }
        }
    }

    @Subscribe(threadMode = ThreadMode.MAIN)
    public void onServerConnectionSuccess(ServerConnectionSuccessEvent event) {
        Log.d(TAG, "Server connection success (event): " + event.mType);
        // TODO: send to all remote clients?

        // new remote client successfully connected
        sendSystemMessageToAllLocalClients(EVENT_DEVICE_CONNECTED, event.mType.toString());
    }

    @Subscribe(threadMode = ThreadMode.MAIN)
    public void onServerConnectionError(ServerMessageErrorEvent event) {
        Log.d(TAG, "Server connection error (event): " + event.mType);
        // TODO: send to all remote clients?

        // server connection error - a single remote client failed // TODO: if no more remote clients connected, re-show QR code?
        sendSystemMessageToAllLocalClients(EVENT_REMOTE_CLIENT_ERROR, event.mType.toString());
    }

    @Subscribe(threadMode = ThreadMode.MAIN)
    public void onClientConnectionSuccess(ClientConnectionSuccessEvent event) {
        Log.d(TAG, "Client connection success (event): " + event.mType);
        // TODO: send to all remote clients?

        // successfully connected to remote server
        mIsConnected = true;
        mConnectionErrorHandler.removeMessages(MSG_WIFI_CONNECTION_ERROR);
        mConnectionErrorHandler.removeMessages(MSG_BLUETOOTH_CONNECTION_ERROR);
        sendSystemMessageToAllLocalClients(EVENT_DEVICE_CONNECTED, event.mType.toString());

        // restore the default properties of the other connection method
        switch (event.mType) {
        case WIFI:
            if (mBluetoothClient != null) {
                mBluetoothClient.closeConnection();
                mBluetoothClient = null;
            }
            restoreOriginalBluetoothState();
            break;
        case BLUETOOTH:
            if (mWifiClient != null) {
                mWifiClient.closeConnection();
                mWifiClient = null;
            }
            restoreOriginalWifiState();
            break;
        case UNKNOWN:
        default:
            break; // nothing to do (should not happen)
        }
    }

    @Subscribe(threadMode = ThreadMode.MAIN)
    public void onClientConnectionError(ClientConnectionErrorEvent event) {
        Log.d(TAG, "Client connection error (event): " + event.mType);

        // client connection error - the connection to the remote server failed during setup
        switch (event.mType) {
        // only retry if it was our chosen connection type that failed (e.g., the one that is not null) - otherwise we get
        // stuck into a loop when trying to cancel unnecessary connections
        case WIFI:
            if (mWifiClient != null) {
                retryWifiConnection();
            }
            break;
        case BLUETOOTH:
            if (mBluetoothClient != null) {
                retryBluetoothConnection();
            }
            break;
        case UNKNOWN:
        default:
        }
    }

    @SuppressFBWarnings("ANDROID_BROADCAST")
    @Subscribe(threadMode = ThreadMode.MAIN)
    public void onClientMessageError(ClientMessageErrorEvent event) {
        Log.d(TAG, "Client message error (event) " + event.mType);

        // client message error - a client's connection to the remote server failed
        switch (event.mType) {
        case WIFI:
            Log.d(TAG, "Wifi client failed - restarting");
            retryWifiConnection();

            sendSystemMessageToAllLocalClients(EVENT_LOCAL_CLIENT_ERROR, event.mType.toString());

            // stop local unconnected activities (i.e., plugins)
            Log.d(TAG, "Sending stop command to all local plugins");
            Intent wifiStopIntent = new Intent(PluginIntent.ACTION_STOP_PLUGIN);
            wifiStopIntent.setClassName(mConnectionOptions.mPluginPackage, PluginIntent.MESSAGE_RECEIVER);
            // TODO: source is only necessary for internal plugins - remove later?
            wifiStopIntent.putExtra(PluginIntent.EXTRA_SOURCE, HotspotManagerService.this.getPackageName());
            sendBroadcast(wifiStopIntent);
            break;

        case BLUETOOTH:
            Log.d(TAG, "Bluetooth client failed - restarting");
            retryBluetoothConnection();

            sendSystemMessageToAllLocalClients(EVENT_LOCAL_CLIENT_ERROR, event.mType.toString());

            // stop local unconnected activities (i.e., plugins)
            Log.d(TAG, "Sending stop command to all local plugins");
            Intent bluetoothStopIntent = new Intent(PluginIntent.ACTION_STOP_PLUGIN);
            bluetoothStopIntent.setClassName(mConnectionOptions.mPluginPackage, PluginIntent.MESSAGE_RECEIVER);
            // TODO: source is only necessary for internal plugins - remove later?
            bluetoothStopIntent.putExtra(PluginIntent.EXTRA_SOURCE, HotspotManagerService.this.getPackageName());
            sendBroadcast(bluetoothStopIntent);
            break;

        case UNKNOWN:
        default:
            if (!mHotspotMode) {
                // TODO: this is caused by RemoteConnection - messages has failed, but the connection is still apparently ok
                // TODO: - should we kill the connection after a certain number of these?
                Log.d(TAG, "Client message error with unknown source - ignoring");
            } else {
                // on the server, we get error messages with source set to unknown - this is caused by the fact that all
                // connections extend RemoteConnection, and that class sends client errors rather than differentiating
                // between client and server (a limitation). When this happens, there is no need to do anything here,
                // because we get a ServerMessageErrorEvent from [Wifi|Bluetooth]ServerConnection classes when the failure
                // actually happens, and a further ServerMessageErrorEvent from [Wifi|Bluetooth]Server when the dead client
                // is removed from the list of connected sockets.
            }
            break;
        }
    }

    @Subscribe(threadMode = ThreadMode.MAIN)
    public void onMessage(MessageReceivedEvent event) {
        Log.d(TAG, "Message received (event)");

        // internal system messages are dealt with by the service locally (e.g., not sent to plugins, but sent to remote clients)
        if (event.mMessage.isSystemMessage()) {
            handleSystemBroadcastMessage(event.mMessage);
        } else {
            sendBroadcastMessageToAllLocalClients(event.mMessage); // forward to local clients
        }

        // if we're the server (e.g., not delivered by the server) then forward to all remote clients, too
        if (!SERVER_MESSAGE_ID.equals(event.mDeliveredBy)) {
            try {
                // forward to all clients if not from server - these messages already have their from attribute set
                sendBroadcastMessageToAllRemoteClients(BroadcastMessage.toString(event.mMessage),
                        event.mDeliveredBy);
            } catch (IOException e) {
                Log.d(TAG, "Broadcast message forwarding error: " + e.getLocalizedMessage());
            }
        }
    }
}