/*
* 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 .
*/
package se.lublin.mumla.service;
import android.content.BroadcastReceiver;
import android.content.Intent;
import android.content.IntentFilter;
import android.content.SharedPreferences;
import android.media.AudioManager;
import android.net.Uri;
import android.os.Binder;
import android.os.Build;
import android.os.Bundle;
import android.os.IBinder;
import android.os.PowerManager;
import android.preference.PreferenceManager;
import android.speech.tts.TextToSpeech;
import android.util.Log;
import android.widget.Toast;
import org.jsoup.Jsoup;
import org.jsoup.nodes.Document;
import org.jsoup.nodes.Element;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import se.lublin.humla.Constants;
import se.lublin.humla.HumlaService;
import se.lublin.humla.exception.AudioException;
import se.lublin.humla.model.IMessage;
import se.lublin.humla.model.IUser;
import se.lublin.humla.model.Message;
import se.lublin.humla.model.TalkState;
import se.lublin.humla.util.HumlaException;
import se.lublin.humla.util.HumlaObserver;
import se.lublin.mumla.R;
import se.lublin.mumla.Settings;
import se.lublin.mumla.service.ipc.TalkBroadcastReceiver;
import se.lublin.mumla.util.HtmlUtils;
/**
* An extension of the Humla service with some added Mumla-exclusive non-standard Mumble features.
* Created by andrew on 28/07/13.
*/
public class MumlaService extends HumlaService implements
SharedPreferences.OnSharedPreferenceChangeListener,
MumlaConnectionNotification.OnActionListener,
MumlaReconnectNotification.OnActionListener, IMumlaService {
private static final String TAG = MumlaService.class.getName();
/** Undocumented constant that permits a proximity-sensing wake lock. */
public static final int PROXIMITY_SCREEN_OFF_WAKE_LOCK = 32;
public static final int TTS_THRESHOLD = 250; // Maximum number of characters to read
public static final int RECONNECT_DELAY = 10000;
private Settings mSettings;
private MumlaConnectionNotification mNotification;
private MumlaMessageNotification mMessageNotification;
private MumlaReconnectNotification mReconnectNotification;
/** Channel view overlay. */
private MumlaOverlay mChannelOverlay;
/** Proximity lock for handset mode. */
private PowerManager.WakeLock mProximityLock;
/** Play sound when push to talk key is pressed */
private boolean mPTTSoundEnabled;
/** Try to shorten spoken messages when using TTS */
private boolean mShortTtsMessagesEnabled;
/**
* True if an error causing disconnection has been dismissed by the user.
* This should serve as a hint not to bother the user.
*/
private boolean mErrorShown;
private List mMessageLog;
private boolean mSuppressNotifications;
private TextToSpeech mTTS;
private TextToSpeech.OnInitListener mTTSInitListener = new TextToSpeech.OnInitListener() {
@Override
public void onInit(int status) {
if(status == TextToSpeech.ERROR)
logWarning(getString(R.string.tts_failed));
}
};
/** The view representing the hot corner. */
private MumlaHotCorner mHotCorner;
private MumlaHotCorner.MumlaHotCornerListener mHotCornerListener = new MumlaHotCorner.MumlaHotCornerListener() {
@Override
public void onHotCornerDown() {
onTalkKeyDown();
}
@Override
public void onHotCornerUp() {
onTalkKeyUp();
}
};
private BroadcastReceiver mTalkReceiver;
private HumlaObserver mObserver = new HumlaObserver() {
@Override
public void onConnecting() {
// Remove old notification left from reconnect,
if (mReconnectNotification != null) {
mReconnectNotification.hide();
mReconnectNotification = null;
}
final String tor = mSettings.isTorEnabled() ? " (Tor)" : "";
mNotification = MumlaConnectionNotification.create(MumlaService.this,
getString(R.string.mumlaConnecting) + tor,
getString(R.string.connecting) + tor,
MumlaService.this);
mNotification.show();
mErrorShown = false;
}
@Override
public void onConnected() {
if (mNotification != null) {
final String tor = mSettings.isTorEnabled() ? " (Tor)" : "";
mNotification.setCustomTicker(getString(R.string.mumlaConnected) + tor);
mNotification.setCustomContentText(getString(R.string.connected) + tor);
mNotification.setActionsShown(true);
mNotification.show();
}
}
@Override
public void onDisconnected(HumlaException e) {
if (mNotification != null) {
mNotification.hide();
mNotification = null;
}
if (e != null && !mSuppressNotifications) {
mReconnectNotification =
MumlaReconnectNotification.show(MumlaService.this,
e.getMessage() + (mSettings.isTorEnabled() ? " (Tor)" : ""),
isReconnecting(), MumlaService.this);
}
}
@Override
public void onUserConnected(IUser user) {
if (user.getTextureHash() != null &&
user.getTexture() == null) {
// Request avatar data if available.
requestAvatar(user.getSession());
}
}
@Override
public void onUserStateUpdated(IUser user) {
if(user.getSession() == getSessionId()) {
mSettings.setMutedAndDeafened(user.isSelfMuted(), user.isSelfDeafened()); // Update settings mute/deafen state
if(mNotification != null) {
String contentText;
if (user.isSelfMuted() && user.isSelfDeafened())
contentText = getString(R.string.status_notify_muted_and_deafened);
else if (user.isSelfMuted())
contentText = getString(R.string.status_notify_muted);
else
contentText = getString(R.string.connected);
mNotification.setCustomContentText(contentText);
mNotification.show();
}
}
if (user.getTextureHash() != null &&
user.getTexture() == null) {
// Update avatar data if available.
requestAvatar(user.getSession());
}
}
@Override
public void onMessageLogged(IMessage message) {
// Split on / strip all HTML tags.
Document parsedMessage = Jsoup.parseBodyFragment(message.getMessage());
String strippedMessage = parsedMessage.text();
String ttsMessage;
if(mShortTtsMessagesEnabled) {
for (Element anchor : parsedMessage.getElementsByTag("A")) {
// Get just the domain portion of links
String href = anchor.attr("href");
// Only shorten anchors without custom text
if (href != null && href.equals(anchor.text())) {
String urlHostname = HtmlUtils.getHostnameFromLink(href);
if (urlHostname != null) {
anchor.text(getString(R.string.chat_message_tts_short_link, urlHostname));
}
}
}
ttsMessage = parsedMessage.text();
} else {
ttsMessage = strippedMessage;
}
String formattedTtsMessage = getString(R.string.notification_message,
message.getActorName(), ttsMessage);
// Read if TTS is enabled, the message is less than threshold, is a text message, and not deafened
if(mSettings.isTextToSpeechEnabled() &&
mTTS != null &&
formattedTtsMessage.length() <= TTS_THRESHOLD &&
getSessionUser() != null &&
!getSessionUser().isSelfDeafened()) {
mTTS.speak(formattedTtsMessage, TextToSpeech.QUEUE_ADD, null);
}
// TODO: create a customizable notification sieve
if (mSettings.isChatNotifyEnabled()) {
mMessageNotification.show(message);
}
mMessageLog.add(new IChatMessage.TextMessage(message));
}
@Override
public void onLogInfo(String message) {
mMessageLog.add(new IChatMessage.InfoMessage(IChatMessage.InfoMessage.Type.INFO, message));
}
@Override
public void onLogWarning(String message) {
mMessageLog.add(new IChatMessage.InfoMessage(IChatMessage.InfoMessage.Type.WARNING, message));
}
@Override
public void onLogError(String message) {
mMessageLog.add(new IChatMessage.InfoMessage(IChatMessage.InfoMessage.Type.ERROR, message));
}
@Override
public void onPermissionDenied(String reason) {
if(mNotification != null && !mSuppressNotifications) {
mNotification.setCustomTicker(reason);
mNotification.show();
}
}
@Override
public void onUserTalkStateUpdated(IUser user) {
if (isConnectionEstablished() &&
getSessionId() == user.getSession() &&
getTransmitMode() == Constants.TRANSMIT_PUSH_TO_TALK &&
user.getTalkState() == TalkState.TALKING &&
mPTTSoundEnabled) {
AudioManager audioManager = (AudioManager) getSystemService(AUDIO_SERVICE);
audioManager.playSoundEffect(AudioManager.FX_KEYPRESS_STANDARD, -1);
}
}
};
@Override
public void onCreate() {
super.onCreate();
registerObserver(mObserver);
// Register for preference changes
mSettings = Settings.getInstance(this);
mPTTSoundEnabled = mSettings.isPttSoundEnabled();
mShortTtsMessagesEnabled = mSettings.isShortTextToSpeechMessagesEnabled();
SharedPreferences preferences = PreferenceManager.getDefaultSharedPreferences(this);
preferences.registerOnSharedPreferenceChangeListener(this);
// Manually set theme to style overlay views
// XML theme does NOT do this!
setTheme(R.style.Theme_Mumla);
mMessageLog = new ArrayList<>();
mMessageNotification = new MumlaMessageNotification(MumlaService.this);
// Instantiate overlay view
mChannelOverlay = new MumlaOverlay(this);
mHotCorner = new MumlaHotCorner(this, mSettings.getHotCornerGravity(), mHotCornerListener);
// Set up TTS
if(mSettings.isTextToSpeechEnabled())
mTTS = new TextToSpeech(this, mTTSInitListener);
mTalkReceiver = new TalkBroadcastReceiver(this);
}
@Override
public IBinder onBind(Intent intent) {
return new MumlaBinder(this);
}
@Override
public void onDestroy() {
if (mNotification != null) {
mNotification.hide();
mNotification = null;
}
if (mReconnectNotification != null) {
mReconnectNotification.hide();
mReconnectNotification = null;
}
SharedPreferences preferences = PreferenceManager.getDefaultSharedPreferences(this);
preferences.unregisterOnSharedPreferenceChangeListener(this);
try {
unregisterReceiver(mTalkReceiver);
} catch (IllegalArgumentException e) {
e.printStackTrace();
}
unregisterObserver(mObserver);
if(mTTS != null) mTTS.shutdown();
mMessageLog = null;
mMessageNotification.dismiss();
super.onDestroy();
}
@Override
public void onConnectionSynchronized() {
// TODO? We seem to be getting a RuntimeException here, from the call
// to the superclass function (in HumlaService). In there,
// mConnect.getSession() finds that isSynchronized==false and throws
// NotSynchronizedException (which is re-thrown as the
// RuntimeException). But how can it be !isSynchronized? -- A server
// msg triggers HumlaConnection.messageServerSync(), which sets up
// mSession and mSynchronized==true and then proceeds to call us from
// a Runnable post()ed to a Handler. The reason could only be that
// HumlaConnect.connect() or disconnect() is called again in the
// middle of all this? And it's made possible by the Handler?
try {
super.onConnectionSynchronized();
} catch (RuntimeException e) {
Log.d(TAG, "exception in onConnectionSynchronized: " + e);
return;
}
// Restore mute/deafen state
if(mSettings.isMuted() || mSettings.isDeafened()) {
setSelfMuteDeafState(mSettings.isMuted(), mSettings.isDeafened());
}
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
registerReceiver(mTalkReceiver, new IntentFilter(TalkBroadcastReceiver.BROADCAST_TALK), RECEIVER_EXPORTED);
} else {
registerReceiver(mTalkReceiver, new IntentFilter(TalkBroadcastReceiver.BROADCAST_TALK));
}
if (mSettings.isHotCornerEnabled()) {
mHotCorner.setShown(true);
}
// Configure proximity sensor
if (mSettings.isHandsetMode()) {
setProximitySensorOn(true);
}
}
@Override
public void onConnectionDisconnected(HumlaException e) {
super.onConnectionDisconnected(e);
try {
unregisterReceiver(mTalkReceiver);
} catch (IllegalArgumentException iae) {
}
// Remove overlay if present.
mChannelOverlay.hide();
mHotCorner.setShown(false);
setProximitySensorOn(false);
clearMessageLog();
mMessageNotification.dismiss();
}
/**
* Called when the user makes a change to their preferences.
* Should update all preferences relevant to the service.
*/
@Override
public void onSharedPreferenceChanged(SharedPreferences sharedPreferences, String key) {
Bundle changedExtras = new Bundle();
boolean requiresReconnect = false;
switch (key) {
case Settings.PREF_INPUT_METHOD:
/* Convert input method defined in settings to an integer format used by Humla. */
int inputMethod = mSettings.getHumlaInputMethod();
changedExtras.putInt(HumlaService.EXTRAS_TRANSMIT_MODE, inputMethod);
mChannelOverlay.setPushToTalkShown(inputMethod == Constants.TRANSMIT_PUSH_TO_TALK);
break;
case Settings.PREF_HANDSET_MODE:
setProximitySensorOn(isConnectionEstablished() && mSettings.isHandsetMode());
changedExtras.putInt(HumlaService.EXTRAS_AUDIO_STREAM, mSettings.isHandsetMode() ?
AudioManager.STREAM_VOICE_CALL : AudioManager.STREAM_MUSIC);
break;
case Settings.PREF_THRESHOLD:
changedExtras.putFloat(HumlaService.EXTRAS_DETECTION_THRESHOLD,
mSettings.getDetectionThreshold());
break;
case Settings.PREF_HOT_CORNER_KEY:
mHotCorner.setGravity(mSettings.getHotCornerGravity());
mHotCorner.setShown(isConnectionEstablished() && mSettings.isHotCornerEnabled());
break;
case Settings.PREF_USE_TTS:
if (mTTS == null && mSettings.isTextToSpeechEnabled())
mTTS = new TextToSpeech(this, mTTSInitListener);
else if (mTTS != null && !mSettings.isTextToSpeechEnabled()) {
mTTS.shutdown();
mTTS = null;
}
break;
case Settings.PREF_SHORT_TTS_MESSAGES:
mShortTtsMessagesEnabled = mSettings.isShortTextToSpeechMessagesEnabled();
break;
case Settings.PREF_AMPLITUDE_BOOST:
changedExtras.putFloat(EXTRAS_AMPLITUDE_BOOST,
mSettings.getAmplitudeBoostMultiplier());
break;
case Settings.PREF_HALF_DUPLEX:
changedExtras.putBoolean(EXTRAS_HALF_DUPLEX, mSettings.isHalfDuplex());
break;
case Settings.PREF_PREPROCESSOR_ENABLED:
changedExtras.putBoolean(EXTRAS_ENABLE_PREPROCESSOR,
mSettings.isPreprocessorEnabled());
break;
case Settings.PREF_PTT_SOUND:
mPTTSoundEnabled = mSettings.isPttSoundEnabled();
break;
case Settings.PREF_INPUT_QUALITY:
changedExtras.putInt(EXTRAS_INPUT_QUALITY, mSettings.getInputQuality());
break;
case Settings.PREF_INPUT_RATE:
changedExtras.putInt(EXTRAS_INPUT_RATE, mSettings.getInputSampleRate());
break;
case Settings.PREF_FRAMES_PER_PACKET:
changedExtras.putInt(EXTRAS_FRAMES_PER_PACKET, mSettings.getFramesPerPacket());
break;
case Settings.PREF_CERT_ID:
case Settings.PREF_FORCE_TCP:
case Settings.PREF_USE_TOR:
case Settings.PREF_DISABLE_OPUS:
// These are settings we flag as 'requiring reconnect'.
requiresReconnect = true;
break;
}
if (changedExtras.size() > 0) {
try {
// Reconfigure the service appropriately.
requiresReconnect |= configureExtras(changedExtras);
} catch (AudioException e) {
e.printStackTrace();
}
}
if (requiresReconnect && isConnectionEstablished()) {
Toast.makeText(this, R.string.change_requires_reconnect, Toast.LENGTH_LONG).show();
}
}
private void setProximitySensorOn(boolean on) {
if(on) {
PowerManager pm = (PowerManager) getSystemService(POWER_SERVICE);
mProximityLock = pm.newWakeLock(PROXIMITY_SCREEN_OFF_WAKE_LOCK, "Mumla:Proximity");
mProximityLock.acquire();
} else {
if(mProximityLock != null) mProximityLock.release();
mProximityLock = null;
}
}
@Override
public void onMuteToggled() {
IUser user = getSessionUser();
if (isConnectionEstablished() && user != null) {
boolean muted = !user.isSelfMuted();
boolean deafened = user.isSelfDeafened() && muted;
setSelfMuteDeafState(muted, deafened);
}
}
@Override
public void onDeafenToggled() {
IUser user = getSessionUser();
if (isConnectionEstablished() && user != null) {
setSelfMuteDeafState(!user.isSelfDeafened(), !user.isSelfDeafened());
}
}
@Override
public void onOverlayToggled() {
// Ditch notification shade/panel to make overlay presence/permission request visible.
// But on Android 12 that's no longer allowed.
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.S) {
Intent close = new Intent(Intent.ACTION_CLOSE_SYSTEM_DIALOGS);
getApplicationContext().sendBroadcast(close);
}
if (!mChannelOverlay.isShown()) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
if (!android.provider.Settings.canDrawOverlays(getApplicationContext())) {
Intent showSetting = new Intent(android.provider.Settings.ACTION_MANAGE_OVERLAY_PERMISSION,
Uri.parse("package:" + getPackageName()));
showSetting.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
startActivity(showSetting);
Toast.makeText(this, R.string.grant_perm_draw_over_apps, Toast.LENGTH_LONG).show();
return;
}
}
mChannelOverlay.show();
} else {
mChannelOverlay.hide();
}
}
@Override
public void onReconnectNotificationDismissed() {
mErrorShown = true;
}
@Override
public void reconnect() {
connect();
}
@Override
public void cancelReconnect() {
if (mReconnectNotification != null) {
mReconnectNotification.hide();
mReconnectNotification = null;
}
super.cancelReconnect();
}
@Override
public void setOverlayShown(boolean showOverlay) {
if(!mChannelOverlay.isShown()) {
mChannelOverlay.show();
} else {
mChannelOverlay.hide();
}
}
@Override
public boolean isOverlayShown() {
return mChannelOverlay.isShown();
}
@Override
public void clearChatNotifications() {
mMessageNotification.dismiss();
}
@Override
public void markErrorShown() {
mErrorShown = true;
// Dismiss the reconnection prompt if a reconnection isn't in progress.
if (mReconnectNotification != null && !isReconnecting()) {
mReconnectNotification.hide();
mReconnectNotification = null;
}
}
@Override
public boolean isErrorShown() {
return mErrorShown;
}
/**
* Called when a user presses a talk key down (i.e. when they want to talk).
* Accounts for talk logic if toggle PTT is on.
*/
@Override
public void onTalkKeyDown() {
if(isConnectionEstablished()
&& Settings.ARRAY_INPUT_METHOD_PTT.equals(mSettings.getInputMethod())) {
if (!mSettings.isPushToTalkToggle() && !isTalking()) {
setTalkingState(true); // Start talking
}
}
}
/**
* Called when a user releases a talk key (i.e. when they do not want to talk).
* Accounts for talk logic if toggle PTT is on.
*/
@Override
public void onTalkKeyUp() {
if(isConnectionEstablished()
&& Settings.ARRAY_INPUT_METHOD_PTT.equals(mSettings.getInputMethod())) {
if (mSettings.isPushToTalkToggle()) {
setTalkingState(!isTalking()); // Toggle talk state
} else if (isTalking()) {
setTalkingState(false); // Stop talking
}
}
}
@Override
public List getMessageLog() {
return Collections.unmodifiableList(mMessageLog);
}
@Override
public void clearMessageLog() {
if (mMessageLog != null) {
mMessageLog.clear();
}
}
/**
* Sets whether or not notifications should be suppressed.
*
* It's typically a good idea to do this when the main activity is foreground, so that the user
* is not bombarded with redundant alerts.
*
* Chat notifications are NOT suppressed. They may be if a chat indicator is added in the
* activity itself. For now, the user may disable chat notifications manually.
*
* @param suppressNotifications true if Mumla is to disable notifications.
*/
@Override
public void setSuppressNotifications(boolean suppressNotifications) {
mSuppressNotifications = suppressNotifications;
}
public static class MumlaBinder extends Binder {
private final MumlaService mService;
private MumlaBinder(MumlaService service) {
mService = service;
}
public IMumlaService getService() {
return mService;
}
}
@Override
public Message sendUserTextMessage(int session, String message) {
Message msg = super.sendUserTextMessage(session, message);
mMessageLog.add(new IChatMessage.TextMessage(msg));
return msg;
}
@Override
public Message sendChannelTextMessage(int channel, String message, boolean tree) {
Message msg = super.sendChannelTextMessage(channel, message, tree);
mMessageLog.add(new IChatMessage.TextMessage(msg));
return msg;
}
}