content: /*
   * Copyright (C) 2010 The Android Open Source Project
   *
   * Licensed under the Apache License, Version 2.0 (the "License");
   * you may not use this file except in compliance with the License.
   * You may obtain a copy of the License at
   *
   *      http://www.apache.org/licenses/LICENSE-2.0
   *
   * Unless required by applicable law or agreed to in writing, software
   * distributed under the License is distributed on an "AS IS" BASIS,
   * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
   * See the License for the specific language governing permissions and
   * limitations under the License.
   */
  
  package com.android.server.sip;
  
  import gov.nist.javax.sip.clientauthutils.AccountManager;
  import gov.nist.javax.sip.clientauthutils.UserCredentials;
  import gov.nist.javax.sip.header.ProxyAuthenticate;
  import gov.nist.javax.sip.header.ReferTo;
  import gov.nist.javax.sip.header.SIPHeaderNames;
  import gov.nist.javax.sip.header.StatusLine;
  import gov.nist.javax.sip.header.WWWAuthenticate;
  import gov.nist.javax.sip.header.extensions.ReferredByHeader;
  import gov.nist.javax.sip.header.extensions.ReplacesHeader;
  import gov.nist.javax.sip.message.SIPMessage;
  import gov.nist.javax.sip.message.SIPResponse;
  
  import android.net.sip.ISipSession;
  import android.net.sip.ISipSessionListener;
  import android.net.sip.SipErrorCode;
  import android.net.sip.SipProfile;
  import android.net.sip.SipSession;
  import android.net.sip.SipSessionAdapter;
  import android.text.TextUtils;
  import android.telephony.Rlog;
  
  import java.io.IOException;
  import java.io.UnsupportedEncodingException;
  import java.net.DatagramSocket;
  import java.net.InetAddress;
  import java.net.UnknownHostException;
  import java.text.ParseException;
  import java.util.EventObject;
  import java.util.HashMap;
  import java.util.Map;
  import java.util.Properties;
  
  import javax.sip.ClientTransaction;
  import javax.sip.Dialog;
  import javax.sip.DialogTerminatedEvent;
  import javax.sip.IOExceptionEvent;
  import javax.sip.ObjectInUseException;
  import javax.sip.RequestEvent;
  import javax.sip.ResponseEvent;
  import javax.sip.ServerTransaction;
  import javax.sip.SipException;
  import javax.sip.SipFactory;
  import javax.sip.SipListener;
  import javax.sip.SipProvider;
  import javax.sip.SipStack;
  import javax.sip.TimeoutEvent;
  import javax.sip.Transaction;
  import javax.sip.TransactionTerminatedEvent;
  import javax.sip.address.Address;
  import javax.sip.address.SipURI;
  import javax.sip.header.CSeqHeader;
  import javax.sip.header.ContactHeader;
  import javax.sip.header.ExpiresHeader;
  import javax.sip.header.FromHeader;
  import javax.sip.header.HeaderAddress;
  import javax.sip.header.MinExpiresHeader;
  import javax.sip.header.ReferToHeader;
  import javax.sip.header.ViaHeader;
  import javax.sip.message.Message;
  import javax.sip.message.Request;
  import javax.sip.message.Response;
  
  
  /**
   * Manages {@link ISipSession}'s for a SIP account.
   */
  class SipSessionGroup implements SipListener {
      private static final String TAG = "SipSession";
      private static final boolean DBG = false;
      private static final boolean DBG_PING = false;
      private static final String ANONYMOUS = "anonymous";
      // Limit the size of thread pool to 1 for the order issue when the phone is
      // waken up from sleep and there are many packets to be processed in the SIP
      // stack. Note: The default thread pool size in NIST SIP stack is -1 which is
      // unlimited.
      private static final String THREAD_POOL_SIZE = "1";
      private static final int EXPIRY_TIME = 3600; // in seconds
      private static final int CANCEL_CALL_TIMER = 3; // in seconds
      private static final int END_CALL_TIMER = 3; // in seconds
      private static final int KEEPALIVE_TIMEOUT = 5; // in seconds
      private static final int INCALL_KEEPALIVE_INTERVAL = 10; // in seconds
      private static final long WAKE_LOCK_HOLDING_TIME = 500; // in milliseconds
  
      private static final EventObject DEREGISTER = new EventObject("Deregister");
      private static final EventObject END_CALL = new EventObject("End call");
  
      private final SipProfile mLocalProfile;
      private final String mPassword;
  
      private SipStack mSipStack;
      private SipHelper mSipHelper;
  
      // session that processes INVITE requests
      private SipSessionImpl mCallReceiverSession;
      private String mLocalIp;
  
      private SipWakeupTimer mWakeupTimer;
      private SipWakeLock mWakeLock;
  
      // call-id-to-SipSession map
      private Map<String, SipSessionImpl> mSessionMap =
              new HashMap<String, SipSessionImpl>();
  
      // external address observed from any response
      private String mExternalIp;
      private int mExternalPort;
  
      /**
       * @param profile the local profile with password crossed out
       * @param password the password of the profile
       * @throws SipException if cannot assign requested address
       */
      public SipSessionGroup(SipProfile profile, String password,
              SipWakeupTimer timer, SipWakeLock wakeLock) throws SipException {
          mLocalProfile = profile;
          mPassword = password;
          mWakeupTimer = timer;
          mWakeLock = wakeLock;
          reset();
      }
  
      // TODO: remove this method once SipWakeupTimer can better handle variety
      // of timeout values
      void setWakeupTimer(SipWakeupTimer timer) {
          mWakeupTimer = timer;
      }
  
      synchronized void reset() throws SipException {
          Properties properties = new Properties();
  
          String protocol = mLocalProfile.getProtocol();
          int port = mLocalProfile.getPort();
          String server = mLocalProfile.getProxyAddress();
  
          if (!TextUtils.isEmpty(server)) {
              properties.setProperty("javax.sip.OUTBOUND_PROXY",
                      server + ':' + port + '/' + protocol);
          } else {
              server = mLocalProfile.getSipDomain();
          }
          if (server.startsWith("[") && server.endsWith("]")) {
              server = server.substring(1, server.length() - 1);
          }
  
          String local = null;
          try {
              for (InetAddress remote : InetAddress.getAllByName(server)) {
                  DatagramSocket socket = new DatagramSocket();
                  socket.connect(remote, port);
                  if (socket.isConnected()) {
                      local = socket.getLocalAddress().getHostAddress();
                      port = socket.getLocalPort();
                      socket.close();
                      break;
                  }
                  socket.close();
              }
          } catch (Exception e) {
              // ignore.
          }
          if (local == null) {
              // We are unable to reach the server. Just bail out.
              return;
          }
  
          close();
          mLocalIp = local;
  
          properties.setProperty("javax.sip.STACK_NAME", getStackName());
          properties.setProperty(
                  "gov.nist.javax.sip.THREAD_POOL_SIZE", THREAD_POOL_SIZE);
          mSipStack = SipFactory.getInstance().createSipStack(properties);
          try {
              SipProvider provider = mSipStack.createSipProvider(
                      mSipStack.createListeningPoint(local, port, protocol));
              provider.addSipListener(this);
              mSipHelper = new SipHelper(mSipStack, provider);
          } catch (SipException e) {
              throw e;
          } catch (Exception e) {
              throw new SipException("failed to initialize SIP stack", e);
          }
  
          if (DBG) log("reset: start stack for " + mLocalProfile.getUriString());
          mSipStack.start();
      }
  
      synchronized void onConnectivityChanged() {
          SipSessionImpl[] ss = mSessionMap.values().toArray(
                      new SipSessionImpl[mSessionMap.size()]);
          // Iterate on the copied array instead of directly on mSessionMap to
          // avoid ConcurrentModificationException being thrown when
          // SipSessionImpl removes itself from mSessionMap in onError() in the
          // following loop.
          for (SipSessionImpl s : ss) {
              s.onError(SipErrorCode.DATA_CONNECTION_LOST,
                      "data connection lost");
          }
      }
  
      synchronized void resetExternalAddress() {
          if (DBG) {
              log("resetExternalAddress: " + mSipStack);
          }
          mExternalIp = null;
          mExternalPort = 0;
      }
  
      public SipProfile getLocalProfile() {
          return mLocalProfile;
      }
  
      public String getLocalProfileUri() {
          return mLocalProfile.getUriString();
      }
  
      private String getStackName() {
          return "stack" + System.currentTimeMillis();
      }
  
      public synchronized void close() {
          if (DBG) log("close: " + SipService.obfuscateSipUri(mLocalProfile.getUriString()));
          onConnectivityChanged();
          mSessionMap.clear();
          closeToNotReceiveCalls();
          if (mSipStack != null) {
              mSipStack.stop();
              mSipStack = null;
              mSipHelper = null;
          }
          resetExternalAddress();
      }
  
      public synchronized boolean isClosed() {
          return (mSipStack == null);
      }
  
      // For internal use, require listener not to block in callbacks.
      public synchronized void openToReceiveCalls(ISipSessionListener listener) {
          if (mCallReceiverSession == null) {
              mCallReceiverSession = new SipSessionCallReceiverImpl(listener);
          } else {
              mCallReceiverSession.setListener(listener);
          }
      }
  
      public synchronized void closeToNotReceiveCalls() {
          mCallReceiverSession = null;
      }
  
      public ISipSession createSession(ISipSessionListener listener) {
          return (isClosed() ? null : new SipSessionImpl(listener));
      }
  
      synchronized boolean containsSession(String callId) {
          return mSessionMap.containsKey(callId);
      }
  
      private synchronized SipSessionImpl getSipSession(EventObject event) {
          String key = SipHelper.getCallId(event);
          SipSessionImpl session = mSessionMap.get(key);
          if ((session != null) && isLoggable(session)) {
              if (DBG) log("getSipSession: event=" + key);
              if (DBG) log("getSipSession: active sessions:");
              for (String k : mSessionMap.keySet()) {
                  if (DBG) log("getSipSession: ..." + k + ": " + mSessionMap.get(k));
              }
          }
          return ((session != null) ? session : mCallReceiverSession);
      }
  
      private synchronized void addSipSession(SipSessionImpl newSession) {
          removeSipSession(newSession);
          String key = newSession.getCallId();
          mSessionMap.put(key, newSession);
          if (isLoggable(newSession)) {
              if (DBG) log("addSipSession: key='" + key + "'");
              for (String k : mSessionMap.keySet()) {
                  if (DBG) log("addSipSession:  " + k + ": " + mSessionMap.get(k));
              }
          }
      }
  
      private synchronized void removeSipSession(SipSessionImpl session) {
          if (session == mCallReceiverSession) return;
          String key = session.getCallId();
          SipSessionImpl s = mSessionMap.remove(key);
          // sanity check
          if ((s != null) && (s != session)) {
              if (DBG) log("removeSession: " + session + " is not associated with key '"
                      + key + "'");
              mSessionMap.put(key, s);
              for (Map.Entry<String, SipSessionImpl> entry
                      : mSessionMap.entrySet()) {
                  if (entry.getValue() == s) {
                      key = entry.getKey();
                      mSessionMap.remove(key);
                  }
              }
          }
  
          if ((s != null) && isLoggable(s)) {
              if (DBG) log("removeSession: " + session + " @key '" + key + "'");
              for (String k : mSessionMap.keySet()) {
                  if (DBG) log("removeSession:  " + k + ": " + mSessionMap.get(k));
              }
          }
      }
  
      @Override
      public void processRequest(final RequestEvent event) {
          if (isRequestEvent(Request.INVITE, event)) {
              if (DBG) log("processRequest: mWakeLock.acquire got INVITE, thread:"
                      + Thread.currentThread());
              // Acquire a wake lock and keep it for WAKE_LOCK_HOLDING_TIME;
              // should be large enough to bring up the app.
              mWakeLock.acquire(WAKE_LOCK_HOLDING_TIME);
          }
          process(event);
      }
  
      @Override
      public void processResponse(ResponseEvent event) {
          process(event);
      }
  
      @Override
      public void processIOException(IOExceptionEvent event) {
          process(event);
      }
  
      @Override
      public void processTimeout(TimeoutEvent event) {
          process(event);
      }
  
      @Override
      public void processTransactionTerminated(TransactionTerminatedEvent event) {
          process(event);
      }
  
      @Override
      public void processDialogTerminated(DialogTerminatedEvent event) {
          process(event);
      }
  
      private synchronized void process(EventObject event) {
          SipSessionImpl session = getSipSession(event);
          try {
              boolean isLoggable = isLoggable(session, event);
              boolean processed = (session != null) && session.process(event);
              if (isLoggable && processed) {
                  log("process: event new state after: "
                          + SipSession.State.toString(session.mState));
              }
          } catch (Throwable e) {
              loge("process: error event=" + event, getRootCause(e));
              session.onError(e);
          }
      }
  
      private String extractContent(Message message) {
          // Currently we do not support secure MIME bodies.
          byte[] bytes = message.getRawContent();
          if (bytes != null) {
              try {
                  if (message instanceof SIPMessage) {
                      return ((SIPMessage) message).getMessageContent();
                  } else {
                      return new String(bytes, "UTF-8");
                  }
              } catch (UnsupportedEncodingException e) {
              }
          }
          return null;
      }
  
      private void extractExternalAddress(ResponseEvent evt) {
          Response response = evt.getResponse();
          ViaHeader viaHeader = (ViaHeader)(response.getHeader(
                  SIPHeaderNames.VIA));
          if (viaHeader == null) return;
          int rport = viaHeader.getRPort();
          String externalIp = viaHeader.getReceived();
          if ((rport > 0) && (externalIp != null)) {
              mExternalIp = externalIp;
              mExternalPort = rport;
              if (DBG) {
                  log("extractExternalAddress: external addr " + externalIp + ":" + rport
                          + " on " + mSipStack);
              }
          }
      }
  
      private Throwable getRootCause(Throwable exception) {
          Throwable cause = exception.getCause();
          while (cause != null) {
              exception = cause;
              cause = exception.getCause();
          }
          return exception;
      }
  
      private SipSessionImpl createNewSession(RequestEvent event,
              ISipSessionListener listener, ServerTransaction transaction,
              int newState) throws SipException {
          SipSessionImpl newSession = new SipSessionImpl(listener);
          newSession.mServerTransaction = transaction;
          newSession.mState = newState;
          newSession.mDialog = newSession.mServerTransaction.getDialog();
          newSession.mInviteReceived = event;
          newSession.mPeerProfile = createPeerProfile((HeaderAddress)
                  event.getRequest().getHeader(FromHeader.NAME));
          newSession.mPeerSessionDescription =
                  extractContent(event.getRequest());
          return newSession;
      }
  
      private class SipSessionCallReceiverImpl extends SipSessionImpl {
          private static final String SSCRI_TAG = "SipSessionCallReceiverImpl";
          private static final boolean SSCRI_DBG = true;
  
          public SipSessionCallReceiverImpl(ISipSessionListener listener) {
              super(listener);
          }
  
          private int processInviteWithReplaces(RequestEvent event,
                  ReplacesHeader replaces) {
              String callId = replaces.getCallId();
              SipSessionImpl session = mSessionMap.get(callId);
              if (session == null) {
                  return Response.CALL_OR_TRANSACTION_DOES_NOT_EXIST;
              }
  
              Dialog dialog = session.mDialog;
              if (dialog == null) return Response.DECLINE;
  
              if (!dialog.getLocalTag().equals(replaces.getToTag()) ||
                      !dialog.getRemoteTag().equals(replaces.getFromTag())) {
                  // No match is found, returns 481.
                  return Response.CALL_OR_TRANSACTION_DOES_NOT_EXIST;
              }
  
              ReferredByHeader referredBy = (ReferredByHeader) event.getRequest()
                      .getHeader(ReferredByHeader.NAME);
              if ((referredBy == null) ||
                      !dialog.getRemoteParty().equals(referredBy.getAddress())) {
                  return Response.CALL_OR_TRANSACTION_DOES_NOT_EXIST;
              }
              return Response.OK;
          }
  
          private void processNewInviteRequest(RequestEvent event)
                  throws SipException {
              ReplacesHeader replaces = (ReplacesHeader) event.getRequest()
                      .getHeader(ReplacesHeader.NAME);
              SipSessionImpl newSession = null;
              if (replaces != null) {
                  int response = processInviteWithReplaces(event, replaces);
                  if (SSCRI_DBG) {
                      log("processNewInviteRequest: " + replaces
                              + " response=" + response);
                  }
                  if (response == Response.OK) {
                      SipSessionImpl replacedSession =
                              mSessionMap.get(replaces.getCallId());
                      // got INVITE w/ replaces request.
                      newSession = createNewSession(event,
                              replacedSession.mProxy.getListener(),
                              mSipHelper.getServerTransaction(event),
                              SipSession.State.INCOMING_CALL);
                      newSession.mProxy.onCallTransferring(newSession,
                              newSession.mPeerSessionDescription);
                  } else {
                      mSipHelper.sendResponse(event, response);
                  }
              } else {
                  // New Incoming call.
                  newSession = createNewSession(event, mProxy,
                          mSipHelper.sendRinging(event, generateTag()),
                          SipSession.State.INCOMING_CALL);
                  mProxy.onRinging(newSession, newSession.mPeerProfile,
                          newSession.mPeerSessionDescription);
              }
              if (newSession != null) addSipSession(newSession);
          }
  
          @Override
          public boolean process(EventObject evt) throws SipException {
              if (isLoggable(this, evt)) log("process: " + this + ": "
                      + SipSession.State.toString(mState) + ": processing "
                      + logEvt(evt));
              if (isRequestEvent(Request.INVITE, evt)) {
                  processNewInviteRequest((RequestEvent) evt);
                  return true;
              } else if (isRequestEvent(Request.OPTIONS, evt)) {
                  mSipHelper.sendResponse((RequestEvent) evt, Response.OK);
                  return true;
              } else {
                  return false;
              }
          }
  
          private void log(String s) {
              Rlog.d(SSCRI_TAG, s);
          }
      }
  
      static interface KeepAliveProcessCallback {
          /** Invoked when the response of keeping alive comes back. */
          void onResponse(boolean portChanged);
          void onError(int errorCode, String description);
      }
  
      class SipSessionImpl extends ISipSession.Stub {
          private static final String SSI_TAG = "SipSessionImpl";
          private static final boolean SSI_DBG = true;
  
          SipProfile mPeerProfile;
          SipSessionListenerProxy mProxy = new SipSessionListenerProxy();
          int mState = SipSession.State.READY_TO_CALL;
          RequestEvent mInviteReceived;
          Dialog mDialog;
          ServerTransaction mServerTransaction;
          ClientTransaction mClientTransaction;
          String mPeerSessionDescription;
          boolean mInCall;
          SessionTimer mSessionTimer;
          int mAuthenticationRetryCount;
  
          private SipKeepAlive mSipKeepAlive;
  
          private SipSessionImpl mSipSessionImpl;
  
          // the following three members are used for handling refer request.
          SipSessionImpl mReferSession;
          ReferredByHeader mReferredBy;
          String mReplaces;
  
          // lightweight timer
          class SessionTimer {
              private boolean mRunning = true;
  
              void start(final int timeout) {
                  new Thread(new Runnable() {
                      @Override
                      public void run() {
                          sleep(timeout);
                          if (mRunning) timeout();
                      }
                  }, "SipSessionTimerThread").start();
              }
  
              synchronized void cancel() {
                  mRunning = false;
                  this.notify();
              }
  
              private void timeout() {
                  synchronized (SipSessionGroup.this) {
                      onError(SipErrorCode.TIME_OUT, "Session timed out!");
                  }
              }
  
              private synchronized void sleep(int timeout) {
                  try {
                      this.wait(timeout * 1000);
                  } catch (InterruptedException e) {
                      loge("session timer interrupted!", e);
                  }
              }
          }
  
          public SipSessionImpl(ISipSessionListener listener) {
              setListener(listener);
          }
  
          SipSessionImpl duplicate() {
              return new SipSessionImpl(mProxy.getListener());
          }
  
          private void reset() {
              mInCall = false;
              removeSipSession(this);
              mPeerProfile = null;
              mState = SipSession.State.READY_TO_CALL;
              mInviteReceived = null;
              mPeerSessionDescription = null;
              mAuthenticationRetryCount = 0;
              mReferSession = null;
              mReferredBy = null;
              mReplaces = null;
  
              if (mDialog != null) mDialog.delete();
              mDialog = null;
  
              try {
                  if (mServerTransaction != null) mServerTransaction.terminate();
              } catch (ObjectInUseException e) {
                  // ignored
              }
              mServerTransaction = null;
  
              try {
                  if (mClientTransaction != null) mClientTransaction.terminate();
              } catch (ObjectInUseException e) {
                  // ignored
              }
              mClientTransaction = null;
  
              cancelSessionTimer();
  
              if (mSipSessionImpl != null) {
                  mSipSessionImpl.stopKeepAliveProcess();
                  mSipSessionImpl = null;
              }
          }
  
          @Override
          public boolean isInCall() {
              return mInCall;
          }
  
          @Override
          public String getLocalIp() {
              return mLocalIp;
          }
  
          @Override
          public SipProfile getLocalProfile() {
              return mLocalProfile;
          }
  
          @Override
          public SipProfile getPeerProfile() {
              return mPeerProfile;
          }
  
          @Override
          public String getCallId() {
              return SipHelper.getCallId(getTransaction());
          }
  
          private Transaction getTransaction() {
              if (mClientTransaction != null) return mClientTransaction;
              if (mServerTransaction != null) return mServerTransaction;
              return null;
          }
  
          @Override
          public int getState() {
              return mState;
          }
  
          @Override
          public void setListener(ISipSessionListener listener) {
              mProxy.setListener((listener instanceof SipSessionListenerProxy)
                      ? ((SipSessionListenerProxy) listener).getListener()
                      : listener);
          }
  
          // process the command in a new thread
          private void doCommandAsync(final EventObject command) {
              new Thread(new Runnable() {
                      @Override
                      public void run() {
                          try {
                              processCommand(command);
                          } catch (Throwable e) {
                              loge("command error: " + command + ": "
                                      + mLocalProfile.getUriString(),
                                      getRootCause(e));
                              onError(e);
                          }
                      }
              }, "SipSessionAsyncCmdThread").start();
          }
  
          @Override
          public void makeCall(SipProfile peerProfile, String sessionDescription,
                  int timeout) {
              doCommandAsync(new MakeCallCommand(peerProfile, sessionDescription,
                      timeout));
          }
  
          @Override
          public void answerCall(String sessionDescription, int timeout) {
              synchronized (SipSessionGroup.this) {
                  if (mPeerProfile == null) return;
                  doCommandAsync(new MakeCallCommand(mPeerProfile,
                          sessionDescription, timeout));
              }
          }
  
          @Override
          public void endCall() {
              doCommandAsync(END_CALL);
          }
  
          @Override
          public void changeCall(String sessionDescription, int timeout) {
              synchronized (SipSessionGroup.this) {
                  if (mPeerProfile == null) return;
                  doCommandAsync(new MakeCallCommand(mPeerProfile,
                          sessionDescription, timeout));
              }
          }
  
          @Override
          public void register(int duration) {
              doCommandAsync(new RegisterCommand(duration));
          }
  
          @Override
          public void unregister() {
              doCommandAsync(DEREGISTER);
          }
  
          private void processCommand(EventObject command) throws SipException {
              if (isLoggable(command)) log("process cmd: " + command);
              if (!process(command)) {
                  onError(SipErrorCode.IN_PROGRESS,
                          "cannot initiate a new transaction to execute: "
                          + command);
              }
          }
  
          protected String generateTag() {
              // 32-bit randomness
              return String.valueOf((long) (Math.random() * 0x100000000L));
          }
  
          @Override
          public String toString() {
              try {
                  String s = super.toString();
                  return s.substring(s.indexOf("@")) + ":"
                          + SipSession.State.toString(mState);
              } catch (Throwable e) {
                  return super.toString();
              }
          }
  
          public boolean process(EventObject evt) throws SipException {
              if (isLoggable(this, evt)) log(" ~~~~~   " + this + ": "
                      + SipSession.State.toString(mState) + ": processing "
                      + logEvt(evt));
              synchronized (SipSessionGroup.this) {
                  if (isClosed()) return false;
  
                  if (mSipKeepAlive != null) {
                      // event consumed by keepalive process
                      if (mSipKeepAlive.process(evt)) return true;
                  }
  
                  Dialog dialog = null;
                  if (evt instanceof RequestEvent) {
                      dialog = ((RequestEvent) evt).getDialog();
                  } else if (evt instanceof ResponseEvent) {
                      dialog = ((ResponseEvent) evt).getDialog();
                      extractExternalAddress((ResponseEvent) evt);
                  }
                  if (dialog != null) mDialog = dialog;
  
                  boolean processed;
  
                  switch (mState) {
                  case SipSession.State.REGISTERING:
                  case SipSession.State.DEREGISTERING:
                      processed = registeringToReady(evt);
                      break;
                  case SipSession.State.READY_TO_CALL:
                      processed = readyForCall(evt);
                      break;
                  case SipSession.State.INCOMING_CALL:
                      processed = incomingCall(evt);
                      break;
                  case SipSession.State.INCOMING_CALL_ANSWERING:
                      processed = incomingCallToInCall(evt);
                      break;
                  case SipSession.State.OUTGOING_CALL:
                  case SipSession.State.OUTGOING_CALL_RING_BACK:
                      processed = outgoingCall(evt);
                      break;
                  case SipSession.State.OUTGOING_CALL_CANCELING:
                      processed = outgoingCallToReady(evt);
                      break;
                  case SipSession.State.IN_CALL:
                      processed = inCall(evt);
                      break;
                  case SipSession.State.ENDING_CALL:
                      processed = endingCall(evt);
                      break;
                  default:
                      processed = false;
                  }
                  return (processed || processExceptions(evt));
              }
          }
  
          private boolean processExceptions(EventObject evt) throws SipException {
              if (isRequestEvent(Request.BYE, evt)) {
                  // terminate the call whenever a BYE is received
                  mSipHelper.sendResponse((RequestEvent) evt, Response.OK);
                  endCallNormally();
                  return true;
              } else if (isRequestEvent(Request.CANCEL, evt)) {
                  mSipHelper.sendResponse((RequestEvent) evt,
                          Response.CALL_OR_TRANSACTION_DOES_NOT_EXIST);
                  return true;
              } else if (evt instanceof TransactionTerminatedEvent) {
                  if (isCurrentTransaction((TransactionTerminatedEvent) evt)) {
                      if (evt instanceof TimeoutEvent) {
                          processTimeout((TimeoutEvent) evt);
                      } else {
                          processTransactionTerminated(
                                  (TransactionTerminatedEvent) evt);
                      }
                      return true;
                  }
              } else if (isRequestEvent(Request.OPTIONS, evt)) {
                  mSipHelper.sendResponse((RequestEvent) evt, Response.OK);
                  return true;
              } else if (evt instanceof DialogTerminatedEvent) {
                  processDialogTerminated((DialogTerminatedEvent) evt);
                  return true;
              }
              return false;
          }
  
          private void processDialogTerminated(DialogTerminatedEvent event) {
              if (mDialog == event.getDialog()) {
                  onError(new SipException("dialog terminated"));
              } else {
                  if (SSI_DBG) log("not the current dialog; current=" + mDialog
                          + ", terminated=" + event.getDialog());
              }
          }
  
          private boolean isCurrentTransaction(TransactionTerminatedEvent event) {
              Transaction current = event.isServerTransaction()
                      ? mServerTransaction
                      : mClientTransaction;
              Transaction target = event.isServerTransaction()
                      ? event.getServerTransaction()
                      : event.getClientTransaction();
  
              if ((current != target) && (mState != SipSession.State.PINGING)) {
                  if (SSI_DBG) log("not the current transaction; current="
                          + toString(current) + ", target=" + toString(target));
                  return false;
              } else if (current != null) {
                  if (SSI_DBG) log("transaction terminated: " + toString(current));
                  return true;
              } else {
                  // no transaction; shouldn't be here; ignored
                  return true;
              }
          }
  
          private String toString(Transaction transaction) {
              if (transaction == null) return "null";
              Request request = transaction.getRequest();
              Dialog dialog = transaction.getDialog();
              CSeqHeader cseq = (CSeqHeader) request.getHeader(CSeqHeader.NAME);
              return String.format("req=%s,%s,s=%s,ds=%s,", request.getMethod(),
                      cseq.getSeqNumber(), transaction.getState(),
                      ((dialog == null) ? "-" : dialog.getState()));
          }
  
          private void processTransactionTerminated(
                  TransactionTerminatedEvent event) {
              switch (mState) {
                  case SipSession.State.IN_CALL:
                  case SipSession.State.READY_TO_CALL:
                      if (SSI_DBG) log("Transaction terminated; do nothing");
                      break;
                  default:
                      if (SSI_DBG) log("Transaction terminated early: " + this);
                      onError(SipErrorCode.TRANSACTION_TERMINTED,
                              "transaction terminated");
              }
          }
  
          private void processTimeout(TimeoutEvent event) {
              if (SSI_DBG) log("processing Timeout...");
              switch (mState) {
                  case SipSession.State.REGISTERING:
                  case SipSession.State.DEREGISTERING:
                      reset();
                      mProxy.onRegistrationTimeout(this);
                      break;
                  case SipSession.State.INCOMING_CALL:
                  case SipSession.State.INCOMING_CALL_ANSWERING:
                  case SipSession.State.OUTGOING_CALL:
                  case SipSession.State.OUTGOING_CALL_CANCELING:
                      onError(SipErrorCode.TIME_OUT, event.toString());
                      break;
  
                  default:
                      if (SSI_DBG) log("   do nothing");
                      break;
              }
          }
  
          private int getExpiryTime(Response response) {
              int time = -1;
              ContactHeader contact = (ContactHeader) response.getHeader(ContactHeader.NAME);
              if (contact != null) {
                  time = contact.getExpires();
              }
              ExpiresHeader expires = (ExpiresHeader) response.getHeader(ExpiresHeader.NAME);
              if (expires != null && (time < 0 || time > expires.getExpires())) {
                  time = expires.getExpires();
              }
              if (time <= 0) {
                  time = EXPIRY_TIME;
              }
              expires = (ExpiresHeader) response.getHeader(MinExpiresHeader.NAME);
              if (expires != null && time < expires.getExpires()) {
                  time = expires.getExpires();
              }
              if (SSI_DBG) {
                  log("Expiry time = " + time);
              }
              return time;
          }
  
          private boolean registeringToReady(EventObject evt)
                  throws SipException {
              if (expectResponse(Request.REGISTER, evt)) {
                  ResponseEvent event = (ResponseEvent) evt;
                  Response response = event.getResponse();
  
                  int statusCode = response.getStatusCode();
                  switch (statusCode) {
                  case Response.OK:
                      int state = mState;
                      onRegistrationDone((state == SipSession.State.REGISTERING)
                              ? getExpiryTime(((ResponseEvent) evt).getResponse())
                              : -1);
                      return true;
                  case Response.UNAUTHORIZED:
                  case Response.PROXY_AUTHENTICATION_REQUIRED:
                      handleAuthentication(event);
                      return true;
                  default:
                      if (statusCode >= 500) {
                          onRegistrationFailed(response);
                          return true;
                      }
                  }
              }
              return false;
          }
  
          private boolean handleAuthentication(ResponseEvent event)
                  throws SipException {
              Response response = event.getResponse();
              String nonce = getNonceFromResponse(response);
              if (nonce == null) {
                  onError(SipErrorCode.SERVER_ERROR,
                          "server does not provide challenge");
                  return false;
              } else if (mAuthenticationRetryCount < 2) {
                  mClientTransaction = mSipHelper.handleChallenge(
                          event, getAccountManager());
                  mDialog = mClientTransaction.getDialog();
                  mAuthenticationRetryCount++;
                  if (isLoggable(this, event)) {
                      if (SSI_DBG) log("   authentication retry count="
                              + mAuthenticationRetryCount);
                  }
                  return true;
              } else {
                  if (crossDomainAuthenticationRequired(response)) {
                      onError(SipErrorCode.CROSS_DOMAIN_AUTHENTICATION,
                              getRealmFromResponse(response));
                  } else {
                      onError(SipErrorCode.INVALID_CREDENTIALS,
                              "incorrect username or password");
                  }
                  return false;
              }
          }
  
          private boolean crossDomainAuthenticationRequired(Response response) {
              String realm = getRealmFromResponse(response);
              if (realm == null) realm = "";
              return !mLocalProfile.getSipDomain().trim().equals(realm.trim());
          }
  
          private AccountManager getAccountManager() {
              return new AccountManager() {
                  @Override
                  public UserCredentials getCredentials(ClientTransaction
                          challengedTransaction, String realm) {
                      return new UserCredentials() {
                          @Override
                          public String getUserName() {
                              String username = mLocalProfile.getAuthUserName();
                              return (!TextUtils.isEmpty(username) ? username :
                                      mLocalProfile.getUserName());
                          }
  
                          @Override
                          public String getPassword() {
                              return mPassword;
                          }
  
                          @Override
                          public String getSipDomain() {
                              return mLocalProfile.getSipDomain();
                          }
                      };
                  }
              };
          }
  
          private String getRealmFromResponse(Response response) {
              WWWAuthenticate wwwAuth = (WWWAuthenticate)response.getHeader(
                      SIPHeaderNames.WWW_AUTHENTICATE);
              if (wwwAuth != null) return wwwAuth.getRealm();
              ProxyAuthenticate proxyAuth = (ProxyAuthenticate)response.getHeader(
                      SIPHeaderNames.PROXY_AUTHENTICATE);
              return (proxyAuth == null) ? null : proxyAuth.getRealm();
          }
  
          private String getNonceFromResponse(Response response) {
              WWWAuthenticate wwwAuth = (WWWAuthenticate)response.getHeader(
                      SIPHeaderNames.WWW_AUTHENTICATE);
              if (wwwAuth != null) return wwwAuth.getNonce();
              ProxyAuthenticate proxyAuth = (ProxyAuthenticate)response.getHeader(
                      SIPHeaderNames.PROXY_AUTHENTICATE);
              return (proxyAuth == null) ? null : proxyAuth.getNonce();
          }
  
          private String getResponseString(int statusCode) {
              StatusLine statusLine = new StatusLine();
              statusLine.setStatusCode(statusCode);
              statusLine.setReasonPhrase(SIPResponse.getReasonPhrase(statusCode));
              return statusLine.encode();
          }
  
          private boolean readyForCall(EventObject evt) throws SipException {
              // expect MakeCallCommand, RegisterCommand, DEREGISTER
              if (evt instanceof MakeCallCommand) {
                  mState = SipSession.State.OUTGOING_CALL;
                  MakeCallCommand cmd = (MakeCallCommand) evt;
                  mPeerProfile = cmd.getPeerProfile();
                  if (mReferSession != null) {
                      mSipHelper.sendReferNotify(mReferSession.mDialog,
                              getResponseString(Response.TRYING));
                  }
                  mClientTransaction = mSipHelper.sendInvite(
                          mLocalProfile, mPeerProfile, cmd.getSessionDescription(),
                          generateTag(), mReferredBy, mReplaces);
                  mDialog = mClientTransaction.getDialog();
                  addSipSession(this);
                  startSessionTimer(cmd.getTimeout());
                  mProxy.onCalling(this);
                  return true;
              } else if (evt instanceof RegisterCommand) {
                  mState = SipSession.State.REGISTERING;
                  int duration = ((RegisterCommand) evt).getDuration();
                  mClientTransaction = mSipHelper.sendRegister(mLocalProfile,
                          generateTag(), duration);
                  mDialog = mClientTransaction.getDialog();
                  addSipSession(this);
                  mProxy.onRegistering(this);
                  return true;
              } else if (DEREGISTER == evt) {
                  mState = SipSession.State.DEREGISTERING;
                  mClientTransaction = mSipHelper.sendRegister(mLocalProfile,
                          generateTag(), 0);
                  mDialog = mClientTransaction.getDialog();
                  addSipSession(this);
                  mProxy.onRegistering(this);
                  return true;
              }
              return false;
          }
  
          private boolean incomingCall(EventObject evt) throws SipException {
              // expect MakeCallCommand(answering) , END_CALL cmd , Cancel
              if (evt instanceof MakeCallCommand) {
                  // answer call
                  mState = SipSession.State.INCOMING_CALL_ANSWERING;
                  mServerTransaction = mSipHelper.sendInviteOk(mInviteReceived,
                          mLocalProfile,
                          ((MakeCallCommand) evt).getSessionDescription(),
                          mServerTransaction,
                          mExternalIp, mExternalPort);
                  startSessionTimer(((MakeCallCommand) evt).getTimeout());
                  return true;
              } else if (END_CALL == evt) {
                  mSipHelper.sendInviteBusyHere(mInviteReceived,
                          mServerTransaction);
                  endCallNormally();
                  return true;
              } else if (isRequestEvent(Request.CANCEL, evt)) {
                  RequestEvent event = (RequestEvent) evt;
                  mSipHelper.sendResponse(event, Response.OK);
                  mSipHelper.sendInviteRequestTerminated(
                          mInviteReceived.getRequest(), mServerTransaction);
                  endCallNormally();
                  return true;
              }
              return false;
          }
  
          private boolean incomingCallToInCall(EventObject evt) {
              // expect ACK, CANCEL request
              if (isRequestEvent(Request.ACK, evt)) {
                  String sdp = extractContent(((RequestEvent) evt).getRequest());
                  if (sdp != null) mPeerSessionDescription = sdp;
                  if (mPeerSessionDescription == null) {
                      onError(SipErrorCode.CLIENT_ERROR, "peer sdp is empty");
                  } else {
                      establishCall(false);
                  }
                  return true;
              } else if (isRequestEvent(Request.CANCEL, evt)) {
                  // http://tools.ietf.org/html/rfc3261#section-9.2
                  // Final response has been sent; do nothing here.
                  return true;
              }
              return false;
          }
  
          private boolean outgoingCall(EventObject evt) throws SipException {
              if (expectResponse(Request.INVITE, evt)) {
                  ResponseEvent event = (ResponseEvent) evt;
                  Response response = event.getResponse();
  
                  int statusCode = response.getStatusCode();
                  switch (statusCode) {
                  case Response.RINGING:
                  case Response.CALL_IS_BEING_FORWARDED:
                  case Response.QUEUED:
                  case Response.SESSION_PROGRESS:
                      // feedback any provisional responses (except TRYING) as
                      // ring back for better UX
                      if (mState == SipSession.State.OUTGOING_CALL) {
                          mState = SipSession.State.OUTGOING_CALL_RING_BACK;
                          cancelSessionTimer();
                          mProxy.onRingingBack(this);
                      }
                      return true;
                  case Response.OK:
                      if (mReferSession != null) {
                          mSipHelper.sendReferNotify(mReferSession.mDialog,
                                  getResponseString(Response.OK));
                          // since we don't need to remember the session anymore.
                          mReferSession = null;
                      }
                      mSipHelper.sendInviteAck(event, mDialog);
                      mPeerSessionDescription = extractContent(response);
                      establishCall(true);
                      return true;
                  case Response.UNAUTHORIZED:
                  case Response.PROXY_AUTHENTICATION_REQUIRED:
                      if (handleAuthentication(event)) {
                          addSipSession(this);
                      }
                      return true;
                  case Response.REQUEST_PENDING:
                      // TODO: rfc3261#section-14.1; re-schedule invite
                      return true;
                  default:
                      if (mReferSession != null) {
                          mSipHelper.sendReferNotify(mReferSession.mDialog,
                                  getResponseString(Response.SERVICE_UNAVAILABLE));
                      }
                      if (statusCode >= 400) {
                          // error: an ack is sent automatically by the stack
                          onError(response);
                          return true;
                      } else if (statusCode >= 300) {
                          // TODO: handle 3xx (redirect)
                      } else {
                          return true;
                      }
                  }
                  return false;
              } else if (END_CALL == evt) {
                  // RFC says that UA should not send out cancel when no
                  // response comes back yet. We are cheating for not checking
                  // response.
                  mState = SipSession.State.OUTGOING_CALL_CANCELING;
                  mSipHelper.sendCancel(mClientTransaction);
                  startSessionTimer(CANCEL_CALL_TIMER);
                  return true;
              } else if (isRequestEvent(Request.INVITE, evt)) {
                  // Call self? Send BUSY HERE so server may redirect the call to
                  // voice mailbox.
                  RequestEvent event = (RequestEvent) evt;
                  mSipHelper.sendInviteBusyHere(event,
                          event.getServerTransaction());
                  return true;
              }
              return false;
          }
  
          private boolean outgoingCallToReady(EventObject evt)
                  throws SipException {
              if (evt instanceof ResponseEvent) {
                  ResponseEvent event = (ResponseEvent) evt;
                  Response response = event.getResponse();
                  int statusCode = response.getStatusCode();
                  if (expectResponse(Request.CANCEL, evt)) {
                      if (statusCode == Response.OK) {
                          // do nothing; wait for REQUEST_TERMINATED
                          return true;
                      }
                  } else if (expectResponse(Request.INVITE, evt)) {
                      switch (statusCode) {
                          case Response.OK:
                              outgoingCall(evt); // abort Cancel
                              return true;
                          case Response.REQUEST_TERMINATED:
                              endCallNormally();
                              return true;
                      }
                  } else {
                      return false;
                  }
  
                  if (statusCode >= 400) {
                      onError(response);
                      return true;
                  }
              } else if (evt instanceof TransactionTerminatedEvent) {
                  // rfc3261#section-14.1:
                  // if re-invite gets timed out, terminate the dialog; but
                  // re-invite is not reliable, just let it go and pretend
                  // nothing happened.
                  onError(new SipException("timed out"));
              }
              return false;
          }
  
          private boolean processReferRequest(RequestEvent event)
                  throws SipException {
              try {
                  ReferToHeader referto = (ReferToHeader) event.getRequest()
                          .getHeader(ReferTo.NAME);
                  Address address = referto.getAddress();
                  SipURI uri = (SipURI) address.getURI();
                  String replacesHeader = uri.getHeader(ReplacesHeader.NAME);
                  String username = uri.getUser();
                  if (username == null) {
                      mSipHelper.sendResponse(event, Response.BAD_REQUEST);
                      return false;
                  }
                  // send notify accepted
                  mSipHelper.sendResponse(event, Response.ACCEPTED);
                  SipSessionImpl newSession = createNewSession(event,
                          this.mProxy.getListener(),
                          mSipHelper.getServerTransaction(event),
                          SipSession.State.READY_TO_CALL);
                  newSession.mReferSession = this;
                  newSession.mReferredBy = (ReferredByHeader) event.getRequest()
                          .getHeader(ReferredByHeader.NAME);
                  newSession.mReplaces = replacesHeader;
                  newSession.mPeerProfile = createPeerProfile(referto);
                  newSession.mProxy.onCallTransferring(newSession,
                          null);
                  return true;
              } catch (IllegalArgumentException e) {
                  throw new SipException("createPeerProfile()", e);
              }
          }
  
          private boolean inCall(EventObject evt) throws SipException {
              // expect END_CALL cmd, BYE request, hold call (MakeCallCommand)
              // OK retransmission is handled in SipStack
              if (END_CALL == evt) {
                  // rfc3261#section-15.1.1
                  mState = SipSession.State.ENDING_CALL;
                  mSipHelper.sendBye(mDialog);
                  mProxy.onCallEnded(this);
                  startSessionTimer(END_CALL_TIMER);
                  return true;
              } else if (isRequestEvent(Request.INVITE, evt)) {
                  // got Re-INVITE
                  mState = SipSession.State.INCOMING_CALL;
                  RequestEvent event = mInviteReceived = (RequestEvent) evt;
                  mPeerSessionDescription = extractContent(event.getRequest());
                  mServerTransaction = null;
                  mProxy.onRinging(this, mPeerProfile, mPeerSessionDescription);
                  return true;
              } else if (isRequestEvent(Request.BYE, evt)) {
                  mSipHelper.sendResponse((RequestEvent) evt, Response.OK);
                  endCallNormally();
                  return true;
              } else if (isRequestEvent(Request.REFER, evt)) {
                  return processReferRequest((RequestEvent) evt);
              } else if (evt instanceof MakeCallCommand) {
                  // to change call
                  mState = SipSession.State.OUTGOING_CALL;
                  mClientTransaction = mSipHelper.sendReinvite(mDialog,
                          ((MakeCallCommand) evt).getSessionDescription());
                  startSessionTimer(((MakeCallCommand) evt).getTimeout());
                  return true;
              } else if (evt instanceof ResponseEvent) {
                  if (expectResponse(Request.NOTIFY, evt)) return true;
              }
              return false;
          }
  
          private boolean endingCall(EventObject evt) throws SipException {
              if (expectResponse(Request.BYE, evt)) {
                  ResponseEvent event = (ResponseEvent) evt;
                  Response response = event.getResponse();
  
                  int statusCode = response.getStatusCode();
                  switch (statusCode) {
                      case Response.UNAUTHORIZED:
                      case Response.PROXY_AUTHENTICATION_REQUIRED:
                          if (handleAuthentication(event)) {
                              return true;
                          } else {
                              // can't authenticate; pass through to end session
                          }
                  }
                  cancelSessionTimer();
                  reset();
                  return true;
              }
              return false;
          }
  
          // timeout in seconds
          private void startSessionTimer(int timeout) {
              if (timeout > 0) {
                  mSessionTimer = new SessionTimer();
                  mSessionTimer.start(timeout);
              }
          }
  
          private void cancelSessionTimer() {
              if (mSessionTimer != null) {
                  mSessionTimer.cancel();
                  mSessionTimer = null;
              }
          }
  
          private String createErrorMessage(Response response) {
              return String.format("%s (%d)", response.getReasonPhrase(),
                      response.getStatusCode());
          }
  
          private void enableKeepAlive() {
              if (mSipSessionImpl != null) {
                  mSipSessionImpl.stopKeepAliveProcess();
              } else {
                  mSipSessionImpl = duplicate();
              }
              try {
                  mSipSessionImpl.startKeepAliveProcess(
                          INCALL_KEEPALIVE_INTERVAL, mPeerProfile, null);
              } catch (SipException e) {
                  loge("keepalive cannot be enabled; ignored", e);
                  mSipSessionImpl.stopKeepAliveProcess();
              }
          }
  
          private void establishCall(boolean enableKeepAlive) {
              mState = SipSession.State.IN_CALL;
              cancelSessionTimer();
              if (!mInCall && enableKeepAlive) enableKeepAlive();
              mInCall = true;
              mProxy.onCallEstablished(this, mPeerSessionDescription);
          }
  
          private void endCallNormally() {
              reset();
              mProxy.onCallEnded(this);
          }
  
          private void endCallOnError(int errorCode, String message) {
              reset();
              mProxy.onError(this, errorCode, message);
          }
  
          private void endCallOnBusy() {
              reset();
              mProxy.onCallBusy(this);
          }
  
          private void onError(int errorCode, String message) {
              cancelSessionTimer();
              switch (mState) {
                  case SipSession.State.REGISTERING:
                  case SipSession.State.DEREGISTERING:
                      onRegistrationFailed(errorCode, message);
                      break;
                  default:
                      endCallOnError(errorCode, message);
              }
          }
  
  
          private void onError(Throwable exception) {
              exception = getRootCause(exception);
              onError(getErrorCode(exception), exception.toString());
          }
  
          private void onError(Response response) {
              int statusCode = response.getStatusCode();
              if (!mInCall && (statusCode == Response.BUSY_HERE)) {
                  endCallOnBusy();
              } else {
                  onError(getErrorCode(statusCode), createErrorMessage(response));
              }
          }
  
          private int getErrorCode(int responseStatusCode) {
              switch (responseStatusCode) {
                  case Response.TEMPORARILY_UNAVAILABLE:
                  case Response.FORBIDDEN:
                  case Response.GONE:
                  case Response.NOT_FOUND:
                  case Response.NOT_ACCEPTABLE:
                  case Response.NOT_ACCEPTABLE_HERE:
                      return SipErrorCode.PEER_NOT_REACHABLE;
  
                  case Response.REQUEST_URI_TOO_LONG:
                  case Response.ADDRESS_INCOMPLETE:
                  case Response.AMBIGUOUS:
                      return SipErrorCode.INVALID_REMOTE_URI;
  
                  case Response.REQUEST_TIMEOUT:
                      return SipErrorCode.TIME_OUT;
  
                  default:
                      if (responseStatusCode < 500) {
                          return SipErrorCode.CLIENT_ERROR;
                      } else {
                          return SipErrorCode.SERVER_ERROR;
                      }
              }
          }
  
          private int getErrorCode(Throwable exception) {
              String message = exception.getMessage();
              if (exception instanceof UnknownHostException) {
                  return SipErrorCode.SERVER_UNREACHABLE;
              } else if (exception instanceof IOException) {
                  return SipErrorCode.SOCKET_ERROR;
              } else {
                  return SipErrorCode.CLIENT_ERROR;
              }
          }
  
          private void onRegistrationDone(int duration) {
              reset();
              mProxy.onRegistrationDone(this, duration);
          }
  
          private void onRegistrationFailed(int errorCode, String message) {
              reset();
              mProxy.onRegistrationFailed(this, errorCode, message);
          }
  
          private void onRegistrationFailed(Response response) {
              int statusCode = response.getStatusCode();
              onRegistrationFailed(getErrorCode(statusCode),
                      createErrorMessage(response));
          }
  
          // Notes: SipSessionListener will be replaced by the keepalive process
          // @param interval in seconds
          public void startKeepAliveProcess(int interval,
                  KeepAliveProcessCallback callback) throws SipException {
              synchronized (SipSessionGroup.this) {
                  startKeepAliveProcess(interval, mLocalProfile, callback);
              }
          }
  
          // Notes: SipSessionListener will be replaced by the keepalive process
          // @param interval in seconds
          public void startKeepAliveProcess(int interval, SipProfile peerProfile,
                  KeepAliveProcessCallback callback) throws SipException {
              synchronized (SipSessionGroup.this) {
                  if (mSipKeepAlive != null) {
                      throw new SipException("Cannot create more than one "
                              + "keepalive process in a SipSession");
                  }
                  mPeerProfile = peerProfile;
                  mSipKeepAlive = new SipKeepAlive();
                  mProxy.setListener(mSipKeepAlive);
                  mSipKeepAlive.start(interval, callback);
              }
          }
  
          public void stopKeepAliveProcess() {
              synchronized (SipSessionGroup.this) {
                  if (mSipKeepAlive != null) {
                      mSipKeepAlive.stop();
                      mSipKeepAlive = null;
                  }
              }
          }
  
          class SipKeepAlive extends SipSessionAdapter implements Runnable {
              private static final String SKA_TAG = "SipKeepAlive";
              private static final boolean SKA_DBG = true;
  
              private boolean mRunning = false;
              private KeepAliveProcessCallback mCallback;
  
              private boolean mPortChanged = false;
              private int mRPort = 0;
              private int mInterval; // just for debugging
  
              // @param interval in seconds
              void start(int interval, KeepAliveProcessCallback callback) {
                  if (mRunning) return;
                  mRunning = true;
                  mInterval = interval;
                  mCallback = new KeepAliveProcessCallbackProxy(callback);
                  mWakeupTimer.set(interval * 1000, this);
                  if (SKA_DBG) {
                      log("start keepalive:"
                              + mLocalProfile.getUriString());
                  }
  
                  // No need to run the first time in a separate thread for now
                  run();
              }
  
              // return true if the event is consumed
              boolean process(EventObject evt) {
                  if (mRunning && (mState == SipSession.State.PINGING)) {
                      if (evt instanceof ResponseEvent) {
                          if (parseOptionsResult(evt)) {
                              if (mPortChanged) {
                                  resetExternalAddress();
                                  stop();
                              } else {
                                  cancelSessionTimer();
                                  removeSipSession(SipSessionImpl.this);
                              }
                              mCallback.onResponse(mPortChanged);
                              return true;
                          }
                      }
                  }
                  return false;
              }
  
              // SipSessionAdapter
              // To react to the session timeout event and network error.
              @Override
              public void onError(ISipSession session, int errorCode, String message) {
                  stop();
                  mCallback.onError(errorCode, message);
              }
  
              // SipWakeupTimer timeout handler
              // To send out keepalive message.
              @Override
              public void run() {
                  synchronized (SipSessionGroup.this) {
                      if (!mRunning) return;
  
                      if (DBG_PING) {
                          String peerUri = (mPeerProfile == null)
                                  ? "null"
                                  : mPeerProfile.getUriString();
                          log("keepalive: " + mLocalProfile.getUriString()
                                  + " --> " + peerUri + ", interval=" + mInterval);
                      }
                      try {
                          sendKeepAlive();
                      } catch (Throwable t) {
                          if (SKA_DBG) {
                              loge("keepalive error: "
                                      + mLocalProfile.getUriString(), getRootCause(t));
                          }
                          // It's possible that the keepalive process is being stopped
                          // during session.sendKeepAlive() so need to check mRunning
                          // again here.
                          if (mRunning) SipSessionImpl.this.onError(t);
                      }
                  }
              }
  
              void stop() {
                  synchronized (SipSessionGroup.this) {
                      if (SKA_DBG) {
                          log("stop keepalive:" + mLocalProfile.getUriString()
                                  + ",RPort=" + mRPort);
                      }
                      mRunning = false;
                      mWakeupTimer.cancel(this);
                      reset();
                  }
              }
  
              private void sendKeepAlive() throws SipException {
                  synchronized (SipSessionGroup.this) {
                      mState = SipSession.State.PINGING;
                      mClientTransaction = mSipHelper.sendOptions(
                              mLocalProfile, mPeerProfile, generateTag());
                      mDialog = mClientTransaction.getDialog();
                      addSipSession(SipSessionImpl.this);
  
                      startSessionTimer(KEEPALIVE_TIMEOUT);
                      // when timed out, onError() will be called with SipErrorCode.TIME_OUT
                  }
              }
  
              private boolean parseOptionsResult(EventObject evt) {
                  if (expectResponse(Request.OPTIONS, evt)) {
                      ResponseEvent event = (ResponseEvent) evt;
                      int rPort = getRPortFromResponse(event.getResponse());
                      if (rPort != -1) {
                          if (mRPort == 0) mRPort = rPort;
                          if (mRPort != rPort) {
                              mPortChanged = true;
                              if (SKA_DBG) log(String.format(
                                      "rport is changed: %d <> %d", mRPort, rPort));
                              mRPort = rPort;
                          } else {
                              if (SKA_DBG) log("rport is the same: " + rPort);
                          }
                      } else {
                          if (SKA_DBG) log("peer did not respond rport");
                      }
                      return true;
                  }
                  return false;
              }
  
              private int getRPortFromResponse(Response response) {
                  ViaHeader viaHeader = (ViaHeader)(response.getHeader(
                          SIPHeaderNames.VIA));
                  return (viaHeader == null) ? -1 : viaHeader.getRPort();
              }
  
              private void log(String s) {
                  Rlog.d(SKA_TAG, s);
              }
          }
  
          private void log(String s) {
              Rlog.d(SSI_TAG, s);
          }
      }
  
      /**
       * @return true if the event is a request event matching the specified
       *      method; false otherwise
       */
      private static boolean isRequestEvent(String method, EventObject event) {
          try {
              if (event instanceof RequestEvent) {
                  RequestEvent requestEvent = (RequestEvent) event;
                  return method.equals(requestEvent.getRequest().getMethod());
              }
          } catch (Throwable e) {
          }
          return false;
      }
  
      private static String getCseqMethod(Message message) {
          return ((CSeqHeader) message.getHeader(CSeqHeader.NAME)).getMethod();
      }
  
      /**
       * @return true if the event is a response event and the CSeqHeader method
       * match the given arguments; false otherwise
       */
      private static boolean expectResponse(
              String expectedMethod, EventObject evt) {
          if (evt instanceof ResponseEvent) {
              ResponseEvent event = (ResponseEvent) evt;
              Response response = event.getResponse();
              return expectedMethod.equalsIgnoreCase(getCseqMethod(response));
          }
          return false;
      }
  
      private static SipProfile createPeerProfile(HeaderAddress header)
              throws SipException {
          try {
              Address address = header.getAddress();
              SipURI uri = (SipURI) address.getURI();
              String username = uri.getUser();
              if (username == null) username = ANONYMOUS;
              int port = uri.getPort();
              SipProfile.Builder builder =
                      new SipProfile.Builder(username, uri.getHost())
                      .setDisplayName(address.getDisplayName());
              if (port > 0) builder.setPort(port);
              return builder.build();
          } catch (IllegalArgumentException e) {
              throw new SipException("createPeerProfile()", e);
          } catch (ParseException e) {
              throw new SipException("createPeerProfile()", e);
          }
      }
  
      private static boolean isLoggable(SipSessionImpl s) {
          if (s != null) {
              switch (s.mState) {
                  case SipSession.State.PINGING:
                      return DBG_PING;
              }
          }
          return DBG;
      }
  
      private static boolean isLoggable(EventObject evt) {
          return isLoggable(null, evt);
      }
  
      private static boolean isLoggable(SipSessionImpl s, EventObject evt) {
          if (!isLoggable(s)) return false;
          if (evt == null) return false;
  
          if (evt instanceof ResponseEvent) {
              Response response = ((ResponseEvent) evt).getResponse();
              if (Request.OPTIONS.equals(response.getHeader(CSeqHeader.NAME))) {
                  return DBG_PING;
              }
              return DBG;
          } else if (evt instanceof RequestEvent) {
              if (isRequestEvent(Request.OPTIONS, evt)) {
                  return DBG_PING;
              }
              return DBG;
          }
          return false;
      }
  
      private static String logEvt(EventObject evt) {
          if (evt instanceof RequestEvent) {
              return ((RequestEvent) evt).getRequest().toString();
          } else if (evt instanceof ResponseEvent) {
              return ((ResponseEvent) evt).getResponse().toString();
          } else {
              return evt.toString();
          }
      }
  
      private class RegisterCommand extends EventObject {
          private int mDuration;
  
          public RegisterCommand(int duration) {
              super(SipSessionGroup.this);
              mDuration = duration;
          }
  
          public int getDuration() {
              return mDuration;
          }
      }
  
      private class MakeCallCommand extends EventObject {
          private String mSessionDescription;
          private int mTimeout; // in seconds
  
          public MakeCallCommand(SipProfile peerProfile,
                  String sessionDescription, int timeout) {
              super(peerProfile);
              mSessionDescription = sessionDescription;
              mTimeout = timeout;
          }
  
          public SipProfile getPeerProfile() {
              return (SipProfile) getSource();
          }
  
          public String getSessionDescription() {
              return mSessionDescription;
          }
  
          public int getTimeout() {
              return mTimeout;
          }
      }
  
      /** Class to help safely run KeepAliveProcessCallback in a different thread. */
      static class KeepAliveProcessCallbackProxy implements KeepAliveProcessCallback {
          private static final String KAPCP_TAG = "KeepAliveProcessCallbackProxy";
          private KeepAliveProcessCallback mCallback;
  
          KeepAliveProcessCallbackProxy(KeepAliveProcessCallback callback) {
              mCallback = callback;
          }
  
          private void proxy(Runnable runnable) {
              // One thread for each calling back.
              // Note: Guarantee ordering if the issue becomes important. Currently,
              // the chance of handling two callback events at a time is none.
              new Thread(runnable, "SIP-KeepAliveProcessCallbackThread").start();
          }
  
          @Override
          public void onResponse(final boolean portChanged) {
              if (mCallback == null) return;
              proxy(new Runnable() {
                  @Override
                  public void run() {
                      try {
                          mCallback.onResponse(portChanged);
                      } catch (Throwable t) {
                          loge("onResponse", t);
                      }
                  }
              });
          }
  
          @Override
          public void onError(final int errorCode, final String description) {
              if (mCallback == null) return;
              proxy(new Runnable() {
                  @Override
                  public void run() {
                      try {
                          mCallback.onError(errorCode, description);
                      } catch (Throwable t) {
                          loge("onError", t);
                      }
                  }
              });
          }
  
          private void loge(String s, Throwable t) {
              Rlog.e(KAPCP_TAG, s, t);
          }
      }
  
      private void log(String s) {
          Rlog.d(TAG, s);
      }
  
      private void loge(String s, Throwable t) {
          Rlog.e(TAG, s, t);
      }
  }
  