/* * 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; } }