diff options
Diffstat (limited to 'src/main/java/se/lublin/humla/HumlaService.java')
-rw-r--r-- | src/main/java/se/lublin/humla/HumlaService.java | 1199 |
1 files changed, 1199 insertions, 0 deletions
diff --git a/src/main/java/se/lublin/humla/HumlaService.java b/src/main/java/se/lublin/humla/HumlaService.java new file mode 100644 index 0000000..07bf287 --- /dev/null +++ b/src/main/java/se/lublin/humla/HumlaService.java @@ -0,0 +1,1199 @@ +/* + * Copyright (C) 2014 Andrew Comminos + * + * 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 se.lublin.humla; + +import android.app.Service; +import android.content.BroadcastReceiver; +import android.content.Context; +import android.content.Intent; +import android.content.IntentFilter; +import android.media.AudioManager; +import android.net.ConnectivityManager; +import android.net.NetworkInfo; +import android.os.Binder; +import android.os.Build; +import android.os.Bundle; +import android.os.Handler; +import android.os.IBinder; +import android.os.PowerManager; +import android.util.Log; + +import se.lublin.humla.audio.AudioOutput; +import se.lublin.humla.audio.BluetoothScoReceiver; +import se.lublin.humla.audio.inputmode.ActivityInputMode; +import se.lublin.humla.audio.inputmode.ContinuousInputMode; +import se.lublin.humla.audio.inputmode.IInputMode; +import se.lublin.humla.audio.inputmode.ToggleInputMode; +import se.lublin.humla.audio.javacpp.CELT7; +import se.lublin.humla.exception.AudioException; +import se.lublin.humla.exception.NotConnectedException; +import se.lublin.humla.exception.NotSynchronizedException; +import se.lublin.humla.model.Channel; +import se.lublin.humla.model.IChannel; +import se.lublin.humla.model.IUser; +import se.lublin.humla.model.Message; +import se.lublin.humla.model.Server; +import se.lublin.humla.model.TalkState; +import se.lublin.humla.model.User; +import se.lublin.humla.model.WhisperTarget; +import se.lublin.humla.model.WhisperTargetList; +import se.lublin.humla.net.HumlaConnection; +import se.lublin.humla.net.HumlaTCPMessageType; +import se.lublin.humla.net.HumlaUDPMessageType; +import se.lublin.humla.protobuf.Mumble; +import se.lublin.humla.protocol.AudioHandler; +import se.lublin.humla.protocol.ModelHandler; +import se.lublin.humla.util.IHumlaObserver; +import se.lublin.humla.util.HumlaCallbacks; +import se.lublin.humla.util.HumlaDisconnectedException; +import se.lublin.humla.util.HumlaException; +import se.lublin.humla.util.HumlaLogger; +import se.lublin.humla.util.VoiceTargetMode; + +import java.security.Security; +import java.security.cert.X509Certificate; +import java.util.ArrayList; +import java.util.List; + +public class HumlaService extends Service implements IHumlaService, IHumlaSession, HumlaConnection.HumlaConnectionListener, HumlaLogger, BluetoothScoReceiver.Listener { + + static { + // Use Spongy Castle for crypto implementation so we can create and manage PKCS #12 (.p12) certificates. + Security.insertProviderAt(new org.spongycastle.jce.provider.BouncyCastleProvider(), 1); + } + + /** + * An action to immediately connect to a given Mumble server. + * Requires that {@link #EXTRAS_SERVER} is provided. + */ + public static final String ACTION_CONNECT = "se.lublin.humla.CONNECT"; + + /** A {@link Server} specifying the server to connect to. */ + public static final String EXTRAS_SERVER = "server"; + public static final String EXTRAS_AUTO_RECONNECT = "auto_reconnect"; + public static final String EXTRAS_AUTO_RECONNECT_DELAY = "auto_reconnect_delay"; + public static final String EXTRAS_CERTIFICATE = "certificate"; + public static final String EXTRAS_CERTIFICATE_PASSWORD = "certificate_password"; + public static final String EXTRAS_DETECTION_THRESHOLD = "detection_threshold"; + public static final String EXTRAS_AMPLITUDE_BOOST = "amplitude_boost"; + public static final String EXTRAS_TRANSMIT_MODE = "transmit_mode"; + public static final String EXTRAS_INPUT_RATE = "input_frequency"; + public static final String EXTRAS_INPUT_QUALITY = "input_quality"; + public static final String EXTRAS_USE_OPUS = "use_opus"; + public static final String EXTRAS_FORCE_TCP = "force_tcp"; + public static final String EXTRAS_USE_TOR = "use_tor"; + public static final String EXTRAS_CLIENT_NAME = "client_name"; + public static final String EXTRAS_ACCESS_TOKENS = "access_tokens"; + public static final String EXTRAS_AUDIO_SOURCE = "audio_source"; + public static final String EXTRAS_AUDIO_STREAM = "audio_stream"; + public static final String EXTRAS_FRAMES_PER_PACKET = "frames_per_packet"; + /** An optional path to a trust store for CA certificates. */ + public static final String EXTRAS_TRUST_STORE = "trust_store"; + /** The trust store's password. */ + public static final String EXTRAS_TRUST_STORE_PASSWORD = "trust_store_password"; + /** The trust store's format. */ + public static final String EXTRAS_TRUST_STORE_FORMAT = "trust_store_format"; + public static final String EXTRAS_HALF_DUPLEX = "half_duplex"; + /** A list of users that should be local muted upon connection. */ + public static final String EXTRAS_LOCAL_MUTE_HISTORY = "local_mute_history"; + /** A list of users that should be local ignored upon connection. */ + public static final String EXTRAS_LOCAL_IGNORE_HISTORY = "local_ignore_history"; + public static final String EXTRAS_ENABLE_PREPROCESSOR = "enable_preprocessor"; + + // Service settings + private Server mServer; + private boolean mAutoReconnect; + private int mAutoReconnectDelay; + private byte[] mCertificate; + private String mCertificatePassword; + private boolean mUseOpus; + private boolean mForceTcp; + private boolean mUseTor; + private String mClientName; + private List<String> mAccessTokens; + private String mTrustStore; + private String mTrustStorePassword; + private String mTrustStoreFormat; + private List<Integer> mLocalMuteHistory; + private List<Integer> mLocalIgnoreHistory; + private AudioHandler.Builder mAudioBuilder; + private int mTransmitMode; + + private byte mVoiceTargetId; + private WhisperTargetList mWhisperTargetList; + + private PowerManager.WakeLock mWakeLock; + private Handler mHandler; + private HumlaCallbacks mCallbacks; + + private HumlaConnection mConnection; + private ConnectionState mConnectionState; + private ModelHandler mModelHandler; + private AudioHandler mAudioHandler; + private BluetoothScoReceiver mBluetoothReceiver; + + private ActivityInputMode mActivityInputMode; + private ToggleInputMode mToggleInputMode; + private ContinuousInputMode mContinuousInputMode; + + private boolean mReconnecting; + + /** + * Listen for connectivity changes in the reconnection state, and reconnect accordingly. + */ + private BroadcastReceiver mConnectivityReceiver = new BroadcastReceiver() { + @Override + public void onReceive(Context context, Intent intent) { + if (!mReconnecting) { + unregisterReceiver(this); + return; + } + + ConnectivityManager cm = (ConnectivityManager) getSystemService(CONNECTIVITY_SERVICE); + if (cm.getActiveNetworkInfo() != null && cm.getActiveNetworkInfo().isConnected()) { + Log.v(Constants.TAG, "Connectivity restored, attempting reconnect."); + connect(); + } + } + }; + + private AudioHandler.AudioEncodeListener mAudioInputListener = + new AudioHandler.AudioEncodeListener() { + @Override + public void onAudioEncoded(byte[] data, int length) { + if(mConnection != null && mConnection.isSynchronized()) { + mConnection.sendUDPMessage(data, length, false); + } + } + + @Override + public void onTalkingStateChanged(final boolean talking) { + mHandler.post(new Runnable() { + @Override + public void run() { + try { + // If the server session is inactive, ignore this message. + // It's likely that this is leftover from a terminated connection. + if (!isSynchronized()) + return; + + final User currentUser = mModelHandler.getUser(mConnection.getSession()); + if (currentUser == null) return; + + currentUser.setTalkState(talking ? TalkState.TALKING : TalkState.PASSIVE); + mCallbacks.onUserTalkStateUpdated(currentUser); + } catch (NotSynchronizedException e) { + e.printStackTrace(); + } + } + }); + } + }; + + private AudioOutput.AudioOutputListener mAudioOutputListener = new AudioOutput.AudioOutputListener() { + @Override + public void onUserTalkStateUpdated(final User user) { + mCallbacks.onUserTalkStateUpdated(user); + } + + @Override + public User getUser(int session) { + if (mModelHandler != null) { + return mModelHandler.getUser(session); + } + return null; + } + }; + + @Override + public int onStartCommand(Intent intent, int flags, int startId) { + if (intent != null) { + Bundle extras = intent.getExtras(); + if (extras != null) { + try { + configureExtras(extras); + } catch (AudioException e) { + throw new RuntimeException("Attempted to initialize audio in onStartCommand erroneously."); + } + } + + if (ACTION_CONNECT.equals(intent.getAction())) { + if (extras == null || !extras.containsKey(EXTRAS_SERVER)) { + // Ensure that we have been provided all required attributes.``` + throw new RuntimeException(ACTION_CONNECT + " requires a server provided in extras."); + } + connect(); + } + } + + return START_NOT_STICKY; + } + + @Override + public void onCreate() { + super.onCreate(); + PowerManager powerManager = (PowerManager) getSystemService(POWER_SERVICE); + mWakeLock = powerManager.newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, "Humla"); + mHandler = new Handler(getMainLooper()); + mCallbacks = new HumlaCallbacks(); + mAudioBuilder = new AudioHandler.Builder() + .setContext(this) + .setLogger(this) + .setEncodeListener(mAudioInputListener) + .setTalkingListener(mAudioOutputListener); + mConnectionState = ConnectionState.DISCONNECTED; + mBluetoothReceiver = new BluetoothScoReceiver(this, this); + registerReceiver(mBluetoothReceiver, new IntentFilter(AudioManager.ACTION_SCO_AUDIO_STATE_CHANGED)); + mToggleInputMode = new ToggleInputMode(); + mActivityInputMode = new ActivityInputMode(0); // FIXME: reasonable default + mContinuousInputMode = new ContinuousInputMode(); + mWhisperTargetList = new WhisperTargetList(); + } + + @Override + public void onDestroy() { + unregisterReceiver(mBluetoothReceiver); + super.onDestroy(); + } + + public IBinder onBind(Intent intent) { + return new HumlaBinder(this); + } + + protected void connect() { + try { + setReconnecting(false); + mConnectionState = ConnectionState.DISCONNECTED; + mVoiceTargetId = 0; + mWhisperTargetList.clear(); + + mConnection = new HumlaConnection(this); + mConnection.setForceTCP(mForceTcp); + mConnection.setUseTor(mUseTor); + mConnection.setKeys(mCertificate, mCertificatePassword); + mConnection.setTrustStore(mTrustStore, mTrustStorePassword, mTrustStoreFormat); + + mModelHandler = new ModelHandler(this, mCallbacks, this, + mLocalMuteHistory, mLocalIgnoreHistory); + mConnection.addTCPMessageHandlers(mModelHandler); + + mConnectionState = ConnectionState.CONNECTING; + + mCallbacks.onConnecting(); + + mConnection.connect(mServer.getHost(), mServer.getPort()); + } catch (HumlaException e) { + e.printStackTrace(); + mCallbacks.onDisconnected(e); + } + } + + public void disconnect() { + if (mConnection != null) { + mConnection.disconnect(); + } + } + + public boolean isConnectionEstablished() { + return mConnection != null && mConnection.isConnected(); + } + + /** + * @return true if Humla has received the ServerSync message, indicating synchronization with + * the server's model and settings. This is the main state of the service. + */ + public boolean isSynchronized() { + return mConnection != null && mConnection.isSynchronized(); + } + + @Override + public void onConnectionEstablished() { + // Send version information and authenticate. + final Mumble.Version.Builder version = Mumble.Version.newBuilder(); + version.setRelease(mClientName); + version.setVersion(Constants.PROTOCOL_VERSION); + version.setOs("Android"); + version.setOsVersion(Build.VERSION.RELEASE); + + final Mumble.Authenticate.Builder auth = Mumble.Authenticate.newBuilder(); + auth.setUsername(mServer.getUsername()); + auth.setPassword(mServer.getPassword()); + auth.addCeltVersions(CELT7.getBitstreamVersion()); + // FIXME: resolve issues with CELT 11 robot voices. +// auth.addCeltVersions(Constants.CELT_11_VERSION); + auth.setOpus(mUseOpus); + auth.addAllTokens(mAccessTokens); + + mConnection.sendTCPMessage(version.build(), HumlaTCPMessageType.Version); + mConnection.sendTCPMessage(auth.build(), HumlaTCPMessageType.Authenticate); + } + + @Override + public void onConnectionSynchronized() { + mConnectionState = ConnectionState.CONNECTED; + + Log.v(Constants.TAG, "Connected"); + mWakeLock.acquire(); + + try { + mAudioHandler = mAudioBuilder.initialize( + mModelHandler.getUser(mConnection.getSession()), + mConnection.getMaxBandwidth(), mConnection.getCodec(), + mVoiceTargetId); + mConnection.addTCPMessageHandlers(mAudioHandler); + mConnection.addUDPMessageHandlers(mAudioHandler); + } catch (AudioException e) { + e.printStackTrace(); + onConnectionWarning(e.getMessage()); + } catch (NotSynchronizedException e) { + throw new RuntimeException("Connection should be synchronized in callback for synchronization!", e); + } + + mCallbacks.onConnected(); + } + + @Override + public void onConnectionHandshakeFailed(X509Certificate[] chain) { + mCallbacks.onTLSHandshakeFailed(chain); + } + + @Override + public void onConnectionDisconnected(HumlaException e) { + if (e != null) { + Log.e(Constants.TAG, "Error: " + e.getMessage() + + " (reason: " + e.getReason().name() + ")"); + mConnectionState = ConnectionState.CONNECTION_LOST; + + setReconnecting(mAutoReconnect + && e.getReason() == HumlaException.HumlaDisconnectReason.CONNECTION_ERROR); + } else { + Log.v(Constants.TAG, "Disconnected"); + mConnectionState = ConnectionState.DISCONNECTED; + } + + if(mWakeLock.isHeld()) { + mWakeLock.release(); + } + + if (mAudioHandler != null) { + mAudioHandler.shutdown(); + } + + mModelHandler = null; + mAudioHandler = null; + mVoiceTargetId = 0; + mWhisperTargetList.clear(); + + // Halt SCO connection on shutdown. + mBluetoothReceiver.stopBluetoothSco(); + + mCallbacks.onDisconnected(e); + } + + @Override + public void onConnectionWarning(String warning) { + logWarning(warning); + } + + @Override + public void logInfo(String message) { + if (mConnection == null || !mConnection.isSynchronized()) + return; // don't log info prior to synchronization + mCallbacks.onLogInfo(message); + } + + @Override + public void logWarning(String message) { + mCallbacks.onLogWarning(message); + } + + @Override + public void logError(String message) { + mCallbacks.onLogError(message); + } + + public void setReconnecting(boolean reconnecting) { + if (mReconnecting == reconnecting) + return; + + mReconnecting = reconnecting; + if (reconnecting) { + ConnectivityManager cm = (ConnectivityManager) getSystemService(CONNECTIVITY_SERVICE); + NetworkInfo info = cm.getActiveNetworkInfo(); + if (info != null && info.isConnected()) { + Log.v(Constants.TAG, "Connection lost due to non-connectivity issue. Start reconnect polling."); + Handler mainHandler = new Handler(); + mainHandler.postDelayed(new Runnable() { + @Override + public void run() { + if (mReconnecting) connect(); + } + }, mAutoReconnectDelay); + } else { + // In the event that we've lost connectivity, don't poll. Wait until network + // returns before we resume connection attempts. + Log.v(Constants.TAG, "Connection lost due to connectivity issue. Waiting until network returns."); + try { + registerReceiver(mConnectivityReceiver, + new IntentFilter(ConnectivityManager.CONNECTIVITY_ACTION)); + } catch (IllegalArgumentException e) { + e.printStackTrace(); + } + } + } else { + try { + unregisterReceiver(mConnectivityReceiver); + } catch (IllegalArgumentException e) { + e.printStackTrace(); + } + } + } + + /** + * Instantiates an audio handler with the current service settings, destroying any previous + * handler. Requires synchronization with the server, as the maximum bandwidth and session must + * be known. + */ + private void createAudioHandler() throws AudioException { + if (BuildConfig.DEBUG && mConnectionState != ConnectionState.CONNECTED) { + throw new AssertionError("Attempted to instantiate audio handler when not connected!"); + } + + if (mAudioHandler != null) { + mConnection.removeTCPMessageHandler(mAudioHandler); + mConnection.removeUDPMessageHandler(mAudioHandler); + mAudioHandler.shutdown(); + } + + try { + mAudioHandler = mAudioBuilder.initialize( + mModelHandler.getUser(mConnection.getSession()), + mConnection.getMaxBandwidth(), mConnection.getCodec(), + mVoiceTargetId); + mConnection.addTCPMessageHandlers(mAudioHandler); + mConnection.addUDPMessageHandlers(mAudioHandler); + } catch (NotSynchronizedException e) { + throw new RuntimeException("Attempted to create audio handler when not synchronized!"); + } + } + + /** + * Loads all defined settings from the given bundle into the HumlaService. + * Some settings may only take effect after a reconnect. + * @param extras A bundle with settings. + * @return true if a reconnect is required for changes to take effect. + * @see se.lublin.humla.HumlaService + */ + public boolean configureExtras(Bundle extras) throws AudioException { + boolean reconnectNeeded = false; + if (extras.containsKey(EXTRAS_SERVER)) { + mServer = extras.getParcelable(EXTRAS_SERVER); + reconnectNeeded = true; + } + if (extras.containsKey(EXTRAS_AUTO_RECONNECT)) { + mAutoReconnect = extras.getBoolean(EXTRAS_AUTO_RECONNECT); + } + if (extras.containsKey(EXTRAS_AUTO_RECONNECT_DELAY)) { + mAutoReconnectDelay = extras.getInt(EXTRAS_AUTO_RECONNECT_DELAY); + } + if (extras.containsKey(EXTRAS_CERTIFICATE)) { + mCertificate = extras.getByteArray(EXTRAS_CERTIFICATE); + reconnectNeeded = true; + } + if (extras.containsKey(EXTRAS_CERTIFICATE_PASSWORD)) { + mCertificatePassword = extras.getString(EXTRAS_CERTIFICATE_PASSWORD); + reconnectNeeded = true; + } + if (extras.containsKey(EXTRAS_DETECTION_THRESHOLD)) { + mActivityInputMode.setThreshold(extras.getFloat(EXTRAS_DETECTION_THRESHOLD)); + } + if (extras.containsKey(EXTRAS_AMPLITUDE_BOOST)) { + mAudioBuilder.setAmplitudeBoost(extras.getFloat(EXTRAS_AMPLITUDE_BOOST)); + } + if (extras.containsKey(EXTRAS_TRANSMIT_MODE)) { + mTransmitMode = extras.getInt(EXTRAS_TRANSMIT_MODE); + IInputMode inputMode; + switch (mTransmitMode) { + case Constants.TRANSMIT_PUSH_TO_TALK: + inputMode = mToggleInputMode; + break; + case Constants.TRANSMIT_CONTINUOUS: + inputMode = mContinuousInputMode; + break; + case Constants.TRANSMIT_VOICE_ACTIVITY: + inputMode = mActivityInputMode; + break; + default: + throw new IllegalArgumentException(); + } + mAudioBuilder.setInputMode(inputMode); + } + if (extras.containsKey(EXTRAS_INPUT_RATE)) { + mAudioBuilder.setInputSampleRate(extras.getInt(EXTRAS_INPUT_RATE)); + } + if (extras.containsKey(EXTRAS_INPUT_QUALITY)) { + mAudioBuilder.setTargetBitrate(extras.getInt(EXTRAS_INPUT_QUALITY)); + } + if (extras.containsKey(EXTRAS_USE_OPUS)) { + mUseOpus = extras.getBoolean(EXTRAS_USE_OPUS); + reconnectNeeded = true; + } + if (extras.containsKey(EXTRAS_USE_TOR)) { + mUseTor = extras.getBoolean(EXTRAS_USE_TOR); + mForceTcp |= mUseTor; // Tor requires TCP connections to work- if it's on, force TCP. + reconnectNeeded = true; + } + if (extras.containsKey(EXTRAS_FORCE_TCP)) { + mForceTcp |= extras.getBoolean(EXTRAS_FORCE_TCP); + reconnectNeeded = true; + } + if (extras.containsKey(EXTRAS_CLIENT_NAME)) { + mClientName = extras.getString(EXTRAS_CLIENT_NAME); + reconnectNeeded = true; + } + if (extras.containsKey(EXTRAS_ACCESS_TOKENS)) { + mAccessTokens = extras.getStringArrayList(EXTRAS_ACCESS_TOKENS); + if (mConnection != null && mConnection.isConnected()) { + mConnection.sendAccessTokens(mAccessTokens); + } + } + if (extras.containsKey(EXTRAS_AUDIO_SOURCE)) { + mAudioBuilder.setAudioSource(extras.getInt(EXTRAS_AUDIO_SOURCE)); + } + if (extras.containsKey(EXTRAS_AUDIO_STREAM)) { + mAudioBuilder.setAudioStream(extras.getInt(EXTRAS_AUDIO_STREAM)); + } + if (extras.containsKey(EXTRAS_FRAMES_PER_PACKET)) { + mAudioBuilder.setTargetFramesPerPacket(extras.getInt(EXTRAS_FRAMES_PER_PACKET)); + } + if (extras.containsKey(EXTRAS_TRUST_STORE)) { + mTrustStore = extras.getString(EXTRAS_TRUST_STORE); + reconnectNeeded = true; + } + if (extras.containsKey(EXTRAS_TRUST_STORE_PASSWORD)) { + mTrustStorePassword = extras.getString(EXTRAS_TRUST_STORE_PASSWORD); + reconnectNeeded = true; + } + if (extras.containsKey(EXTRAS_TRUST_STORE_FORMAT)) { + mTrustStoreFormat = extras.getString(EXTRAS_TRUST_STORE_FORMAT); + reconnectNeeded = true; + } + if (extras.containsKey(EXTRAS_HALF_DUPLEX)) { + mAudioBuilder.setHalfDuplexEnabled( + extras.getInt(EXTRAS_TRANSMIT_MODE) == Constants.TRANSMIT_PUSH_TO_TALK + && extras.getBoolean(EXTRAS_HALF_DUPLEX)); + } + if (extras.containsKey(EXTRAS_LOCAL_MUTE_HISTORY)) { + mLocalMuteHistory = extras.getIntegerArrayList(EXTRAS_LOCAL_MUTE_HISTORY); + reconnectNeeded = true; + } + if (extras.containsKey(EXTRAS_LOCAL_IGNORE_HISTORY)) { + mLocalIgnoreHistory = extras.getIntegerArrayList(EXTRAS_LOCAL_IGNORE_HISTORY); + reconnectNeeded = true; + } + if (extras.containsKey(EXTRAS_ENABLE_PREPROCESSOR)) { + mAudioBuilder.setPreprocessorEnabled(extras.getBoolean(EXTRAS_ENABLE_PREPROCESSOR)); + } + + // Reload audio subsystem if initialized + if (mAudioHandler != null && mAudioHandler.isInitialized()) { + createAudioHandler(); + Log.i(Constants.TAG, "Audio subsystem reloaded after settings change."); + } + return reconnectNeeded; + } + + @Override + public void onBluetoothScoConnected() { + // After an SCO connection is established, audio is rerouted to be compatible with SCO. + mAudioBuilder.setBluetoothEnabled(true); + if (mAudioHandler != null) { + try { + createAudioHandler(); + } catch (AudioException e) { + e.printStackTrace(); + } + } + } + + @Override + public void onBluetoothScoDisconnected() { + // Restore audio settings after disconnection. + mAudioBuilder.setBluetoothEnabled(false); + if (mAudioHandler != null) { + try { + createAudioHandler(); + } catch (AudioException e) { + e.printStackTrace(); + } + } + } + + /** + * Exposes the current connection. The current connection is set once an attempt to connect to + * a server is made, and remains set until a subsequent connection. It remains available + * after disconnection to provide information regarding the terminated connection. + * @return The active {@link HumlaConnection}. + */ + public HumlaConnection getConnection() { + return mConnection; + } + + /** + * Returnes the current {@link AudioHandler}. An AudioHandler is instantiated upon connection + * to a server, and destroyed upon disconnection. + * @return the active AudioHandler, or null if there is no active connection. + */ + private AudioHandler getAudioHandler() throws NotSynchronizedException { + if (!isSynchronized()) + throw new NotSynchronizedException(); + if (mAudioHandler == null && mConnectionState == ConnectionState.CONNECTED) + throw new RuntimeException("Audio handler should always be instantiated while connected!"); + return mAudioHandler; + } + + /** + * Returns the current {@link ModelHandler}, containing the channel tree. A model handler is + * valid for the lifetime of a connection. + * @return the active ModelHandler, or null if there is no active connection. + */ + private ModelHandler getModelHandler() throws NotSynchronizedException { + if (!isSynchronized()) + throw new NotSynchronizedException(); + if (mModelHandler == null && mConnectionState == ConnectionState.CONNECTED) + throw new RuntimeException("Model handler should always be instantiated while connected!"); + return mModelHandler; + } + + /** + * Returns the bluetooth service provider, established after synchronization. + * @return The {@link BluetoothScoReceiver} attached to this service. + */ + private BluetoothScoReceiver getBluetoothReceiver() throws NotSynchronizedException { + if (!isSynchronized()) + throw new NotSynchronizedException(); + return mBluetoothReceiver; + } + + @Override + public HumlaService.ConnectionState getConnectionState() { + return mConnectionState; + } + + @Override + public HumlaException getConnectionError() { + HumlaConnection connection = getConnection(); + return connection != null ? connection.getError() : null; + } + + @Override + public boolean isReconnecting() { + return mReconnecting; + } + + @Override + public void cancelReconnect() { + setReconnecting(false); + } + + @Override + public Server getTargetServer() { + return mServer; + } + + @Override + public IHumlaSession getSession() throws HumlaDisconnectedException { + if (mConnectionState != ConnectionState.CONNECTED) + throw new HumlaDisconnectedException(); + return this; + } + + @Override + public long getTCPLatency() { + try { + return getConnection().getTCPLatency(); + } catch (NotConnectedException e) { + throw new IllegalStateException(e); + } + } + + @Override + public long getUDPLatency() { + try { + return getConnection().getUDPLatency(); + } catch (NotConnectedException e) { + throw new IllegalStateException(e); + } + } + + @Override + public int getMaxBandwidth() { + try { + return getConnection().getMaxBandwidth(); + } catch (NotSynchronizedException e) { + throw new IllegalStateException(e); + } + } + + @Override + public int getCurrentBandwidth() { + try { + return getAudioHandler().getCurrentBandwidth(); + } catch (NotSynchronizedException e) { + throw new IllegalStateException(e); + } + } + + @Override + public int getServerVersion() { + try { + return getConnection().getServerVersion(); + } catch (NotSynchronizedException e) { + throw new IllegalStateException(e); + } + } + + @Override + public String getServerRelease() { + try { + return getConnection().getServerRelease(); + } catch (NotSynchronizedException e) { + throw new IllegalStateException(e); + } + } + + @Override + public String getServerOSName() { + try { + return getConnection().getServerOSName(); + } catch (NotSynchronizedException e) { + throw new IllegalStateException(e); + } + } + + @Override + public String getServerOSVersion() { + try { + return getConnection().getServerOSVersion(); + } catch (NotSynchronizedException e) { + throw new IllegalStateException(e); + } + } + + @Override + public int getSessionId() { + try { + return getConnection().getSession(); + } catch (NotSynchronizedException e) { + throw new IllegalStateException(e); + } + } + + @Override + public IUser getSessionUser() { + try { + return getModelHandler().getUser(getSessionId()); + } catch (NotSynchronizedException e) { + throw new IllegalStateException(e); + } + } + + @Override + public IChannel getSessionChannel() { + IUser user = getSessionUser(); + if (user != null) + return user.getChannel(); + throw new IllegalStateException("Session user should be set post-synchronization!"); + } + + @Override + public IUser getUser(int session) { + try { + return getModelHandler().getUser(session); + } catch (NotSynchronizedException e) { + throw new IllegalStateException(e); + } + } + + @Override + public IChannel getChannel(int id) { + try { + return getModelHandler().getChannel(id); + } catch (NotSynchronizedException e) { + throw new IllegalStateException(e); + } + } + + @Override + public IChannel getRootChannel() { + return getChannel(0); + } + + @Override + public int getPermissions() { + try { + return getModelHandler().getPermissions(); + } catch (NotSynchronizedException e) { + throw new IllegalStateException(e); + } + } + + @Override + public int getTransmitMode() { + return mTransmitMode; + } + + @Override + public HumlaUDPMessageType getCodec() { + try { + return getConnection().getCodec(); + } catch (NotSynchronizedException e) { + throw new IllegalStateException(e); + } + } + + @Override + public boolean usingBluetoothSco() { + try { + return getBluetoothReceiver().isBluetoothScoOn(); + } catch (NotSynchronizedException e) { + throw new IllegalStateException(e); + } + } + + @Override + public void enableBluetoothSco() { + try { + getBluetoothReceiver().startBluetoothSco(); + } catch (NotSynchronizedException e) { + throw new IllegalStateException(e); + } + } + + @Override + public void disableBluetoothSco() { + try { + getBluetoothReceiver().stopBluetoothSco(); + } catch (NotSynchronizedException e) { + throw new IllegalStateException(e); + } + } + + @Override + public boolean isTalking() { + return mToggleInputMode.isTalkingOn(); + } + + @Override + public void setTalkingState(boolean talking) { + mToggleInputMode.setTalkingOn(talking); + } + + @Override + public void joinChannel(int channel) { + moveUserToChannel(getSessionId(), channel); + } + + @Override + public void moveUserToChannel(int session, int channel) { + Mumble.UserState.Builder usb = Mumble.UserState.newBuilder(); + usb.setSession(session); + usb.setChannelId(channel); + getConnection().sendTCPMessage(usb.build(), HumlaTCPMessageType.UserState); + } + + @Override + public void createChannel(int parent, String name, String description, int position, boolean temporary) { + Mumble.ChannelState.Builder csb = Mumble.ChannelState.newBuilder(); + csb.setParent(parent); + csb.setName(name); + csb.setDescription(description); + csb.setPosition(position); + csb.setTemporary(temporary); + getConnection().sendTCPMessage(csb.build(), HumlaTCPMessageType.ChannelState); + } + + @Override + public void sendAccessTokens(final List<String> tokens) { + getConnection().sendAccessTokens(tokens); + } + + @Override + public void requestBanList() { + throw new UnsupportedOperationException("Not yet implemented"); // TODO + } + + @Override + public void requestUserList() { + throw new UnsupportedOperationException("Not yet implemented"); // TODO + } + + @Override + public void requestPermissions(int channel) { + Mumble.PermissionQuery.Builder pqb = Mumble.PermissionQuery.newBuilder(); + pqb.setChannelId(channel); + getConnection().sendTCPMessage(pqb.build(), HumlaTCPMessageType.PermissionQuery); + } + + @Override + public void requestComment(int session) { + Mumble.RequestBlob.Builder rbb = Mumble.RequestBlob.newBuilder(); + rbb.addSessionComment(session); + getConnection().sendTCPMessage(rbb.build(), HumlaTCPMessageType.RequestBlob); + } + + @Override + public void requestAvatar(int session) { + Mumble.RequestBlob.Builder rbb = Mumble.RequestBlob.newBuilder(); + rbb.addSessionTexture(session); + getConnection().sendTCPMessage(rbb.build(), HumlaTCPMessageType.RequestBlob); + } + + @Override + public void requestChannelDescription(int channel) { + Mumble.RequestBlob.Builder rbb = Mumble.RequestBlob.newBuilder(); + rbb.addChannelDescription(channel); + getConnection().sendTCPMessage(rbb.build(), HumlaTCPMessageType.RequestBlob); + } + + @Override + public void registerUser(int session) { + Mumble.UserState.Builder usb = Mumble.UserState.newBuilder(); + usb.setSession(session); + usb.setUserId(0); + getConnection().sendTCPMessage(usb.build(), HumlaTCPMessageType.UserState); + } + + @Override + public void kickBanUser(int session, String reason, boolean ban) { + Mumble.UserRemove.Builder urb = Mumble.UserRemove.newBuilder(); + urb.setSession(session); + urb.setReason(reason); + urb.setBan(ban); + getConnection().sendTCPMessage(urb.build(), HumlaTCPMessageType.UserRemove); + } + + @Override + public Message sendUserTextMessage(int session, String message) { + try { + if (!isSynchronized()) + throw new NotSynchronizedException(); + + Mumble.TextMessage.Builder tmb = Mumble.TextMessage.newBuilder(); + tmb.addSession(session); + tmb.setMessage(message); + getConnection().sendTCPMessage(tmb.build(), HumlaTCPMessageType.TextMessage); + + User self = getModelHandler().getUser(getSessionId()); + User user = getModelHandler().getUser(session); + List<User> users = new ArrayList<User>(1); + users.add(user); + return new Message(getSessionId(), self.getName(), new ArrayList<Channel>(0), new ArrayList<Channel>(0), users, message); + } catch (NotSynchronizedException e) { + throw new IllegalStateException(e); + } + } + + @Override + public Message sendChannelTextMessage(int channel, String message, boolean tree) { + try { + if (!isSynchronized()) + throw new NotSynchronizedException(); + + Mumble.TextMessage.Builder tmb = Mumble.TextMessage.newBuilder(); + if (tree) tmb.addTreeId(channel); + else tmb.addChannelId(channel); + tmb.setMessage(message); + getConnection().sendTCPMessage(tmb.build(), HumlaTCPMessageType.TextMessage); + + User self = getModelHandler().getUser(getSessionId()); + Channel targetChannel = getModelHandler().getChannel(channel); + List<Channel> targetChannels = new ArrayList<Channel>(); + targetChannels.add(targetChannel); + return new Message(getSessionId(), self.getName(), targetChannels, tree ? targetChannels : new ArrayList<Channel>(0), new ArrayList<User>(0), message); + } catch (NotSynchronizedException e) { + throw new IllegalStateException(e); + } + } + + @Override + public void setUserComment(int session, String comment) { + Mumble.UserState.Builder usb = Mumble.UserState.newBuilder(); + usb.setSession(session); + usb.setComment(comment); + getConnection().sendTCPMessage(usb.build(), HumlaTCPMessageType.UserState); + } + + @Override + public void setPrioritySpeaker(int session, boolean priority) { + Mumble.UserState.Builder usb = Mumble.UserState.newBuilder(); + usb.setSession(session); + usb.setPrioritySpeaker(priority); + getConnection().sendTCPMessage(usb.build(), HumlaTCPMessageType.UserState); + } + + @Override + public void removeChannel(int channel) { + Mumble.ChannelRemove.Builder crb = Mumble.ChannelRemove.newBuilder(); + crb.setChannelId(channel); + getConnection().sendTCPMessage(crb.build(), HumlaTCPMessageType.ChannelRemove); + } + + @Override + public void setMuteDeafState(int session, boolean mute, boolean deaf) { + Mumble.UserState.Builder usb = Mumble.UserState.newBuilder(); + usb.setSession(session); + usb.setMute(mute); + usb.setDeaf(deaf); + if (!mute) usb.setSuppress(false); + getConnection().sendTCPMessage(usb.build(), HumlaTCPMessageType.UserState); + } + + @Override + public void setSelfMuteDeafState(boolean mute, boolean deaf) { + Mumble.UserState.Builder usb = Mumble.UserState.newBuilder(); + usb.setSelfMute(mute); + usb.setSelfDeaf(deaf); + getConnection().sendTCPMessage(usb.build(), HumlaTCPMessageType.UserState); + } + + public void registerObserver(IHumlaObserver observer) { + mCallbacks.registerObserver(observer); + } + + public void unregisterObserver(IHumlaObserver observer) { + mCallbacks.unregisterObserver(observer); + } + + @Override + public boolean isConnected() { + return mConnectionState == ConnectionState.CONNECTED; + } + + @Override + public void linkChannels(IChannel channelA, IChannel channelB) { + Mumble.ChannelState.Builder csb = Mumble.ChannelState.newBuilder(); + csb.setChannelId(channelA.getId()); + csb.addLinksAdd(channelB.getId()); + getConnection().sendTCPMessage(csb.build(), HumlaTCPMessageType.ChannelState); + } + + @Override + public void unlinkChannels(IChannel channelA, IChannel channelB) { + Mumble.ChannelState.Builder csb = Mumble.ChannelState.newBuilder(); + csb.setChannelId(channelA.getId()); + csb.addLinksRemove(channelB.getId()); + getConnection().sendTCPMessage(csb.build(), HumlaTCPMessageType.ChannelState); + } + + @Override + public void unlinkAllChannels(IChannel channel) { + Mumble.ChannelState.Builder csb = Mumble.ChannelState.newBuilder(); + csb.setChannelId(channel.getId()); + for (IChannel linked : channel.getLinks()) { + csb.addLinksRemove(linked.getId()); + } + getConnection().sendTCPMessage(csb.build(), HumlaTCPMessageType.ChannelState); + } + + @Override + public byte registerWhisperTarget(final WhisperTarget target) { + byte id = mWhisperTargetList.append(target); + if (id < 0) { + return -1; + } + + Mumble.VoiceTarget.Target voiceTarget = target.createTarget(); + Mumble.VoiceTarget.Builder vtb = Mumble.VoiceTarget.newBuilder(); + vtb.setId(id); + vtb.addTargets(voiceTarget); + getConnection().sendTCPMessage(vtb.build(), HumlaTCPMessageType.VoiceTarget); + return id; + } + + @Override + public void unregisterWhisperTarget(byte targetId) { + mWhisperTargetList.free(targetId); + } + + @Override + public void setVoiceTargetId(byte targetId) { + if ((targetId & ~0x1F) > 0) { + throw new IllegalArgumentException("Target ID must be at most 5 bits."); + } + mVoiceTargetId = targetId; + mAudioHandler.setVoiceTargetId(targetId); + mCallbacks.onVoiceTargetChanged(VoiceTargetMode.fromId(targetId)); + } + + @Override + public byte getVoiceTargetId() { + return mVoiceTargetId; + } + + @Override + public VoiceTargetMode getVoiceTargetMode() { + return VoiceTargetMode.fromId(mVoiceTargetId); + } + + @Override + public WhisperTarget getWhisperTarget() { + if (VoiceTargetMode.fromId(mVoiceTargetId) == VoiceTargetMode.WHISPER) { + return mWhisperTargetList.get(mVoiceTargetId); + } + return null; + } + + /** + * The current connection state of the service. + */ + public enum ConnectionState { + /** + * The default state of Humla, before connection to a server and after graceful/expected + * disconnection from a server. + */ + DISCONNECTED, + /** + * A connection to the server is currently in progress. + */ + CONNECTING, + /** + * Humla has received all data necessary for normal protocol communication with the server. + */ + CONNECTED, + /** + * The connection was lost due to either a kick/ban or socket I/O error. + * Humla may be reconnecting in this state. + * @see #isReconnecting() + * @see #cancelReconnect() + */ + CONNECTION_LOST + } + + public static class HumlaBinder extends Binder { + private final IHumlaService mService; + + private HumlaBinder(IHumlaService service) { + mService = service; + } + + public IHumlaService getService() { + return mService; + } + } +} |