content: /*
   * 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 com.morlunk.mumbleclient.service;
  
  import android.app.AlertDialog;
  import android.content.BroadcastReceiver;
  import android.content.Intent;
  import android.content.IntentFilter;
  import android.content.SharedPreferences;
  import android.media.AudioManager;
  import android.os.Binder;
  import android.os.Bundle;
  import android.os.IBinder;
  import android.os.PowerManager;
  import android.preference.PreferenceManager;
  import android.speech.tts.TextToSpeech;
  import android.view.WindowManager;
  
  import com.morlunk.jumble.Constants;
  import com.morlunk.jumble.JumbleService;
  import com.morlunk.jumble.exception.AudioException;
  import com.morlunk.jumble.model.IMessage;
  import com.morlunk.jumble.model.IUser;
  import com.morlunk.jumble.model.TalkState;
  import com.morlunk.jumble.util.JumbleException;
  import com.morlunk.jumble.util.JumbleObserver;
  import com.morlunk.mumbleclient.R;
  import com.morlunk.mumbleclient.Settings;
  import com.morlunk.mumbleclient.service.ipc.TalkBroadcastReceiver;
  import com.morlunk.mumbleclient.util.HtmlUtils;
  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;
  
  /**
   * An extension of the Jumble service with some added Plumble-exclusive non-standard Mumble features.
   * Created by andrew on 28/07/13.
   */
  public class PlumbleService extends JumbleService implements
          SharedPreferences.OnSharedPreferenceChangeListener,
          PlumbleConnectionNotification.OnActionListener,
          PlumbleReconnectNotification.OnActionListener, IPlumbleService {
      /** 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 PlumbleConnectionNotification mNotification;
      private PlumbleMessageNotification mMessageNotification;
      private PlumbleReconnectNotification mReconnectNotification;
      /** Channel view overlay. */
      private PlumbleOverlay 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<IChatMessage> 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 PlumbleHotCorner mHotCorner;
      private PlumbleHotCorner.PlumbleHotCornerListener mHotCornerListener = new PlumbleHotCorner.PlumbleHotCornerListener() {
          @Override
          public void onHotCornerDown() {
              onTalkKeyDown();
          }
  
          @Override
          public void onHotCornerUp() {
              onTalkKeyUp();
          }
      };
  
      private BroadcastReceiver mTalkReceiver;
  
      private JumbleObserver mObserver = new JumbleObserver() {
  
          @Override
          public void onConnecting() {
              // Remove old notification left from reconnect,
              if (mReconnectNotification != null) {
                  mReconnectNotification.hide();
                  mReconnectNotification = null;
              }
  
              mNotification = PlumbleConnectionNotification.create(PlumbleService.this,
                      getString(R.string.plumbleConnecting),
                      getString(R.string.connecting),
                      PlumbleService.this);
              mNotification.show();
  
              mErrorShown = false;
          }
  
          @Override
          public void onConnected() {
              if (mNotification != null) {
                  mNotification.setCustomTicker(getString(R.string.plumbleConnected));
                  mNotification.setCustomContentText(getString(R.string.connected));
                  mNotification.setActionsShown(true);
                  mNotification.show();
              }
          }
  
          @Override
          public void onDisconnected(JumbleException e) {
              if (mNotification != null) {
                  mNotification.hide();
                  mNotification = null;
              }
              if (e != null && !mSuppressNotifications) {
                  mReconnectNotification =
                          PlumbleReconnectNotification.show(PlumbleService.this, e.getMessage(),
                                  isReconnecting(),
                                  PlumbleService.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 <application> theme does NOT do this!
          setTheme(R.style.Theme_Plumble);
  
          // Instantiate overlay view
          mChannelOverlay = new PlumbleOverlay(this);
          mHotCorner = new PlumbleHotCorner(this, mSettings.getHotCornerGravity(), mHotCornerListener);
  
          // Set up TTS
          if(mSettings.isTextToSpeechEnabled())
              mTTS = new TextToSpeech(this, mTTSInitListener);
  
          mTalkReceiver = new TalkBroadcastReceiver(this);
          mMessageLog = new ArrayList<>();
          mMessageNotification = new PlumbleMessageNotification(PlumbleService.this);
      }
  
      @Override
      public IBinder onBind(Intent intent) {
          return new PlumbleBinder(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() {
          super.onConnectionSynchronized();
  
          // Restore mute/deafen state
          if(mSettings.isMuted() || mSettings.isDeafened()) {
              setSelfMuteDeafState(mSettings.isMuted(), mSettings.isDeafened());
          }
  
          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(JumbleException e) {
          super.onConnectionDisconnected(e);
          try {
              unregisterReceiver(mTalkReceiver);
          } catch (IllegalArgumentException iae) {
          }
  
          // Remove overlay if present.
          mChannelOverlay.hide();
  
          mHotCorner.setShown(false);
  
          setProximitySensorOn(false);
  
          mMessageLog.clear();
          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 Jumble. */
                  int inputMethod = mSettings.getJumbleInputMethod();
                  changedExtras.putInt(JumbleService.EXTRAS_TRANSMIT_MODE, inputMethod);
                  mChannelOverlay.setPushToTalkShown(inputMethod == Constants.TRANSMIT_PUSH_TO_TALK);
                  break;
              case Settings.PREF_HANDSET_MODE:
                  setProximitySensorOn(isConnectionEstablished() && mSettings.isHandsetMode());
                  changedExtras.putInt(JumbleService.EXTRAS_AUDIO_STREAM, mSettings.isHandsetMode() ?
                                       AudioManager.STREAM_VOICE_CALL : AudioManager.STREAM_MUSIC);
                  break;
              case Settings.PREF_THRESHOLD:
                  changedExtras.putFloat(JumbleService.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()) {
              AlertDialog ad = new AlertDialog.Builder(this)
                      .setTitle(R.string.information)
                      .setMessage(R.string.change_requires_reconnect)
                      .setPositiveButton(android.R.string.ok, null)
                      .create();
              ad.getWindow().setType(WindowManager.LayoutParams.TYPE_SYSTEM_ALERT);
              ad.show();
          }
      }
  
      private void setProximitySensorOn(boolean on) {
          if(on) {
              PowerManager pm = (PowerManager) getSystemService(POWER_SERVICE);
              mProximityLock = pm.newWakeLock(PROXIMITY_SCREEN_OFF_WAKE_LOCK, "plumble_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() {
          if (!mChannelOverlay.isShown()) {
              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<IChatMessage> getMessageLog() {
          return Collections.unmodifiableList(mMessageLog);
      }
  
      @Override
      public void clearMessageLog() {
          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.
       *
       * <b>Chat notifications are NOT suppressed.</b> 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 Plumble is to disable notifications.
       */
      @Override
      public void setSuppressNotifications(boolean suppressNotifications) {
          mSuppressNotifications = suppressNotifications;
      }
  
      public static class PlumbleBinder extends Binder {
          private final PlumbleService mService;
  
          private PlumbleBinder(PlumbleService service) {
              mService = service;
          }
  
          public IPlumbleService getService() {
              return mService;
          }
      }
  }
  