diff options
37 files changed, 11349 insertions, 0 deletions
diff --git a/java/android/net/rtp/AudioCodec.java b/java/android/net/rtp/AudioCodec.java new file mode 100644 index 0000000..85255c8 --- /dev/null +++ b/java/android/net/rtp/AudioCodec.java @@ -0,0 +1,146 @@ +/* + * 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 android.net.rtp; + +import java.util.Arrays; + +/** + * This class defines a collection of audio codecs to be used with + * {@link AudioStream}s. Their parameters are designed to be exchanged using + * Session Description Protocol (SDP). Most of the values listed here can be + * found in RFC 3551, while others are described in separated standards. + * + * <p>Few simple configurations are defined as public static instances for the + * convenience of direct uses. More complicated ones could be obtained using + * {@link #getCodec(int, String, String)}. For example, one can use the + * following snippet to create a mode-1-only AMR codec.</p> + * <pre> + * AudioCodec codec = AudioCodec.getCodec(100, "AMR/8000", "mode-set=1"); + * </pre> + * + * @see AudioStream + */ +public class AudioCodec { + /** + * The RTP payload type of the encoding. + */ + public final int type; + + /** + * The encoding parameters to be used in the corresponding SDP attribute. + */ + public final String rtpmap; + + /** + * The format parameters to be used in the corresponding SDP attribute. + */ + public final String fmtp; + + /** + * G.711 u-law audio codec. + */ + public static final AudioCodec PCMU = new AudioCodec(0, "PCMU/8000", null); + + /** + * G.711 a-law audio codec. + */ + public static final AudioCodec PCMA = new AudioCodec(8, "PCMA/8000", null); + + /** + * GSM Full-Rate audio codec, also known as GSM-FR, GSM 06.10, GSM, or + * simply FR. + */ + public static final AudioCodec GSM = new AudioCodec(3, "GSM/8000", null); + + /** + * GSM Enhanced Full-Rate audio codec, also known as GSM-EFR, GSM 06.60, or + * simply EFR. + */ + public static final AudioCodec GSM_EFR = new AudioCodec(96, "GSM-EFR/8000", null); + + /** + * Adaptive Multi-Rate narrowband audio codec, also known as AMR or AMR-NB. + * Currently CRC, robust sorting, and interleaving are not supported. See + * more details about these features in RFC 4867. + */ + public static final AudioCodec AMR = new AudioCodec(97, "AMR/8000", null); + + private static final AudioCodec[] sCodecs = {GSM_EFR, AMR, GSM, PCMU, PCMA}; + + private AudioCodec(int type, String rtpmap, String fmtp) { + this.type = type; + this.rtpmap = rtpmap; + this.fmtp = fmtp; + } + + /** + * Returns system supported audio codecs. + */ + public static AudioCodec[] getCodecs() { + return Arrays.copyOf(sCodecs, sCodecs.length); + } + + /** + * Creates an AudioCodec according to the given configuration. + * + * @param type The payload type of the encoding defined in RTP/AVP. + * @param rtpmap The encoding parameters specified in the corresponding SDP + * attribute, or null if it is not available. + * @param fmtp The format parameters specified in the corresponding SDP + * attribute, or null if it is not available. + * @return The configured AudioCodec or {@code null} if it is not supported. + */ + public static AudioCodec getCodec(int type, String rtpmap, String fmtp) { + if (type < 0 || type > 127) { + return null; + } + + AudioCodec hint = null; + if (rtpmap != null) { + String clue = rtpmap.trim().toUpperCase(); + for (AudioCodec codec : sCodecs) { + if (clue.startsWith(codec.rtpmap)) { + String channels = clue.substring(codec.rtpmap.length()); + if (channels.length() == 0 || channels.equals("/1")) { + hint = codec; + } + break; + } + } + } else if (type < 96) { + for (AudioCodec codec : sCodecs) { + if (type == codec.type) { + hint = codec; + rtpmap = codec.rtpmap; + break; + } + } + } + + if (hint == null) { + return null; + } + if (hint == AMR && fmtp != null) { + String clue = fmtp.toLowerCase(); + if (clue.contains("crc=1") || clue.contains("robust-sorting=1") || + clue.contains("interleaving=")) { + return null; + } + } + return new AudioCodec(type, rtpmap, fmtp); + } +} diff --git a/java/android/net/rtp/AudioGroup.java b/java/android/net/rtp/AudioGroup.java new file mode 100644 index 0000000..8faeb88 --- /dev/null +++ b/java/android/net/rtp/AudioGroup.java @@ -0,0 +1,206 @@ +/* + * 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 android.net.rtp; + +import android.media.AudioManager; + +import java.util.HashMap; +import java.util.Locale; +import java.util.Map; + +/** + * An AudioGroup is an audio hub for the speaker, the microphone, and + * {@link AudioStream}s. Each of these components can be logically turned on + * or off by calling {@link #setMode(int)} or {@link RtpStream#setMode(int)}. + * The AudioGroup will go through these components and process them one by one + * within its execution loop. The loop consists of four steps. First, for each + * AudioStream not in {@link RtpStream#MODE_SEND_ONLY}, decodes its incoming + * packets and stores in its buffer. Then, if the microphone is enabled, + * processes the recorded audio and stores in its buffer. Third, if the speaker + * is enabled, mixes all AudioStream buffers and plays back. Finally, for each + * AudioStream not in {@link RtpStream#MODE_RECEIVE_ONLY}, mixes all other + * buffers and sends back the encoded packets. An AudioGroup does nothing if + * there is no AudioStream in it. + * + * <p>Few things must be noticed before using these classes. The performance is + * highly related to the system load and the network bandwidth. Usually a + * simpler {@link AudioCodec} costs fewer CPU cycles but requires more network + * bandwidth, and vise versa. Using two AudioStreams at the same time doubles + * not only the load but also the bandwidth. The condition varies from one + * device to another, and developers should choose the right combination in + * order to get the best result.</p> + * + * <p>It is sometimes useful to keep multiple AudioGroups at the same time. For + * example, a Voice over IP (VoIP) application might want to put a conference + * call on hold in order to make a new call but still allow people in the + * conference call talking to each other. This can be done easily using two + * AudioGroups, but there are some limitations. Since the speaker and the + * microphone are globally shared resources, only one AudioGroup at a time is + * allowed to run in a mode other than {@link #MODE_ON_HOLD}. The others will + * be unable to acquire these resources and fail silently.</p> + * + * <p class="note">Using this class requires + * {@link android.Manifest.permission#RECORD_AUDIO} permission. Developers + * should set the audio mode to {@link AudioManager#MODE_IN_COMMUNICATION} + * using {@link AudioManager#setMode(int)} and change it back when none of + * the AudioGroups is in use.</p> + * + * @see AudioStream + */ +public class AudioGroup { + /** + * This mode is similar to {@link #MODE_NORMAL} except the speaker and + * the microphone are both disabled. + */ + public static final int MODE_ON_HOLD = 0; + + /** + * This mode is similar to {@link #MODE_NORMAL} except the microphone is + * disabled. + */ + public static final int MODE_MUTED = 1; + + /** + * This mode indicates that the speaker, the microphone, and all + * {@link AudioStream}s in the group are enabled. First, the packets + * received from the streams are decoded and mixed with the audio recorded + * from the microphone. Then, the results are played back to the speaker, + * encoded and sent back to each stream. + */ + public static final int MODE_NORMAL = 2; + + /** + * This mode is similar to {@link #MODE_NORMAL} except the echo suppression + * is enabled. It should be only used when the speaker phone is on. + */ + public static final int MODE_ECHO_SUPPRESSION = 3; + + private static final int MODE_LAST = 3; + + private final Map<AudioStream, Integer> mStreams; + private int mMode = MODE_ON_HOLD; + + private int mNative; + static { + System.loadLibrary("rtp_jni"); + } + + /** + * Creates an empty AudioGroup. + */ + public AudioGroup() { + mStreams = new HashMap<AudioStream, Integer>(); + } + + /** + * Returns the {@link AudioStream}s in this group. + */ + public AudioStream[] getStreams() { + synchronized (this) { + return mStreams.keySet().toArray(new AudioStream[mStreams.size()]); + } + } + + /** + * Returns the current mode. + */ + public int getMode() { + return mMode; + } + + /** + * Changes the current mode. It must be one of {@link #MODE_ON_HOLD}, + * {@link #MODE_MUTED}, {@link #MODE_NORMAL}, and + * {@link #MODE_ECHO_SUPPRESSION}. + * + * @param mode The mode to change to. + * @throws IllegalArgumentException if the mode is invalid. + */ + public void setMode(int mode) { + if (mode < 0 || mode > MODE_LAST) { + throw new IllegalArgumentException("Invalid mode"); + } + synchronized (this) { + nativeSetMode(mode); + mMode = mode; + } + } + + private native void nativeSetMode(int mode); + + // Package-private method used by AudioStream.join(). + synchronized void add(AudioStream stream) { + if (!mStreams.containsKey(stream)) { + try { + AudioCodec codec = stream.getCodec(); + String codecSpec = String.format(Locale.US, "%d %s %s", codec.type, + codec.rtpmap, codec.fmtp); + int id = nativeAdd(stream.getMode(), stream.getSocket(), + stream.getRemoteAddress().getHostAddress(), + stream.getRemotePort(), codecSpec, stream.getDtmfType()); + mStreams.put(stream, id); + } catch (NullPointerException e) { + throw new IllegalStateException(e); + } + } + } + + private native int nativeAdd(int mode, int socket, String remoteAddress, + int remotePort, String codecSpec, int dtmfType); + + // Package-private method used by AudioStream.join(). + synchronized void remove(AudioStream stream) { + Integer id = mStreams.remove(stream); + if (id != null) { + nativeRemove(id); + } + } + + private native void nativeRemove(int id); + + /** + * Sends a DTMF digit to every {@link AudioStream} in this group. Currently + * only event {@code 0} to {@code 15} are supported. + * + * @throws IllegalArgumentException if the event is invalid. + */ + public void sendDtmf(int event) { + if (event < 0 || event > 15) { + throw new IllegalArgumentException("Invalid event"); + } + synchronized (this) { + nativeSendDtmf(event); + } + } + + private native void nativeSendDtmf(int event); + + /** + * Removes every {@link AudioStream} in this group. + */ + public void clear() { + for (AudioStream stream : getStreams()) { + stream.join(null); + } + } + + @Override + protected void finalize() throws Throwable { + nativeRemove(0); + super.finalize(); + } +} diff --git a/java/android/net/rtp/AudioStream.java b/java/android/net/rtp/AudioStream.java new file mode 100644 index 0000000..5cd1abc --- /dev/null +++ b/java/android/net/rtp/AudioStream.java @@ -0,0 +1,167 @@ +/* + * 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 android.net.rtp; + +import java.net.InetAddress; +import java.net.SocketException; + +/** + * An AudioStream is a {@link RtpStream} which carrys audio payloads over + * Real-time Transport Protocol (RTP). Two different classes are developed in + * order to support various usages such as audio conferencing. An AudioStream + * represents a remote endpoint which consists of a network mapping and a + * configured {@link AudioCodec}. On the other side, An {@link AudioGroup} + * represents a local endpoint which mixes all the AudioStreams and optionally + * interacts with the speaker and the microphone at the same time. The simplest + * usage includes one for each endpoints. For other combinations, developers + * should be aware of the limitations described in {@link AudioGroup}. + * + * <p>An AudioStream becomes busy when it joins an AudioGroup. In this case most + * of the setter methods are disabled. This is designed to ease the task of + * managing native resources. One can always make an AudioStream leave its + * AudioGroup by calling {@link #join(AudioGroup)} with {@code null} and put it + * back after the modification is done.</p> + * + * <p class="note">Using this class requires + * {@link android.Manifest.permission#INTERNET} permission.</p> + * + * @see RtpStream + * @see AudioGroup + */ +public class AudioStream extends RtpStream { + private AudioCodec mCodec; + private int mDtmfType = -1; + private AudioGroup mGroup; + + /** + * Creates an AudioStream on the given local address. Note that the local + * port is assigned automatically to conform with RFC 3550. + * + * @param address The network address of the local host to bind to. + * @throws SocketException if the address cannot be bound or a problem + * occurs during binding. + */ + public AudioStream(InetAddress address) throws SocketException { + super(address); + } + + /** + * Returns {@code true} if the stream has already joined an + * {@link AudioGroup}. + */ + @Override + public final boolean isBusy() { + return mGroup != null; + } + + /** + * Returns the joined {@link AudioGroup}. + */ + public AudioGroup getGroup() { + return mGroup; + } + + /** + * Joins an {@link AudioGroup}. Each stream can join only one group at a + * time. The group can be changed by passing a different one or removed + * by calling this method with {@code null}. + * + * @param group The AudioGroup to join or {@code null} to leave. + * @throws IllegalStateException if the stream is not properly configured. + * @see AudioGroup + */ + public void join(AudioGroup group) { + synchronized (this) { + if (mGroup == group) { + return; + } + if (mGroup != null) { + mGroup.remove(this); + mGroup = null; + } + if (group != null) { + group.add(this); + mGroup = group; + } + } + } + + /** + * Returns the {@link AudioCodec}, or {@code null} if it is not set. + * + * @see #setCodec(AudioCodec) + */ + public AudioCodec getCodec() { + return mCodec; + } + + /** + * Sets the {@link AudioCodec}. + * + * @param codec The AudioCodec to be used. + * @throws IllegalArgumentException if its type is used by DTMF. + * @throws IllegalStateException if the stream is busy. + */ + public void setCodec(AudioCodec codec) { + if (isBusy()) { + throw new IllegalStateException("Busy"); + } + if (codec.type == mDtmfType) { + throw new IllegalArgumentException("The type is used by DTMF"); + } + mCodec = codec; + } + + /** + * Returns the RTP payload type for dual-tone multi-frequency (DTMF) digits, + * or {@code -1} if it is not enabled. + * + * @see #setDtmfType(int) + */ + public int getDtmfType() { + return mDtmfType; + } + + /** + * Sets the RTP payload type for dual-tone multi-frequency (DTMF) digits. + * The primary usage is to send digits to the remote gateway to perform + * certain tasks, such as second-stage dialing. According to RFC 2833, the + * RTP payload type for DTMF is assigned dynamically, so it must be in the + * range of 96 and 127. One can use {@code -1} to disable DTMF and free up + * the previous assigned type. This method cannot be called when the stream + * already joined an {@link AudioGroup}. + * + * @param type The RTP payload type to be used or {@code -1} to disable it. + * @throws IllegalArgumentException if the type is invalid or used by codec. + * @throws IllegalStateException if the stream is busy. + * @see AudioGroup#sendDtmf(int) + */ + public void setDtmfType(int type) { + if (isBusy()) { + throw new IllegalStateException("Busy"); + } + if (type != -1) { + if (type < 96 || type > 127) { + throw new IllegalArgumentException("Invalid type"); + } + if (mCodec != null && type == mCodec.type) { + throw new IllegalArgumentException("The type is used by codec"); + } + } + mDtmfType = type; + } +} diff --git a/java/android/net/rtp/RtpStream.java b/java/android/net/rtp/RtpStream.java new file mode 100644 index 0000000..b9d75cd --- /dev/null +++ b/java/android/net/rtp/RtpStream.java @@ -0,0 +1,195 @@ +/* + * 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 android.net.rtp; + +import java.net.InetAddress; +import java.net.Inet4Address; +import java.net.Inet6Address; +import java.net.SocketException; + +/** + * RtpStream represents the base class of streams which send and receive network + * packets with media payloads over Real-time Transport Protocol (RTP). + * + * <p class="note">Using this class requires + * {@link android.Manifest.permission#INTERNET} permission.</p> + */ +public class RtpStream { + /** + * This mode indicates that the stream sends and receives packets at the + * same time. This is the initial mode for new streams. + */ + public static final int MODE_NORMAL = 0; + + /** + * This mode indicates that the stream only sends packets. + */ + public static final int MODE_SEND_ONLY = 1; + + /** + * This mode indicates that the stream only receives packets. + */ + public static final int MODE_RECEIVE_ONLY = 2; + + private static final int MODE_LAST = 2; + + private final InetAddress mLocalAddress; + private final int mLocalPort; + + private InetAddress mRemoteAddress; + private int mRemotePort = -1; + private int mMode = MODE_NORMAL; + + private int mSocket = -1; + static { + System.loadLibrary("rtp_jni"); + } + + /** + * Creates a RtpStream on the given local address. Note that the local + * port is assigned automatically to conform with RFC 3550. + * + * @param address The network address of the local host to bind to. + * @throws SocketException if the address cannot be bound or a problem + * occurs during binding. + */ + RtpStream(InetAddress address) throws SocketException { + mLocalPort = create(address.getHostAddress()); + mLocalAddress = address; + } + + private native int create(String address) throws SocketException; + + /** + * Returns the network address of the local host. + */ + public InetAddress getLocalAddress() { + return mLocalAddress; + } + + /** + * Returns the network port of the local host. + */ + public int getLocalPort() { + return mLocalPort; + } + + /** + * Returns the network address of the remote host or {@code null} if the + * stream is not associated. + */ + public InetAddress getRemoteAddress() { + return mRemoteAddress; + } + + /** + * Returns the network port of the remote host or {@code -1} if the stream + * is not associated. + */ + public int getRemotePort() { + return mRemotePort; + } + + /** + * Returns {@code true} if the stream is busy. In this case most of the + * setter methods are disabled. This method is intended to be overridden + * by subclasses. + */ + public boolean isBusy() { + return false; + } + + /** + * Returns the current mode. + */ + public int getMode() { + return mMode; + } + + /** + * Changes the current mode. It must be one of {@link #MODE_NORMAL}, + * {@link #MODE_SEND_ONLY}, and {@link #MODE_RECEIVE_ONLY}. + * + * @param mode The mode to change to. + * @throws IllegalArgumentException if the mode is invalid. + * @throws IllegalStateException if the stream is busy. + * @see #isBusy() + */ + public void setMode(int mode) { + if (isBusy()) { + throw new IllegalStateException("Busy"); + } + if (mode < 0 || mode > MODE_LAST) { + throw new IllegalArgumentException("Invalid mode"); + } + mMode = mode; + } + + /** + * Associates with a remote host. This defines the destination of the + * outgoing packets. + * + * @param address The network address of the remote host. + * @param port The network port of the remote host. + * @throws IllegalArgumentException if the address is not supported or the + * port is invalid. + * @throws IllegalStateException if the stream is busy. + * @see #isBusy() + */ + public void associate(InetAddress address, int port) { + if (isBusy()) { + throw new IllegalStateException("Busy"); + } + if (!(address instanceof Inet4Address && mLocalAddress instanceof Inet4Address) && + !(address instanceof Inet6Address && mLocalAddress instanceof Inet6Address)) { + throw new IllegalArgumentException("Unsupported address"); + } + if (port < 0 || port > 65535) { + throw new IllegalArgumentException("Invalid port"); + } + mRemoteAddress = address; + mRemotePort = port; + } + + int getSocket() { + return mSocket; + } + + /** + * Releases allocated resources. The stream becomes inoperable after calling + * this method. + * + * @throws IllegalStateException if the stream is busy. + * @see #isBusy() + */ + public void release() { + synchronized (this) { + if (isBusy()) { + throw new IllegalStateException("Busy"); + } + close(); + } + } + + private native void close(); + + @Override + protected void finalize() throws Throwable { + close(); + super.finalize(); + } +} diff --git a/java/android/net/rtp/package.html b/java/android/net/rtp/package.html new file mode 100644 index 0000000..4506b09 --- /dev/null +++ b/java/android/net/rtp/package.html @@ -0,0 +1,28 @@ +<html> +<body> +<p>Provides APIs for RTP (Real-time Transport Protocol), allowing applications to manage on-demand +or interactive data streaming. In particular, apps that provide VOIP, push-to-talk, conferencing, +and audio streaming can use these APIs to initiate sessions and transmit or receive data streams +over any available network.</p> + +<p>To support audio conferencing and similar usages, you need to instantiate two classes as +endpoints for the stream:</p> + +<ul> +<li>{@link android.net.rtp.AudioStream} specifies a remote endpoint and consists of network mapping +and a configured {@link android.net.rtp.AudioCodec}.</li> + +<li>{@link android.net.rtp.AudioGroup} represents the local endpoint for one or more {@link +android.net.rtp.AudioStream}s. The {@link android.net.rtp.AudioGroup} mixes all the {@link +android.net.rtp.AudioStream}s and optionally interacts with the device speaker and the microphone at +the same time.</li> +</ul> + +<p>The simplest usage involves a single remote endpoint and local endpoint. For more complex usages, +refer to the limitations described for {@link android.net.rtp.AudioGroup}.</p> + +<p class="note"><strong>Note:</strong> To use the RTP APIs, you must request the {@link +android.Manifest.permission#INTERNET} and {@link +android.Manifest.permission#RECORD_AUDIO} permissions in your manifest file.</p> +</body> +</html>
\ No newline at end of file diff --git a/java/android/net/sip/ISipService.aidl b/java/android/net/sip/ISipService.aidl new file mode 100644 index 0000000..3250bf9 --- /dev/null +++ b/java/android/net/sip/ISipService.aidl @@ -0,0 +1,43 @@ +/* + * 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 android.net.sip; + +import android.app.PendingIntent; +import android.net.sip.ISipSession; +import android.net.sip.ISipSessionListener; +import android.net.sip.SipProfile; + +/** + * {@hide} + */ +interface ISipService { + void open(in SipProfile localProfile); + void open3(in SipProfile localProfile, + in PendingIntent incomingCallPendingIntent, + in ISipSessionListener listener); + void close(in String localProfileUri); + boolean isOpened(String localProfileUri); + boolean isRegistered(String localProfileUri); + void setRegistrationListener(String localProfileUri, + ISipSessionListener listener); + + ISipSession createSession(in SipProfile localProfile, + in ISipSessionListener listener); + ISipSession getPendingSession(String callId); + + SipProfile[] getListOfProfiles(); +} diff --git a/java/android/net/sip/ISipSession.aidl b/java/android/net/sip/ISipSession.aidl new file mode 100644 index 0000000..2d515db --- /dev/null +++ b/java/android/net/sip/ISipSession.aidl @@ -0,0 +1,148 @@ +/* + * 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 android.net.sip; + +import android.net.sip.ISipSessionListener; +import android.net.sip.SipProfile; + +/** + * A SIP session that is associated with a SIP dialog or a transaction that is + * not within a dialog. + * @hide + */ +interface ISipSession { + /** + * Gets the IP address of the local host on which this SIP session runs. + * + * @return the IP address of the local host + */ + String getLocalIp(); + + /** + * Gets the SIP profile that this session is associated with. + * + * @return the SIP profile that this session is associated with + */ + SipProfile getLocalProfile(); + + /** + * Gets the SIP profile that this session is connected to. Only available + * when the session is associated with a SIP dialog. + * + * @return the SIP profile that this session is connected to + */ + SipProfile getPeerProfile(); + + /** + * Gets the session state. The value returned must be one of the states in + * {@link SipSessionState}. + * + * @return the session state + */ + int getState(); + + /** + * Checks if the session is in a call. + * + * @return true if the session is in a call + */ + boolean isInCall(); + + /** + * Gets the call ID of the session. + * + * @return the call ID + */ + String getCallId(); + + + /** + * Sets the listener to listen to the session events. A {@link ISipSession} + * can only hold one listener at a time. Subsequent calls to this method + * override the previous listener. + * + * @param listener to listen to the session events of this object + */ + void setListener(in ISipSessionListener listener); + + + /** + * Performs registration to the server specified by the associated local + * profile. The session listener is called back upon success or failure of + * registration. The method is only valid to call when the session state is + * in {@link SipSessionState#READY_TO_CALL}. + * + * @param duration duration in second before the registration expires + * @see ISipSessionListener + */ + void register(int duration); + + /** + * Performs unregistration to the server specified by the associated local + * profile. Unregistration is technically the same as registration with zero + * expiration duration. The session listener is called back upon success or + * failure of unregistration. The method is only valid to call when the + * session state is in {@link SipSessionState#READY_TO_CALL}. + * + * @see ISipSessionListener + */ + void unregister(); + + /** + * Initiates a call to the specified profile. The session listener is called + * back upon defined session events. The method is only valid to call when + * the session state is in {@link SipSessionState#READY_TO_CALL}. + * + * @param callee the SIP profile to make the call to + * @param sessionDescription the session description of this call + * @param timeout the session will be timed out if the call is not + * established within {@code timeout} seconds + * @see ISipSessionListener + */ + void makeCall(in SipProfile callee, String sessionDescription, int timeout); + + /** + * Answers an incoming call with the specified session description. The + * method is only valid to call when the session state is in + * {@link SipSessionState#INCOMING_CALL}. + * + * @param sessionDescription the session description to answer this call + * @param timeout the session will be timed out if the call is not + * established within {@code timeout} seconds + */ + void answerCall(String sessionDescription, int timeout); + + /** + * Ends an established call, terminates an outgoing call or rejects an + * incoming call. The method is only valid to call when the session state is + * in {@link SipSessionState#IN_CALL}, + * {@link SipSessionState#INCOMING_CALL}, + * {@link SipSessionState#OUTGOING_CALL} or + * {@link SipSessionState#OUTGOING_CALL_RING_BACK}. + */ + void endCall(); + + /** + * Changes the session description during a call. The method is only valid + * to call when the session state is in {@link SipSessionState#IN_CALL}. + * + * @param sessionDescription the new session description + * @param timeout the session will be timed out if the call is not + * established within {@code timeout} seconds + */ + void changeCall(String sessionDescription, int timeout); +} diff --git a/java/android/net/sip/ISipSessionListener.aidl b/java/android/net/sip/ISipSessionListener.aidl new file mode 100644 index 0000000..690700c --- /dev/null +++ b/java/android/net/sip/ISipSessionListener.aidl @@ -0,0 +1,133 @@ +/* + * 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 android.net.sip; + +import android.net.sip.ISipSession; +import android.net.sip.SipProfile; + +/** + * Listener class to listen to SIP session events. + * @hide + */ +interface ISipSessionListener { + /** + * Called when an INVITE request is sent to initiate a new call. + * + * @param session the session object that carries out the transaction + */ + void onCalling(in ISipSession session); + + /** + * Called when an INVITE request is received. + * + * @param session the session object that carries out the transaction + * @param caller the SIP profile of the caller + * @param sessionDescription the caller's session description + */ + void onRinging(in ISipSession session, in SipProfile caller, + String sessionDescription); + + /** + * Called when a RINGING response is received for the INVITE request sent + * + * @param session the session object that carries out the transaction + */ + void onRingingBack(in ISipSession session); + + /** + * Called when the session is established. + * + * @param session the session object that is associated with the dialog + * @param sessionDescription the peer's session description + */ + void onCallEstablished(in ISipSession session, + String sessionDescription); + + /** + * Called when the session is terminated. + * + * @param session the session object that is associated with the dialog + */ + void onCallEnded(in ISipSession session); + + /** + * Called when the peer is busy during session initialization. + * + * @param session the session object that carries out the transaction + */ + void onCallBusy(in ISipSession session); + + /** + * Called when the call is being transferred to a new one. + * + * @param newSession the new session that the call will be transferred to + * @param sessionDescription the new peer's session description + */ + void onCallTransferring(in ISipSession newSession, String sessionDescription); + + /** + * Called when an error occurs during session initialization and + * termination. + * + * @param session the session object that carries out the transaction + * @param errorCode error code defined in {@link SipErrorCode} + * @param errorMessage error message + */ + void onError(in ISipSession session, int errorCode, String errorMessage); + + /** + * Called when an error occurs during session modification negotiation. + * + * @param session the session object that carries out the transaction + * @param errorCode error code defined in {@link SipErrorCode} + * @param errorMessage error message + */ + void onCallChangeFailed(in ISipSession session, int errorCode, + String errorMessage); + + /** + * Called when a registration request is sent. + * + * @param session the session object that carries out the transaction + */ + void onRegistering(in ISipSession session); + + /** + * Called when registration is successfully done. + * + * @param session the session object that carries out the transaction + * @param duration duration in second before the registration expires + */ + void onRegistrationDone(in ISipSession session, int duration); + + /** + * Called when the registration fails. + * + * @param session the session object that carries out the transaction + * @param errorCode error code defined in {@link SipErrorCode} + * @param errorMessage error message + */ + void onRegistrationFailed(in ISipSession session, int errorCode, + String errorMessage); + + /** + * Called when the registration gets timed out. + * + * @param session the session object that carries out the transaction + */ + void onRegistrationTimeout(in ISipSession session); +} diff --git a/java/android/net/sip/SimpleSessionDescription.java b/java/android/net/sip/SimpleSessionDescription.java new file mode 100644 index 0000000..9fcd21d --- /dev/null +++ b/java/android/net/sip/SimpleSessionDescription.java @@ -0,0 +1,613 @@ +/* + * 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 android.net.sip; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Locale; + +/** + * An object used to manipulate messages of Session Description Protocol (SDP). + * It is mainly designed for the uses of Session Initiation Protocol (SIP). + * Therefore, it only handles connection addresses ("c="), bandwidth limits, + * ("b="), encryption keys ("k="), and attribute fields ("a="). Currently this + * implementation does not support multicast sessions. + * + * <p>Here is an example code to create a session description.</p> + * <pre> + * SimpleSessionDescription description = new SimpleSessionDescription( + * System.currentTimeMillis(), "1.2.3.4"); + * Media media = description.newMedia("audio", 56789, 1, "RTP/AVP"); + * media.setRtpPayload(0, "PCMU/8000", null); + * media.setRtpPayload(8, "PCMA/8000", null); + * media.setRtpPayload(127, "telephone-event/8000", "0-15"); + * media.setAttribute("sendrecv", ""); + * </pre> + * <p>Invoking <code>description.encode()</code> will produce a result like the + * one below.</p> + * <pre> + * v=0 + * o=- 1284970442706 1284970442709 IN IP4 1.2.3.4 + * s=- + * c=IN IP4 1.2.3.4 + * t=0 0 + * m=audio 56789 RTP/AVP 0 8 127 + * a=rtpmap:0 PCMU/8000 + * a=rtpmap:8 PCMA/8000 + * a=rtpmap:127 telephone-event/8000 + * a=fmtp:127 0-15 + * a=sendrecv + * </pre> + * @hide + */ +public class SimpleSessionDescription { + private final Fields mFields = new Fields("voscbtka"); + private final ArrayList<Media> mMedia = new ArrayList<Media>(); + + /** + * Creates a minimal session description from the given session ID and + * unicast address. The address is used in the origin field ("o=") and the + * connection field ("c="). See {@link SimpleSessionDescription} for an + * example of its usage. + */ + public SimpleSessionDescription(long sessionId, String address) { + address = (address.indexOf(':') < 0 ? "IN IP4 " : "IN IP6 ") + address; + mFields.parse("v=0"); + mFields.parse(String.format(Locale.US, "o=- %d %d %s", sessionId, + System.currentTimeMillis(), address)); + mFields.parse("s=-"); + mFields.parse("t=0 0"); + mFields.parse("c=" + address); + } + + /** + * Creates a session description from the given message. + * + * @throws IllegalArgumentException if message is invalid. + */ + public SimpleSessionDescription(String message) { + String[] lines = message.trim().replaceAll(" +", " ").split("[\r\n]+"); + Fields fields = mFields; + + for (String line : lines) { + try { + if (line.charAt(1) != '=') { + throw new IllegalArgumentException(); + } + if (line.charAt(0) == 'm') { + String[] parts = line.substring(2).split(" ", 4); + String[] ports = parts[1].split("/", 2); + Media media = newMedia(parts[0], Integer.parseInt(ports[0]), + (ports.length < 2) ? 1 : Integer.parseInt(ports[1]), + parts[2]); + for (String format : parts[3].split(" ")) { + media.setFormat(format, null); + } + fields = media; + } else { + fields.parse(line); + } + } catch (Exception e) { + throw new IllegalArgumentException("Invalid SDP: " + line); + } + } + } + + /** + * Creates a new media description in this session description. + * + * @param type The media type, e.g. {@code "audio"}. + * @param port The first transport port used by this media. + * @param portCount The number of contiguous ports used by this media. + * @param protocol The transport protocol, e.g. {@code "RTP/AVP"}. + */ + public Media newMedia(String type, int port, int portCount, + String protocol) { + Media media = new Media(type, port, portCount, protocol); + mMedia.add(media); + return media; + } + + /** + * Returns all the media descriptions in this session description. + */ + public Media[] getMedia() { + return mMedia.toArray(new Media[mMedia.size()]); + } + + /** + * Encodes the session description and all its media descriptions in a + * string. Note that the result might be incomplete if a required field + * has never been added before. + */ + public String encode() { + StringBuilder buffer = new StringBuilder(); + mFields.write(buffer); + for (Media media : mMedia) { + media.write(buffer); + } + return buffer.toString(); + } + + /** + * Returns the connection address or {@code null} if it is not present. + */ + public String getAddress() { + return mFields.getAddress(); + } + + /** + * Sets the connection address. The field will be removed if the address + * is {@code null}. + */ + public void setAddress(String address) { + mFields.setAddress(address); + } + + /** + * Returns the encryption method or {@code null} if it is not present. + */ + public String getEncryptionMethod() { + return mFields.getEncryptionMethod(); + } + + /** + * Returns the encryption key or {@code null} if it is not present. + */ + public String getEncryptionKey() { + return mFields.getEncryptionKey(); + } + + /** + * Sets the encryption method and the encryption key. The field will be + * removed if the method is {@code null}. + */ + public void setEncryption(String method, String key) { + mFields.setEncryption(method, key); + } + + /** + * Returns the types of the bandwidth limits. + */ + public String[] getBandwidthTypes() { + return mFields.getBandwidthTypes(); + } + + /** + * Returns the bandwidth limit of the given type or {@code -1} if it is not + * present. + */ + public int getBandwidth(String type) { + return mFields.getBandwidth(type); + } + + /** + * Sets the bandwith limit for the given type. The field will be removed if + * the value is negative. + */ + public void setBandwidth(String type, int value) { + mFields.setBandwidth(type, value); + } + + /** + * Returns the names of all the attributes. + */ + public String[] getAttributeNames() { + return mFields.getAttributeNames(); + } + + /** + * Returns the attribute of the given name or {@code null} if it is not + * present. + */ + public String getAttribute(String name) { + return mFields.getAttribute(name); + } + + /** + * Sets the attribute for the given name. The field will be removed if + * the value is {@code null}. To set a binary attribute, use an empty + * string as the value. + */ + public void setAttribute(String name, String value) { + mFields.setAttribute(name, value); + } + + /** + * This class represents a media description of a session description. It + * can only be created by {@link SimpleSessionDescription#newMedia}. Since + * the syntax is more restricted for RTP based protocols, two sets of access + * methods are implemented. See {@link SimpleSessionDescription} for an + * example of its usage. + */ + public static class Media extends Fields { + private final String mType; + private final int mPort; + private final int mPortCount; + private final String mProtocol; + private ArrayList<String> mFormats = new ArrayList<String>(); + + private Media(String type, int port, int portCount, String protocol) { + super("icbka"); + mType = type; + mPort = port; + mPortCount = portCount; + mProtocol = protocol; + } + + /** + * Returns the media type. + */ + public String getType() { + return mType; + } + + /** + * Returns the first transport port used by this media. + */ + public int getPort() { + return mPort; + } + + /** + * Returns the number of contiguous ports used by this media. + */ + public int getPortCount() { + return mPortCount; + } + + /** + * Returns the transport protocol. + */ + public String getProtocol() { + return mProtocol; + } + + /** + * Returns the media formats. + */ + public String[] getFormats() { + return mFormats.toArray(new String[mFormats.size()]); + } + + /** + * Returns the {@code fmtp} attribute of the given format or + * {@code null} if it is not present. + */ + public String getFmtp(String format) { + return super.get("a=fmtp:" + format, ' '); + } + + /** + * Sets a format and its {@code fmtp} attribute. If the attribute is + * {@code null}, the corresponding field will be removed. + */ + public void setFormat(String format, String fmtp) { + mFormats.remove(format); + mFormats.add(format); + super.set("a=rtpmap:" + format, ' ', null); + super.set("a=fmtp:" + format, ' ', fmtp); + } + + /** + * Removes a format and its {@code fmtp} attribute. + */ + public void removeFormat(String format) { + mFormats.remove(format); + super.set("a=rtpmap:" + format, ' ', null); + super.set("a=fmtp:" + format, ' ', null); + } + + /** + * Returns the RTP payload types. + */ + public int[] getRtpPayloadTypes() { + int[] types = new int[mFormats.size()]; + int length = 0; + for (String format : mFormats) { + try { + types[length] = Integer.parseInt(format); + ++length; + } catch (NumberFormatException e) { } + } + return Arrays.copyOf(types, length); + } + + /** + * Returns the {@code rtpmap} attribute of the given RTP payload type + * or {@code null} if it is not present. + */ + public String getRtpmap(int type) { + return super.get("a=rtpmap:" + type, ' '); + } + + /** + * Returns the {@code fmtp} attribute of the given RTP payload type or + * {@code null} if it is not present. + */ + public String getFmtp(int type) { + return super.get("a=fmtp:" + type, ' '); + } + + /** + * Sets a RTP payload type and its {@code rtpmap} and {@code fmtp} + * attributes. If any of the attributes is {@code null}, the + * corresponding field will be removed. See + * {@link SimpleSessionDescription} for an example of its usage. + */ + public void setRtpPayload(int type, String rtpmap, String fmtp) { + String format = String.valueOf(type); + mFormats.remove(format); + mFormats.add(format); + super.set("a=rtpmap:" + format, ' ', rtpmap); + super.set("a=fmtp:" + format, ' ', fmtp); + } + + /** + * Removes a RTP payload and its {@code rtpmap} and {@code fmtp} + * attributes. + */ + public void removeRtpPayload(int type) { + removeFormat(String.valueOf(type)); + } + + private void write(StringBuilder buffer) { + buffer.append("m=").append(mType).append(' ').append(mPort); + if (mPortCount != 1) { + buffer.append('/').append(mPortCount); + } + buffer.append(' ').append(mProtocol); + for (String format : mFormats) { + buffer.append(' ').append(format); + } + buffer.append("\r\n"); + super.write(buffer); + } + } + + /** + * This class acts as a set of fields, and the size of the set is expected + * to be small. Therefore, it uses a simple list instead of maps. Each field + * has three parts: a key, a delimiter, and a value. Delimiters are special + * because they are not included in binary attributes. As a result, the + * private methods, which are the building blocks of this class, all take + * the delimiter as an argument. + */ + private static class Fields { + private final String mOrder; + private final ArrayList<String> mLines = new ArrayList<String>(); + + Fields(String order) { + mOrder = order; + } + + /** + * Returns the connection address or {@code null} if it is not present. + */ + public String getAddress() { + String address = get("c", '='); + if (address == null) { + return null; + } + String[] parts = address.split(" "); + if (parts.length != 3) { + return null; + } + int slash = parts[2].indexOf('/'); + return (slash < 0) ? parts[2] : parts[2].substring(0, slash); + } + + /** + * Sets the connection address. The field will be removed if the address + * is {@code null}. + */ + public void setAddress(String address) { + if (address != null) { + address = (address.indexOf(':') < 0 ? "IN IP4 " : "IN IP6 ") + + address; + } + set("c", '=', address); + } + + /** + * Returns the encryption method or {@code null} if it is not present. + */ + public String getEncryptionMethod() { + String encryption = get("k", '='); + if (encryption == null) { + return null; + } + int colon = encryption.indexOf(':'); + return (colon == -1) ? encryption : encryption.substring(0, colon); + } + + /** + * Returns the encryption key or {@code null} if it is not present. + */ + public String getEncryptionKey() { + String encryption = get("k", '='); + if (encryption == null) { + return null; + } + int colon = encryption.indexOf(':'); + return (colon == -1) ? null : encryption.substring(0, colon + 1); + } + + /** + * Sets the encryption method and the encryption key. The field will be + * removed if the method is {@code null}. + */ + public void setEncryption(String method, String key) { + set("k", '=', (method == null || key == null) ? + method : method + ':' + key); + } + + /** + * Returns the types of the bandwidth limits. + */ + public String[] getBandwidthTypes() { + return cut("b=", ':'); + } + + /** + * Returns the bandwidth limit of the given type or {@code -1} if it is + * not present. + */ + public int getBandwidth(String type) { + String value = get("b=" + type, ':'); + if (value != null) { + try { + return Integer.parseInt(value); + } catch (NumberFormatException e) { } + setBandwidth(type, -1); + } + return -1; + } + + /** + * Sets the bandwith limit for the given type. The field will be removed + * if the value is negative. + */ + public void setBandwidth(String type, int value) { + set("b=" + type, ':', (value < 0) ? null : String.valueOf(value)); + } + + /** + * Returns the names of all the attributes. + */ + public String[] getAttributeNames() { + return cut("a=", ':'); + } + + /** + * Returns the attribute of the given name or {@code null} if it is not + * present. + */ + public String getAttribute(String name) { + return get("a=" + name, ':'); + } + + /** + * Sets the attribute for the given name. The field will be removed if + * the value is {@code null}. To set a binary attribute, use an empty + * string as the value. + */ + public void setAttribute(String name, String value) { + set("a=" + name, ':', value); + } + + private void write(StringBuilder buffer) { + for (int i = 0; i < mOrder.length(); ++i) { + char type = mOrder.charAt(i); + for (String line : mLines) { + if (line.charAt(0) == type) { + buffer.append(line).append("\r\n"); + } + } + } + } + + /** + * Invokes {@link #set} after splitting the line into three parts. + */ + private void parse(String line) { + char type = line.charAt(0); + if (mOrder.indexOf(type) == -1) { + return; + } + char delimiter = '='; + if (line.startsWith("a=rtpmap:") || line.startsWith("a=fmtp:")) { + delimiter = ' '; + } else if (type == 'b' || type == 'a') { + delimiter = ':'; + } + int i = line.indexOf(delimiter); + if (i == -1) { + set(line, delimiter, ""); + } else { + set(line.substring(0, i), delimiter, line.substring(i + 1)); + } + } + + /** + * Finds the key with the given prefix and returns its suffix. + */ + private String[] cut(String prefix, char delimiter) { + String[] names = new String[mLines.size()]; + int length = 0; + for (String line : mLines) { + if (line.startsWith(prefix)) { + int i = line.indexOf(delimiter); + if (i == -1) { + i = line.length(); + } + names[length] = line.substring(prefix.length(), i); + ++length; + } + } + return Arrays.copyOf(names, length); + } + + /** + * Returns the index of the key. + */ + private int find(String key, char delimiter) { + int length = key.length(); + for (int i = mLines.size() - 1; i >= 0; --i) { + String line = mLines.get(i); + if (line.startsWith(key) && (line.length() == length || + line.charAt(length) == delimiter)) { + return i; + } + } + return -1; + } + + /** + * Sets the key with the value or removes the key if the value is + * {@code null}. + */ + private void set(String key, char delimiter, String value) { + int index = find(key, delimiter); + if (value != null) { + if (value.length() != 0) { + key = key + delimiter + value; + } + if (index == -1) { + mLines.add(key); + } else { + mLines.set(index, key); + } + } else if (index != -1) { + mLines.remove(index); + } + } + + /** + * Returns the value of the key. + */ + private String get(String key, char delimiter) { + int index = find(key, delimiter); + if (index == -1) { + return null; + } + String line = mLines.get(index); + int length = key.length(); + return (line.length() == length) ? "" : line.substring(length + 1); + } + } +} diff --git a/java/android/net/sip/SipAudioCall.java b/java/android/net/sip/SipAudioCall.java new file mode 100644 index 0000000..ea943e9 --- /dev/null +++ b/java/android/net/sip/SipAudioCall.java @@ -0,0 +1,1143 @@ +/* + * 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 android.net.sip; + +import android.content.Context; +import android.media.AudioManager; +import android.net.rtp.AudioCodec; +import android.net.rtp.AudioGroup; +import android.net.rtp.AudioStream; +import android.net.rtp.RtpStream; +import android.net.sip.SimpleSessionDescription.Media; +import android.net.wifi.WifiManager; +import android.os.Message; +import android.telephony.Rlog; +import android.text.TextUtils; +import java.io.IOException; +import java.net.InetAddress; +import java.net.UnknownHostException; + +/** + * Handles an Internet audio call over SIP. You can instantiate this class with {@link SipManager}, + * using {@link SipManager#makeAudioCall makeAudioCall()} and {@link SipManager#takeAudioCall + * takeAudioCall()}. + * + * <p class="note"><strong>Note:</strong> Using this class require the + * {@link android.Manifest.permission#INTERNET} and + * {@link android.Manifest.permission#USE_SIP} permissions. In addition, {@link + * #startAudio} requires the + * {@link android.Manifest.permission#RECORD_AUDIO}, + * {@link android.Manifest.permission#ACCESS_WIFI_STATE}, and + * {@link android.Manifest.permission#WAKE_LOCK} permissions; and {@link #setSpeakerMode + * setSpeakerMode()} requires the + * {@link android.Manifest.permission#MODIFY_AUDIO_SETTINGS} permission.</p> + * + * <div class="special reference"> + * <h3>Developer Guides</h3> + * <p>For more information about using SIP, read the + * <a href="{@docRoot}guide/topics/network/sip.html">Session Initiation Protocol</a> + * developer guide.</p> + * </div> + */ +public class SipAudioCall { + private static final String LOG_TAG = SipAudioCall.class.getSimpleName(); + private static final boolean DBG = true; + private static final boolean RELEASE_SOCKET = true; + private static final boolean DONT_RELEASE_SOCKET = false; + private static final int SESSION_TIMEOUT = 5; // in seconds + private static final int TRANSFER_TIMEOUT = 15; // in seconds + + /** Listener for events relating to a SIP call, such as when a call is being + * recieved ("on ringing") or a call is outgoing ("on calling"). + * <p>Many of these events are also received by {@link SipSession.Listener}.</p> + */ + public static class Listener { + /** + * Called when the call object is ready to make another call. + * The default implementation calls {@link #onChanged}. + * + * @param call the call object that is ready to make another call + */ + public void onReadyToCall(SipAudioCall call) { + onChanged(call); + } + + /** + * Called when a request is sent out to initiate a new call. + * The default implementation calls {@link #onChanged}. + * + * @param call the call object that carries out the audio call + */ + public void onCalling(SipAudioCall call) { + onChanged(call); + } + + /** + * Called when a new call comes in. + * The default implementation calls {@link #onChanged}. + * + * @param call the call object that carries out the audio call + * @param caller the SIP profile of the caller + */ + public void onRinging(SipAudioCall call, SipProfile caller) { + onChanged(call); + } + + /** + * Called when a RINGING response is received for the INVITE request + * sent. The default implementation calls {@link #onChanged}. + * + * @param call the call object that carries out the audio call + */ + public void onRingingBack(SipAudioCall call) { + onChanged(call); + } + + /** + * Called when the session is established. + * The default implementation calls {@link #onChanged}. + * + * @param call the call object that carries out the audio call + */ + public void onCallEstablished(SipAudioCall call) { + onChanged(call); + } + + /** + * Called when the session is terminated. + * The default implementation calls {@link #onChanged}. + * + * @param call the call object that carries out the audio call + */ + public void onCallEnded(SipAudioCall call) { + onChanged(call); + } + + /** + * Called when the peer is busy during session initialization. + * The default implementation calls {@link #onChanged}. + * + * @param call the call object that carries out the audio call + */ + public void onCallBusy(SipAudioCall call) { + onChanged(call); + } + + /** + * Called when the call is on hold. + * The default implementation calls {@link #onChanged}. + * + * @param call the call object that carries out the audio call + */ + public void onCallHeld(SipAudioCall call) { + onChanged(call); + } + + /** + * Called when an error occurs. The default implementation is no op. + * + * @param call the call object that carries out the audio call + * @param errorCode error code of this error + * @param errorMessage error message + * @see SipErrorCode + */ + public void onError(SipAudioCall call, int errorCode, + String errorMessage) { + // no-op + } + + /** + * Called when an event occurs and the corresponding callback is not + * overridden. The default implementation is no op. Error events are + * not re-directed to this callback and are handled in {@link #onError}. + */ + public void onChanged(SipAudioCall call) { + // no-op + } + } + + private Context mContext; + private SipProfile mLocalProfile; + private SipAudioCall.Listener mListener; + private SipSession mSipSession; + private SipSession mTransferringSession; + + private long mSessionId = System.currentTimeMillis(); + private String mPeerSd; + + private AudioStream mAudioStream; + private AudioGroup mAudioGroup; + + private boolean mInCall = false; + private boolean mMuted = false; + private boolean mHold = false; + + private WifiManager mWm; + private WifiManager.WifiLock mWifiHighPerfLock; + + private int mErrorCode = SipErrorCode.NO_ERROR; + private String mErrorMessage; + + /** + * Creates a call object with the local SIP profile. + * @param context the context for accessing system services such as + * ringtone, audio, WIFI etc + */ + public SipAudioCall(Context context, SipProfile localProfile) { + mContext = context; + mLocalProfile = localProfile; + mWm = (WifiManager) context.getSystemService(Context.WIFI_SERVICE); + } + + /** + * Sets the listener to listen to the audio call events. The method calls + * {@link #setListener setListener(listener, false)}. + * + * @param listener to listen to the audio call events of this object + * @see #setListener(Listener, boolean) + */ + public void setListener(SipAudioCall.Listener listener) { + setListener(listener, false); + } + + /** + * Sets the listener to listen to the audio call events. A + * {@link SipAudioCall} can only hold one listener at a time. Subsequent + * calls to this method override the previous listener. + * + * @param listener to listen to the audio call events of this object + * @param callbackImmediately set to true if the caller wants to be called + * back immediately on the current state + */ + public void setListener(SipAudioCall.Listener listener, + boolean callbackImmediately) { + mListener = listener; + try { + if ((listener == null) || !callbackImmediately) { + // do nothing + } else if (mErrorCode != SipErrorCode.NO_ERROR) { + listener.onError(this, mErrorCode, mErrorMessage); + } else if (mInCall) { + if (mHold) { + listener.onCallHeld(this); + } else { + listener.onCallEstablished(this); + } + } else { + int state = getState(); + switch (state) { + case SipSession.State.READY_TO_CALL: + listener.onReadyToCall(this); + break; + case SipSession.State.INCOMING_CALL: + listener.onRinging(this, getPeerProfile()); + break; + case SipSession.State.OUTGOING_CALL: + listener.onCalling(this); + break; + case SipSession.State.OUTGOING_CALL_RING_BACK: + listener.onRingingBack(this); + break; + } + } + } catch (Throwable t) { + loge("setListener()", t); + } + } + + /** + * Checks if the call is established. + * + * @return true if the call is established + */ + public boolean isInCall() { + synchronized (this) { + return mInCall; + } + } + + /** + * Checks if the call is on hold. + * + * @return true if the call is on hold + */ + public boolean isOnHold() { + synchronized (this) { + return mHold; + } + } + + /** + * Closes this object. This object is not usable after being closed. + */ + public void close() { + close(true); + } + + private synchronized void close(boolean closeRtp) { + if (closeRtp) stopCall(RELEASE_SOCKET); + + mInCall = false; + mHold = false; + mSessionId = System.currentTimeMillis(); + mErrorCode = SipErrorCode.NO_ERROR; + mErrorMessage = null; + + if (mSipSession != null) { + mSipSession.setListener(null); + mSipSession = null; + } + } + + /** + * Gets the local SIP profile. + * + * @return the local SIP profile + */ + public SipProfile getLocalProfile() { + synchronized (this) { + return mLocalProfile; + } + } + + /** + * Gets the peer's SIP profile. + * + * @return the peer's SIP profile + */ + public SipProfile getPeerProfile() { + synchronized (this) { + return (mSipSession == null) ? null : mSipSession.getPeerProfile(); + } + } + + /** + * Gets the state of the {@link SipSession} that carries this call. + * The value returned must be one of the states in {@link SipSession.State}. + * + * @return the session state + */ + public int getState() { + synchronized (this) { + if (mSipSession == null) return SipSession.State.READY_TO_CALL; + return mSipSession.getState(); + } + } + + + /** + * Gets the {@link SipSession} that carries this call. + * + * @return the session object that carries this call + * @hide + */ + public SipSession getSipSession() { + synchronized (this) { + return mSipSession; + } + } + + private synchronized void transferToNewSession() { + if (mTransferringSession == null) return; + SipSession origin = mSipSession; + mSipSession = mTransferringSession; + mTransferringSession = null; + + // stop the replaced call. + if (mAudioStream != null) { + mAudioStream.join(null); + } else { + try { + mAudioStream = new AudioStream(InetAddress.getByName( + getLocalIp())); + } catch (Throwable t) { + loge("transferToNewSession():", t); + } + } + if (origin != null) origin.endCall(); + startAudio(); + } + + private SipSession.Listener createListener() { + return new SipSession.Listener() { + @Override + public void onCalling(SipSession session) { + if (DBG) log("onCalling: session=" + session); + Listener listener = mListener; + if (listener != null) { + try { + listener.onCalling(SipAudioCall.this); + } catch (Throwable t) { + loge("onCalling():", t); + } + } + } + + @Override + public void onRingingBack(SipSession session) { + if (DBG) log("onRingingBackk: " + session); + Listener listener = mListener; + if (listener != null) { + try { + listener.onRingingBack(SipAudioCall.this); + } catch (Throwable t) { + loge("onRingingBack():", t); + } + } + } + + @Override + public void onRinging(SipSession session, + SipProfile peerProfile, String sessionDescription) { + // this callback is triggered only for reinvite. + synchronized (SipAudioCall.this) { + if ((mSipSession == null) || !mInCall + || !session.getCallId().equals( + mSipSession.getCallId())) { + // should not happen + session.endCall(); + return; + } + + // session changing request + try { + String answer = createAnswer(sessionDescription).encode(); + mSipSession.answerCall(answer, SESSION_TIMEOUT); + } catch (Throwable e) { + loge("onRinging():", e); + session.endCall(); + } + } + } + + @Override + public void onCallEstablished(SipSession session, + String sessionDescription) { + mPeerSd = sessionDescription; + if (DBG) log("onCallEstablished(): " + mPeerSd); + + // TODO: how to notify the UI that the remote party is changed + if ((mTransferringSession != null) + && (session == mTransferringSession)) { + transferToNewSession(); + return; + } + + Listener listener = mListener; + if (listener != null) { + try { + if (mHold) { + listener.onCallHeld(SipAudioCall.this); + } else { + listener.onCallEstablished(SipAudioCall.this); + } + } catch (Throwable t) { + loge("onCallEstablished(): ", t); + } + } + } + + @Override + public void onCallEnded(SipSession session) { + if (DBG) log("onCallEnded: " + session + " mSipSession:" + mSipSession); + // reset the trasnferring session if it is the one. + if (session == mTransferringSession) { + mTransferringSession = null; + return; + } + // or ignore the event if the original session is being + // transferred to the new one. + if ((mTransferringSession != null) || + (session != mSipSession)) return; + + Listener listener = mListener; + if (listener != null) { + try { + listener.onCallEnded(SipAudioCall.this); + } catch (Throwable t) { + loge("onCallEnded(): ", t); + } + } + close(); + } + + @Override + public void onCallBusy(SipSession session) { + if (DBG) log("onCallBusy: " + session); + Listener listener = mListener; + if (listener != null) { + try { + listener.onCallBusy(SipAudioCall.this); + } catch (Throwable t) { + loge("onCallBusy(): ", t); + } + } + close(false); + } + + @Override + public void onCallChangeFailed(SipSession session, int errorCode, + String message) { + if (DBG) log("onCallChangedFailed: " + message); + mErrorCode = errorCode; + mErrorMessage = message; + Listener listener = mListener; + if (listener != null) { + try { + listener.onError(SipAudioCall.this, mErrorCode, + message); + } catch (Throwable t) { + loge("onCallBusy():", t); + } + } + } + + @Override + public void onError(SipSession session, int errorCode, + String message) { + SipAudioCall.this.onError(errorCode, message); + } + + @Override + public void onRegistering(SipSession session) { + // irrelevant + } + + @Override + public void onRegistrationTimeout(SipSession session) { + // irrelevant + } + + @Override + public void onRegistrationFailed(SipSession session, int errorCode, + String message) { + // irrelevant + } + + @Override + public void onRegistrationDone(SipSession session, int duration) { + // irrelevant + } + + @Override + public void onCallTransferring(SipSession newSession, + String sessionDescription) { + if (DBG) log("onCallTransferring: mSipSession=" + + mSipSession + " newSession=" + newSession); + mTransferringSession = newSession; + try { + if (sessionDescription == null) { + newSession.makeCall(newSession.getPeerProfile(), + createOffer().encode(), TRANSFER_TIMEOUT); + } else { + String answer = createAnswer(sessionDescription).encode(); + newSession.answerCall(answer, SESSION_TIMEOUT); + } + } catch (Throwable e) { + loge("onCallTransferring()", e); + newSession.endCall(); + } + } + }; + } + + private void onError(int errorCode, String message) { + if (DBG) log("onError: " + + SipErrorCode.toString(errorCode) + ": " + message); + mErrorCode = errorCode; + mErrorMessage = message; + Listener listener = mListener; + if (listener != null) { + try { + listener.onError(this, errorCode, message); + } catch (Throwable t) { + loge("onError():", t); + } + } + synchronized (this) { + if ((errorCode == SipErrorCode.DATA_CONNECTION_LOST) + || !isInCall()) { + close(true); + } + } + } + + /** + * Attaches an incoming call to this call object. + * + * @param session the session that receives the incoming call + * @param sessionDescription the session description of the incoming call + * @throws SipException if the SIP service fails to attach this object to + * the session or VOIP API is not supported by the device + * @see SipManager#isVoipSupported + */ + public void attachCall(SipSession session, String sessionDescription) + throws SipException { + if (!SipManager.isVoipSupported(mContext)) { + throw new SipException("VOIP API is not supported"); + } + + synchronized (this) { + mSipSession = session; + mPeerSd = sessionDescription; + if (DBG) log("attachCall(): " + mPeerSd); + try { + session.setListener(createListener()); + } catch (Throwable e) { + loge("attachCall()", e); + throwSipException(e); + } + } + } + + /** + * Initiates an audio call to the specified profile. The attempt will be + * timed out if the call is not established within {@code timeout} seconds + * and {@link Listener#onError onError(SipAudioCall, SipErrorCode.TIME_OUT, String)} + * will be called. + * + * @param peerProfile the SIP profile to make the call to + * @param sipSession the {@link SipSession} for carrying out the call + * @param timeout the timeout value in seconds. Default value (defined by + * SIP protocol) is used if {@code timeout} is zero or negative. + * @see Listener#onError + * @throws SipException if the SIP service fails to create a session for the + * call or VOIP API is not supported by the device + * @see SipManager#isVoipSupported + */ + public void makeCall(SipProfile peerProfile, SipSession sipSession, + int timeout) throws SipException { + if (DBG) log("makeCall: " + peerProfile + " session=" + sipSession + " timeout=" + timeout); + if (!SipManager.isVoipSupported(mContext)) { + throw new SipException("VOIP API is not supported"); + } + + synchronized (this) { + mSipSession = sipSession; + try { + mAudioStream = new AudioStream(InetAddress.getByName( + getLocalIp())); + sipSession.setListener(createListener()); + sipSession.makeCall(peerProfile, createOffer().encode(), + timeout); + } catch (IOException e) { + loge("makeCall:", e); + throw new SipException("makeCall()", e); + } + } + } + + /** + * Ends a call. + * @throws SipException if the SIP service fails to end the call + */ + public void endCall() throws SipException { + if (DBG) log("endCall: mSipSession" + mSipSession); + synchronized (this) { + stopCall(RELEASE_SOCKET); + mInCall = false; + + // perform the above local ops first and then network op + if (mSipSession != null) mSipSession.endCall(); + } + } + + /** + * Puts a call on hold. When succeeds, {@link Listener#onCallHeld} is + * called. The attempt will be timed out if the call is not established + * within {@code timeout} seconds and + * {@link Listener#onError onError(SipAudioCall, SipErrorCode.TIME_OUT, String)} + * will be called. + * + * @param timeout the timeout value in seconds. Default value (defined by + * SIP protocol) is used if {@code timeout} is zero or negative. + * @see Listener#onError + * @throws SipException if the SIP service fails to hold the call + */ + public void holdCall(int timeout) throws SipException { + if (DBG) log("holdCall: mSipSession" + mSipSession + " timeout=" + timeout); + synchronized (this) { + if (mHold) return; + if (mSipSession == null) { + loge("holdCall:"); + throw new SipException("Not in a call to hold call"); + } + mSipSession.changeCall(createHoldOffer().encode(), timeout); + mHold = true; + setAudioGroupMode(); + } + } + + /** + * Answers a call. The attempt will be timed out if the call is not + * established within {@code timeout} seconds and + * {@link Listener#onError onError(SipAudioCall, SipErrorCode.TIME_OUT, String)} + * will be called. + * + * @param timeout the timeout value in seconds. Default value (defined by + * SIP protocol) is used if {@code timeout} is zero or negative. + * @see Listener#onError + * @throws SipException if the SIP service fails to answer the call + */ + public void answerCall(int timeout) throws SipException { + if (DBG) log("answerCall: mSipSession" + mSipSession + " timeout=" + timeout); + synchronized (this) { + if (mSipSession == null) { + throw new SipException("No call to answer"); + } + try { + mAudioStream = new AudioStream(InetAddress.getByName( + getLocalIp())); + mSipSession.answerCall(createAnswer(mPeerSd).encode(), timeout); + } catch (IOException e) { + loge("answerCall:", e); + throw new SipException("answerCall()", e); + } + } + } + + /** + * Continues a call that's on hold. When succeeds, + * {@link Listener#onCallEstablished} is called. The attempt will be timed + * out if the call is not established within {@code timeout} seconds and + * {@link Listener#onError onError(SipAudioCall, SipErrorCode.TIME_OUT, String)} + * will be called. + * + * @param timeout the timeout value in seconds. Default value (defined by + * SIP protocol) is used if {@code timeout} is zero or negative. + * @see Listener#onError + * @throws SipException if the SIP service fails to unhold the call + */ + public void continueCall(int timeout) throws SipException { + if (DBG) log("continueCall: mSipSession" + mSipSession + " timeout=" + timeout); + synchronized (this) { + if (!mHold) return; + mSipSession.changeCall(createContinueOffer().encode(), timeout); + mHold = false; + setAudioGroupMode(); + } + } + + private SimpleSessionDescription createOffer() { + SimpleSessionDescription offer = + new SimpleSessionDescription(mSessionId, getLocalIp()); + AudioCodec[] codecs = AudioCodec.getCodecs(); + Media media = offer.newMedia( + "audio", mAudioStream.getLocalPort(), 1, "RTP/AVP"); + for (AudioCodec codec : AudioCodec.getCodecs()) { + media.setRtpPayload(codec.type, codec.rtpmap, codec.fmtp); + } + media.setRtpPayload(127, "telephone-event/8000", "0-15"); + if (DBG) log("createOffer: offer=" + offer); + return offer; + } + + private SimpleSessionDescription createAnswer(String offerSd) { + if (TextUtils.isEmpty(offerSd)) return createOffer(); + SimpleSessionDescription offer = + new SimpleSessionDescription(offerSd); + SimpleSessionDescription answer = + new SimpleSessionDescription(mSessionId, getLocalIp()); + AudioCodec codec = null; + for (Media media : offer.getMedia()) { + if ((codec == null) && (media.getPort() > 0) + && "audio".equals(media.getType()) + && "RTP/AVP".equals(media.getProtocol())) { + // Find the first audio codec we supported. + for (int type : media.getRtpPayloadTypes()) { + codec = AudioCodec.getCodec(type, media.getRtpmap(type), + media.getFmtp(type)); + if (codec != null) { + break; + } + } + if (codec != null) { + Media reply = answer.newMedia( + "audio", mAudioStream.getLocalPort(), 1, "RTP/AVP"); + reply.setRtpPayload(codec.type, codec.rtpmap, codec.fmtp); + + // Check if DTMF is supported in the same media. + for (int type : media.getRtpPayloadTypes()) { + String rtpmap = media.getRtpmap(type); + if ((type != codec.type) && (rtpmap != null) + && rtpmap.startsWith("telephone-event")) { + reply.setRtpPayload( + type, rtpmap, media.getFmtp(type)); + } + } + + // Handle recvonly and sendonly. + if (media.getAttribute("recvonly") != null) { + answer.setAttribute("sendonly", ""); + } else if(media.getAttribute("sendonly") != null) { + answer.setAttribute("recvonly", ""); + } else if(offer.getAttribute("recvonly") != null) { + answer.setAttribute("sendonly", ""); + } else if(offer.getAttribute("sendonly") != null) { + answer.setAttribute("recvonly", ""); + } + continue; + } + } + // Reject the media. + Media reply = answer.newMedia( + media.getType(), 0, 1, media.getProtocol()); + for (String format : media.getFormats()) { + reply.setFormat(format, null); + } + } + if (codec == null) { + loge("createAnswer: no suitable codes"); + throw new IllegalStateException("Reject SDP: no suitable codecs"); + } + if (DBG) log("createAnswer: answer=" + answer); + return answer; + } + + private SimpleSessionDescription createHoldOffer() { + SimpleSessionDescription offer = createContinueOffer(); + offer.setAttribute("sendonly", ""); + if (DBG) log("createHoldOffer: offer=" + offer); + return offer; + } + + private SimpleSessionDescription createContinueOffer() { + if (DBG) log("createContinueOffer"); + SimpleSessionDescription offer = + new SimpleSessionDescription(mSessionId, getLocalIp()); + Media media = offer.newMedia( + "audio", mAudioStream.getLocalPort(), 1, "RTP/AVP"); + AudioCodec codec = mAudioStream.getCodec(); + media.setRtpPayload(codec.type, codec.rtpmap, codec.fmtp); + int dtmfType = mAudioStream.getDtmfType(); + if (dtmfType != -1) { + media.setRtpPayload(dtmfType, "telephone-event/8000", "0-15"); + } + return offer; + } + + private void grabWifiHighPerfLock() { + if (mWifiHighPerfLock == null) { + if (DBG) log("grabWifiHighPerfLock:"); + mWifiHighPerfLock = ((WifiManager) + mContext.getSystemService(Context.WIFI_SERVICE)) + .createWifiLock(WifiManager.WIFI_MODE_FULL_HIGH_PERF, LOG_TAG); + mWifiHighPerfLock.acquire(); + } + } + + private void releaseWifiHighPerfLock() { + if (mWifiHighPerfLock != null) { + if (DBG) log("releaseWifiHighPerfLock:"); + mWifiHighPerfLock.release(); + mWifiHighPerfLock = null; + } + } + + private boolean isWifiOn() { + return (mWm.getConnectionInfo().getBSSID() == null) ? false : true; + } + + /** Toggles mute. */ + public void toggleMute() { + synchronized (this) { + mMuted = !mMuted; + setAudioGroupMode(); + } + } + + /** + * Checks if the call is muted. + * + * @return true if the call is muted + */ + public boolean isMuted() { + synchronized (this) { + return mMuted; + } + } + + /** + * Puts the device to speaker mode. + * <p class="note"><strong>Note:</strong> Requires the + * {@link android.Manifest.permission#MODIFY_AUDIO_SETTINGS} permission.</p> + * + * @param speakerMode set true to enable speaker mode; false to disable + */ + public void setSpeakerMode(boolean speakerMode) { + synchronized (this) { + ((AudioManager) mContext.getSystemService(Context.AUDIO_SERVICE)) + .setSpeakerphoneOn(speakerMode); + setAudioGroupMode(); + } + } + + private boolean isSpeakerOn() { + return ((AudioManager) mContext.getSystemService(Context.AUDIO_SERVICE)) + .isSpeakerphoneOn(); + } + + /** + * Sends a DTMF code. According to <a href="http://tools.ietf.org/html/rfc2833">RFC 2883</a>, + * event 0--9 maps to decimal + * value 0--9, '*' to 10, '#' to 11, event 'A'--'D' to 12--15, and event + * flash to 16. Currently, event flash is not supported. + * + * @param code the DTMF code to send. Value 0 to 15 (inclusive) are valid + * inputs. + */ + public void sendDtmf(int code) { + sendDtmf(code, null); + } + + /** + * Sends a DTMF code. According to <a href="http://tools.ietf.org/html/rfc2833">RFC 2883</a>, + * event 0--9 maps to decimal + * value 0--9, '*' to 10, '#' to 11, event 'A'--'D' to 12--15, and event + * flash to 16. Currently, event flash is not supported. + * + * @param code the DTMF code to send. Value 0 to 15 (inclusive) are valid + * inputs. + * @param result the result message to send when done + */ + public void sendDtmf(int code, Message result) { + synchronized (this) { + AudioGroup audioGroup = getAudioGroup(); + if ((audioGroup != null) && (mSipSession != null) + && (SipSession.State.IN_CALL == getState())) { + if (DBG) log("sendDtmf: code=" + code + " result=" + result); + audioGroup.sendDtmf(code); + } + if (result != null) result.sendToTarget(); + } + } + + /** + * Gets the {@link AudioStream} object used in this call. The object + * represents the RTP stream that carries the audio data to and from the + * peer. The object may not be created before the call is established. And + * it is undefined after the call ends or the {@link #close} method is + * called. + * + * @return the {@link AudioStream} object or null if the RTP stream has not + * yet been set up + * @hide + */ + public AudioStream getAudioStream() { + synchronized (this) { + return mAudioStream; + } + } + + /** + * Gets the {@link AudioGroup} object which the {@link AudioStream} object + * joins. The group object may not exist before the call is established. + * Also, the {@code AudioStream} may change its group during a call (e.g., + * after the call is held/un-held). Finally, the {@code AudioGroup} object + * returned by this method is undefined after the call ends or the + * {@link #close} method is called. If a group object is set by + * {@link #setAudioGroup(AudioGroup)}, then this method returns that object. + * + * @return the {@link AudioGroup} object or null if the RTP stream has not + * yet been set up + * @see #getAudioStream + * @hide + */ + public AudioGroup getAudioGroup() { + synchronized (this) { + if (mAudioGroup != null) return mAudioGroup; + return ((mAudioStream == null) ? null : mAudioStream.getGroup()); + } + } + + /** + * Sets the {@link AudioGroup} object which the {@link AudioStream} object + * joins. If {@code audioGroup} is null, then the {@code AudioGroup} object + * will be dynamically created when needed. Note that the mode of the + * {@code AudioGroup} is not changed according to the audio settings (i.e., + * hold, mute, speaker phone) of this object. This is mainly used to merge + * multiple {@code SipAudioCall} objects to form a conference call. The + * settings of the first object (that merges others) override others'. + * + * @see #getAudioStream + * @hide + */ + public void setAudioGroup(AudioGroup group) { + synchronized (this) { + if (DBG) log("setAudioGroup: group=" + group); + if ((mAudioStream != null) && (mAudioStream.getGroup() != null)) { + mAudioStream.join(group); + } + mAudioGroup = group; + } + } + + /** + * Starts the audio for the established call. This method should be called + * after {@link Listener#onCallEstablished} is called. + * <p class="note"><strong>Note:</strong> Requires the + * {@link android.Manifest.permission#RECORD_AUDIO}, + * {@link android.Manifest.permission#ACCESS_WIFI_STATE} and + * {@link android.Manifest.permission#WAKE_LOCK} permissions.</p> + */ + public void startAudio() { + try { + startAudioInternal(); + } catch (UnknownHostException e) { + onError(SipErrorCode.PEER_NOT_REACHABLE, e.getMessage()); + } catch (Throwable e) { + onError(SipErrorCode.CLIENT_ERROR, e.getMessage()); + } + } + + private synchronized void startAudioInternal() throws UnknownHostException { + if (DBG) loge("startAudioInternal: mPeerSd=" + mPeerSd); + if (mPeerSd == null) { + throw new IllegalStateException("mPeerSd = null"); + } + + stopCall(DONT_RELEASE_SOCKET); + mInCall = true; + + // Run exact the same logic in createAnswer() to setup mAudioStream. + SimpleSessionDescription offer = + new SimpleSessionDescription(mPeerSd); + AudioStream stream = mAudioStream; + AudioCodec codec = null; + for (Media media : offer.getMedia()) { + if ((codec == null) && (media.getPort() > 0) + && "audio".equals(media.getType()) + && "RTP/AVP".equals(media.getProtocol())) { + // Find the first audio codec we supported. + for (int type : media.getRtpPayloadTypes()) { + codec = AudioCodec.getCodec( + type, media.getRtpmap(type), media.getFmtp(type)); + if (codec != null) { + break; + } + } + + if (codec != null) { + // Associate with the remote host. + String address = media.getAddress(); + if (address == null) { + address = offer.getAddress(); + } + stream.associate(InetAddress.getByName(address), + media.getPort()); + + stream.setDtmfType(-1); + stream.setCodec(codec); + // Check if DTMF is supported in the same media. + for (int type : media.getRtpPayloadTypes()) { + String rtpmap = media.getRtpmap(type); + if ((type != codec.type) && (rtpmap != null) + && rtpmap.startsWith("telephone-event")) { + stream.setDtmfType(type); + } + } + + // Handle recvonly and sendonly. + if (mHold) { + stream.setMode(RtpStream.MODE_NORMAL); + } else if (media.getAttribute("recvonly") != null) { + stream.setMode(RtpStream.MODE_SEND_ONLY); + } else if(media.getAttribute("sendonly") != null) { + stream.setMode(RtpStream.MODE_RECEIVE_ONLY); + } else if(offer.getAttribute("recvonly") != null) { + stream.setMode(RtpStream.MODE_SEND_ONLY); + } else if(offer.getAttribute("sendonly") != null) { + stream.setMode(RtpStream.MODE_RECEIVE_ONLY); + } else { + stream.setMode(RtpStream.MODE_NORMAL); + } + break; + } + } + } + if (codec == null) { + throw new IllegalStateException("Reject SDP: no suitable codecs"); + } + + if (isWifiOn()) grabWifiHighPerfLock(); + + // AudioGroup logic: + AudioGroup audioGroup = getAudioGroup(); + if (mHold) { + // don't create an AudioGroup here; doing so will fail if + // there's another AudioGroup out there that's active + } else { + if (audioGroup == null) audioGroup = new AudioGroup(); + stream.join(audioGroup); + } + setAudioGroupMode(); + } + + // set audio group mode based on current audio configuration + private void setAudioGroupMode() { + AudioGroup audioGroup = getAudioGroup(); + if (DBG) log("setAudioGroupMode: audioGroup=" + audioGroup); + if (audioGroup != null) { + if (mHold) { + audioGroup.setMode(AudioGroup.MODE_ON_HOLD); + } else if (mMuted) { + audioGroup.setMode(AudioGroup.MODE_MUTED); + } else if (isSpeakerOn()) { + audioGroup.setMode(AudioGroup.MODE_ECHO_SUPPRESSION); + } else { + audioGroup.setMode(AudioGroup.MODE_NORMAL); + } + } + } + + private void stopCall(boolean releaseSocket) { + if (DBG) log("stopCall: releaseSocket=" + releaseSocket); + releaseWifiHighPerfLock(); + if (mAudioStream != null) { + mAudioStream.join(null); + + if (releaseSocket) { + mAudioStream.release(); + mAudioStream = null; + } + } + } + + private String getLocalIp() { + return mSipSession.getLocalIp(); + } + + private void throwSipException(Throwable throwable) throws SipException { + if (throwable instanceof SipException) { + throw (SipException) throwable; + } else { + throw new SipException("", throwable); + } + } + + private void log(String s) { + Rlog.d(LOG_TAG, s); + } + + private void loge(String s) { + Rlog.e(LOG_TAG, s); + } + + private void loge(String s, Throwable t) { + Rlog.e(LOG_TAG, s, t); + } +} diff --git a/java/android/net/sip/SipErrorCode.java b/java/android/net/sip/SipErrorCode.java new file mode 100644 index 0000000..509728f --- /dev/null +++ b/java/android/net/sip/SipErrorCode.java @@ -0,0 +1,101 @@ +/* + * 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 android.net.sip; + +/** + * Defines error codes returned during SIP actions. For example, during + * {@link SipRegistrationListener#onRegistrationFailed onRegistrationFailed()}, + * {@link SipSession.Listener#onError onError()}, + * {@link SipSession.Listener#onCallChangeFailed onCallChangeFailed()} and + * {@link SipSession.Listener#onRegistrationFailed onRegistrationFailed()}. + */ +public class SipErrorCode { + /** Not an error. */ + public static final int NO_ERROR = 0; + + /** When some socket error occurs. */ + public static final int SOCKET_ERROR = -1; + + /** When server responds with an error. */ + public static final int SERVER_ERROR = -2; + + /** When transaction is terminated unexpectedly. */ + public static final int TRANSACTION_TERMINTED = -3; + + /** When some error occurs on the device, possibly due to a bug. */ + public static final int CLIENT_ERROR = -4; + + /** When the transaction gets timed out. */ + public static final int TIME_OUT = -5; + + /** When the remote URI is not valid. */ + public static final int INVALID_REMOTE_URI = -6; + + /** When the peer is not reachable. */ + public static final int PEER_NOT_REACHABLE = -7; + + /** When invalid credentials are provided. */ + public static final int INVALID_CREDENTIALS = -8; + + /** The client is in a transaction and cannot initiate a new one. */ + public static final int IN_PROGRESS = -9; + + /** When data connection is lost. */ + public static final int DATA_CONNECTION_LOST = -10; + + /** Cross-domain authentication required. */ + public static final int CROSS_DOMAIN_AUTHENTICATION = -11; + + /** When the server is not reachable. */ + public static final int SERVER_UNREACHABLE = -12; + + public static String toString(int errorCode) { + switch (errorCode) { + case NO_ERROR: + return "NO_ERROR"; + case SOCKET_ERROR: + return "SOCKET_ERROR"; + case SERVER_ERROR: + return "SERVER_ERROR"; + case TRANSACTION_TERMINTED: + return "TRANSACTION_TERMINTED"; + case CLIENT_ERROR: + return "CLIENT_ERROR"; + case TIME_OUT: + return "TIME_OUT"; + case INVALID_REMOTE_URI: + return "INVALID_REMOTE_URI"; + case PEER_NOT_REACHABLE: + return "PEER_NOT_REACHABLE"; + case INVALID_CREDENTIALS: + return "INVALID_CREDENTIALS"; + case IN_PROGRESS: + return "IN_PROGRESS"; + case DATA_CONNECTION_LOST: + return "DATA_CONNECTION_LOST"; + case CROSS_DOMAIN_AUTHENTICATION: + return "CROSS_DOMAIN_AUTHENTICATION"; + case SERVER_UNREACHABLE: + return "SERVER_UNREACHABLE"; + default: + return "UNKNOWN"; + } + } + + private SipErrorCode() { + } +} diff --git a/java/android/net/sip/SipException.java b/java/android/net/sip/SipException.java new file mode 100644 index 0000000..0339395 --- /dev/null +++ b/java/android/net/sip/SipException.java @@ -0,0 +1,37 @@ +/* + * 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 android.net.sip; + +/** + * Indicates a general SIP-related exception. + */ +public class SipException extends Exception { + public SipException() { + } + + public SipException(String message) { + super(message); + } + + public SipException(String message, Throwable cause) { + // we want to eliminate the dependency on javax.sip.SipException + super(message, ((cause instanceof javax.sip.SipException) + && (cause.getCause() != null)) + ? cause.getCause() + : cause); + } +} diff --git a/java/android/net/sip/SipManager.java b/java/android/net/sip/SipManager.java new file mode 100644 index 0000000..a94232a --- /dev/null +++ b/java/android/net/sip/SipManager.java @@ -0,0 +1,622 @@ +/* + * 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 android.net.sip; + +import android.app.PendingIntent; +import android.content.Context; +import android.content.Intent; +import android.content.pm.PackageManager; +import android.os.IBinder; +import android.os.RemoteException; +import android.os.ServiceManager; +import android.telephony.Rlog; + +import java.text.ParseException; + +/** + * Provides APIs for SIP tasks, such as initiating SIP connections, and provides access to related + * SIP services. This class is the starting point for any SIP actions. You can acquire an instance + * of it with {@link #newInstance newInstance()}.</p> + * <p>The APIs in this class allows you to:</p> + * <ul> + * <li>Create a {@link SipSession} to get ready for making calls or listen for incoming calls. See + * {@link #createSipSession createSipSession()} and {@link #getSessionFor getSessionFor()}.</li> + * <li>Initiate and receive generic SIP calls or audio-only SIP calls. Generic SIP calls may + * be video, audio, or other, and are initiated with {@link #open open()}. Audio-only SIP calls + * should be handled with a {@link SipAudioCall}, which you can acquire with {@link + * #makeAudioCall makeAudioCall()} and {@link #takeAudioCall takeAudioCall()}.</li> + * <li>Register and unregister with a SIP service provider, with + * {@link #register register()} and {@link #unregister unregister()}.</li> + * <li>Verify session connectivity, with {@link #isOpened isOpened()} and + * {@link #isRegistered isRegistered()}.</li> + * </ul> + * <p class="note"><strong>Note:</strong> Not all Android-powered devices support VOIP calls using + * SIP. You should always call {@link android.net.sip.SipManager#isVoipSupported + * isVoipSupported()} to verify that the device supports VOIP calling and {@link + * android.net.sip.SipManager#isApiSupported isApiSupported()} to verify that the device supports + * the SIP APIs. Your application must also request the {@link + * android.Manifest.permission#INTERNET} and {@link android.Manifest.permission#USE_SIP} + * permissions.</p> + * + * <div class="special reference"> + * <h3>Developer Guides</h3> + * <p>For more information about using SIP, read the + * <a href="{@docRoot}guide/topics/network/sip.html">Session Initiation Protocol</a> + * developer guide.</p> + * </div> + */ +public class SipManager { + /** + * The result code to be sent back with the incoming call + * {@link PendingIntent}. + * @see #open(SipProfile, PendingIntent, SipRegistrationListener) + */ + public static final int INCOMING_CALL_RESULT_CODE = 101; + + /** + * Key to retrieve the call ID from an incoming call intent. + * @see #open(SipProfile, PendingIntent, SipRegistrationListener) + */ + public static final String EXTRA_CALL_ID = "android:sipCallID"; + + /** + * Key to retrieve the offered session description from an incoming call + * intent. + * @see #open(SipProfile, PendingIntent, SipRegistrationListener) + */ + public static final String EXTRA_OFFER_SD = "android:sipOfferSD"; + + /** + * Action to broadcast when SipService is up. + * Internal use only. + * @hide + */ + public static final String ACTION_SIP_SERVICE_UP = + "android.net.sip.SIP_SERVICE_UP"; + /** + * Action string for the incoming call intent for the Phone app. + * Internal use only. + * @hide + */ + public static final String ACTION_SIP_INCOMING_CALL = + "com.android.phone.SIP_INCOMING_CALL"; + /** + * Action string for the add-phone intent. + * Internal use only. + * @hide + */ + public static final String ACTION_SIP_ADD_PHONE = + "com.android.phone.SIP_ADD_PHONE"; + /** + * Action string for the remove-phone intent. + * Internal use only. + * @hide + */ + public static final String ACTION_SIP_REMOVE_PHONE = + "com.android.phone.SIP_REMOVE_PHONE"; + /** + * Part of the ACTION_SIP_ADD_PHONE and ACTION_SIP_REMOVE_PHONE intents. + * Internal use only. + * @hide + */ + public static final String EXTRA_LOCAL_URI = "android:localSipUri"; + + private static final String TAG = "SipManager"; + + private ISipService mSipService; + private Context mContext; + + /** + * Creates a manager instance. Returns null if SIP API is not supported. + * + * @param context application context for creating the manager object + * @return the manager instance or null if SIP API is not supported + */ + public static SipManager newInstance(Context context) { + return (isApiSupported(context) ? new SipManager(context) : null); + } + + /** + * Returns true if the SIP API is supported by the system. + */ + public static boolean isApiSupported(Context context) { + return context.getPackageManager().hasSystemFeature( + PackageManager.FEATURE_SIP); + } + + /** + * Returns true if the system supports SIP-based VOIP API. + */ + public static boolean isVoipSupported(Context context) { + return context.getPackageManager().hasSystemFeature( + PackageManager.FEATURE_SIP_VOIP) && isApiSupported(context); + } + + /** + * Returns true if SIP is only available on WIFI. + */ + public static boolean isSipWifiOnly(Context context) { + return context.getResources().getBoolean( + com.android.internal.R.bool.config_sip_wifi_only); + } + + private SipManager(Context context) { + mContext = context; + createSipService(); + } + + private void createSipService() { + IBinder b = ServiceManager.getService(Context.SIP_SERVICE); + mSipService = ISipService.Stub.asInterface(b); + } + + /** + * Opens the profile for making generic SIP calls. The caller may make subsequent calls + * through {@link #makeAudioCall}. If one also wants to receive calls on the + * profile, use + * {@link #open(SipProfile, PendingIntent, SipRegistrationListener)} + * instead. + * + * @param localProfile the SIP profile to make calls from + * @throws SipException if the profile contains incorrect settings or + * calling the SIP service results in an error + */ + public void open(SipProfile localProfile) throws SipException { + try { + mSipService.open(localProfile); + } catch (RemoteException e) { + throw new SipException("open()", e); + } + } + + /** + * Opens the profile for making calls and/or receiving generic SIP calls. The caller may + * make subsequent calls through {@link #makeAudioCall}. If the + * auto-registration option is enabled in the profile, the SIP service + * will register the profile to the corresponding SIP provider periodically + * in order to receive calls from the provider. When the SIP service + * receives a new call, it will send out an intent with the provided action + * string. The intent contains a call ID extra and an offer session + * description string extra. Use {@link #getCallId} and + * {@link #getOfferSessionDescription} to retrieve those extras. + * + * @param localProfile the SIP profile to receive incoming calls for + * @param incomingCallPendingIntent When an incoming call is received, the + * SIP service will call + * {@link PendingIntent#send(Context, int, Intent)} to send back the + * intent to the caller with {@link #INCOMING_CALL_RESULT_CODE} as the + * result code and the intent to fill in the call ID and session + * description information. It cannot be null. + * @param listener to listen to registration events; can be null + * @see #getCallId + * @see #getOfferSessionDescription + * @see #takeAudioCall + * @throws NullPointerException if {@code incomingCallPendingIntent} is null + * @throws SipException if the profile contains incorrect settings or + * calling the SIP service results in an error + * @see #isIncomingCallIntent + * @see #getCallId + * @see #getOfferSessionDescription + */ + public void open(SipProfile localProfile, + PendingIntent incomingCallPendingIntent, + SipRegistrationListener listener) throws SipException { + if (incomingCallPendingIntent == null) { + throw new NullPointerException( + "incomingCallPendingIntent cannot be null"); + } + try { + mSipService.open3(localProfile, incomingCallPendingIntent, + createRelay(listener, localProfile.getUriString())); + } catch (RemoteException e) { + throw new SipException("open()", e); + } + } + + /** + * Sets the listener to listen to registration events. No effect if the + * profile has not been opened to receive calls (see + * {@link #open(SipProfile, PendingIntent, SipRegistrationListener)}). + * + * @param localProfileUri the URI of the profile + * @param listener to listen to registration events; can be null + * @throws SipException if calling the SIP service results in an error + */ + public void setRegistrationListener(String localProfileUri, + SipRegistrationListener listener) throws SipException { + try { + mSipService.setRegistrationListener( + localProfileUri, createRelay(listener, localProfileUri)); + } catch (RemoteException e) { + throw new SipException("setRegistrationListener()", e); + } + } + + /** + * Closes the specified profile to not make/receive calls. All the resources + * that were allocated to the profile are also released. + * + * @param localProfileUri the URI of the profile to close + * @throws SipException if calling the SIP service results in an error + */ + public void close(String localProfileUri) throws SipException { + try { + mSipService.close(localProfileUri); + } catch (RemoteException e) { + throw new SipException("close()", e); + } + } + + /** + * Checks if the specified profile is opened in the SIP service for + * making and/or receiving calls. + * + * @param localProfileUri the URI of the profile in question + * @return true if the profile is enabled to receive calls + * @throws SipException if calling the SIP service results in an error + */ + public boolean isOpened(String localProfileUri) throws SipException { + try { + return mSipService.isOpened(localProfileUri); + } catch (RemoteException e) { + throw new SipException("isOpened()", e); + } + } + + /** + * Checks if the SIP service has successfully registered the profile to the + * SIP provider (specified in the profile) for receiving calls. Returning + * true from this method also implies the profile is opened + * ({@link #isOpened}). + * + * @param localProfileUri the URI of the profile in question + * @return true if the profile is registered to the SIP provider; false if + * the profile has not been opened in the SIP service or the SIP + * service has not yet successfully registered the profile to the SIP + * provider + * @throws SipException if calling the SIP service results in an error + */ + public boolean isRegistered(String localProfileUri) throws SipException { + try { + return mSipService.isRegistered(localProfileUri); + } catch (RemoteException e) { + throw new SipException("isRegistered()", e); + } + } + + /** + * Creates a {@link SipAudioCall} to make a call. The attempt will be timed + * out if the call is not established within {@code timeout} seconds and + * {@link SipAudioCall.Listener#onError onError(SipAudioCall, SipErrorCode.TIME_OUT, String)} + * will be called. + * + * @param localProfile the SIP profile to make the call from + * @param peerProfile the SIP profile to make the call to + * @param listener to listen to the call events from {@link SipAudioCall}; + * can be null + * @param timeout the timeout value in seconds. Default value (defined by + * SIP protocol) is used if {@code timeout} is zero or negative. + * @return a {@link SipAudioCall} object + * @throws SipException if calling the SIP service results in an error or + * VOIP API is not supported by the device + * @see SipAudioCall.Listener#onError + * @see #isVoipSupported + */ + public SipAudioCall makeAudioCall(SipProfile localProfile, + SipProfile peerProfile, SipAudioCall.Listener listener, int timeout) + throws SipException { + if (!isVoipSupported(mContext)) { + throw new SipException("VOIP API is not supported"); + } + SipAudioCall call = new SipAudioCall(mContext, localProfile); + call.setListener(listener); + SipSession s = createSipSession(localProfile, null); + call.makeCall(peerProfile, s, timeout); + return call; + } + + /** + * Creates a {@link SipAudioCall} to make an audio call. The attempt will be + * timed out if the call is not established within {@code timeout} seconds + * and + * {@link SipAudioCall.Listener#onError onError(SipAudioCall, SipErrorCode.TIME_OUT, String)} + * will be called. + * + * @param localProfileUri URI of the SIP profile to make the call from + * @param peerProfileUri URI of the SIP profile to make the call to + * @param listener to listen to the call events from {@link SipAudioCall}; + * can be null + * @param timeout the timeout value in seconds. Default value (defined by + * SIP protocol) is used if {@code timeout} is zero or negative. + * @return a {@link SipAudioCall} object + * @throws SipException if calling the SIP service results in an error or + * VOIP API is not supported by the device + * @see SipAudioCall.Listener#onError + * @see #isVoipSupported + */ + public SipAudioCall makeAudioCall(String localProfileUri, + String peerProfileUri, SipAudioCall.Listener listener, int timeout) + throws SipException { + if (!isVoipSupported(mContext)) { + throw new SipException("VOIP API is not supported"); + } + try { + return makeAudioCall( + new SipProfile.Builder(localProfileUri).build(), + new SipProfile.Builder(peerProfileUri).build(), listener, + timeout); + } catch (ParseException e) { + throw new SipException("build SipProfile", e); + } + } + + /** + * Creates a {@link SipAudioCall} to take an incoming call. Before the call + * is returned, the listener will receive a + * {@link SipAudioCall.Listener#onRinging} + * callback. + * + * @param incomingCallIntent the incoming call broadcast intent + * @param listener to listen to the call events from {@link SipAudioCall}; + * can be null + * @return a {@link SipAudioCall} object + * @throws SipException if calling the SIP service results in an error + */ + public SipAudioCall takeAudioCall(Intent incomingCallIntent, + SipAudioCall.Listener listener) throws SipException { + if (incomingCallIntent == null) { + throw new SipException("Cannot retrieve session with null intent"); + } + + String callId = getCallId(incomingCallIntent); + if (callId == null) { + throw new SipException("Call ID missing in incoming call intent"); + } + + String offerSd = getOfferSessionDescription(incomingCallIntent); + if (offerSd == null) { + throw new SipException("Session description missing in incoming " + + "call intent"); + } + + try { + ISipSession session = mSipService.getPendingSession(callId); + if (session == null) { + throw new SipException("No pending session for the call"); + } + SipAudioCall call = new SipAudioCall( + mContext, session.getLocalProfile()); + call.attachCall(new SipSession(session), offerSd); + call.setListener(listener); + return call; + } catch (Throwable t) { + throw new SipException("takeAudioCall()", t); + } + } + + /** + * Checks if the intent is an incoming call broadcast intent. + * + * @param intent the intent in question + * @return true if the intent is an incoming call broadcast intent + */ + public static boolean isIncomingCallIntent(Intent intent) { + if (intent == null) return false; + String callId = getCallId(intent); + String offerSd = getOfferSessionDescription(intent); + return ((callId != null) && (offerSd != null)); + } + + /** + * Gets the call ID from the specified incoming call broadcast intent. + * + * @param incomingCallIntent the incoming call broadcast intent + * @return the call ID or null if the intent does not contain it + */ + public static String getCallId(Intent incomingCallIntent) { + return incomingCallIntent.getStringExtra(EXTRA_CALL_ID); + } + + /** + * Gets the offer session description from the specified incoming call + * broadcast intent. + * + * @param incomingCallIntent the incoming call broadcast intent + * @return the offer session description or null if the intent does not + * have it + */ + public static String getOfferSessionDescription(Intent incomingCallIntent) { + return incomingCallIntent.getStringExtra(EXTRA_OFFER_SD); + } + + /** + * Creates an incoming call broadcast intent. + * + * @param callId the call ID of the incoming call + * @param sessionDescription the session description of the incoming call + * @return the incoming call intent + * @hide + */ + public static Intent createIncomingCallBroadcast(String callId, + String sessionDescription) { + Intent intent = new Intent(); + intent.putExtra(EXTRA_CALL_ID, callId); + intent.putExtra(EXTRA_OFFER_SD, sessionDescription); + return intent; + } + + /** + * Manually registers the profile to the corresponding SIP provider for + * receiving calls. + * {@link #open(SipProfile, PendingIntent, SipRegistrationListener)} is + * still needed to be called at least once in order for the SIP service to + * notify the caller with the {@link android.app.PendingIntent} when an incoming call is + * received. + * + * @param localProfile the SIP profile to register with + * @param expiryTime registration expiration time (in seconds) + * @param listener to listen to the registration events + * @throws SipException if calling the SIP service results in an error + */ + public void register(SipProfile localProfile, int expiryTime, + SipRegistrationListener listener) throws SipException { + try { + ISipSession session = mSipService.createSession(localProfile, + createRelay(listener, localProfile.getUriString())); + if (session == null) { + throw new SipException( + "SipService.createSession() returns null"); + } + session.register(expiryTime); + } catch (RemoteException e) { + throw new SipException("register()", e); + } + } + + /** + * Manually unregisters the profile from the corresponding SIP provider for + * stop receiving further calls. This may interference with the auto + * registration process in the SIP service if the auto-registration option + * in the profile is enabled. + * + * @param localProfile the SIP profile to register with + * @param listener to listen to the registration events + * @throws SipException if calling the SIP service results in an error + */ + public void unregister(SipProfile localProfile, + SipRegistrationListener listener) throws SipException { + try { + ISipSession session = mSipService.createSession(localProfile, + createRelay(listener, localProfile.getUriString())); + if (session == null) { + throw new SipException( + "SipService.createSession() returns null"); + } + session.unregister(); + } catch (RemoteException e) { + throw new SipException("unregister()", e); + } + } + + /** + * Gets the {@link SipSession} that handles the incoming call. For audio + * calls, consider to use {@link SipAudioCall} to handle the incoming call. + * See {@link #takeAudioCall}. Note that the method may be called only once + * for the same intent. For subsequent calls on the same intent, the method + * returns null. + * + * @param incomingCallIntent the incoming call broadcast intent + * @return the session object that handles the incoming call + */ + public SipSession getSessionFor(Intent incomingCallIntent) + throws SipException { + try { + String callId = getCallId(incomingCallIntent); + ISipSession s = mSipService.getPendingSession(callId); + return ((s == null) ? null : new SipSession(s)); + } catch (RemoteException e) { + throw new SipException("getSessionFor()", e); + } + } + + private static ISipSessionListener createRelay( + SipRegistrationListener listener, String uri) { + return ((listener == null) ? null : new ListenerRelay(listener, uri)); + } + + /** + * Creates a {@link SipSession} with the specified profile. Use other + * methods, if applicable, instead of interacting with {@link SipSession} + * directly. + * + * @param localProfile the SIP profile the session is associated with + * @param listener to listen to SIP session events + */ + public SipSession createSipSession(SipProfile localProfile, + SipSession.Listener listener) throws SipException { + try { + ISipSession s = mSipService.createSession(localProfile, null); + if (s == null) { + throw new SipException( + "Failed to create SipSession; network unavailable?"); + } + return new SipSession(s, listener); + } catch (RemoteException e) { + throw new SipException("createSipSession()", e); + } + } + + /** + * Gets the list of profiles hosted by the SIP service. The user information + * (username, password and display name) are crossed out. + * @hide + */ + public SipProfile[] getListOfProfiles() { + try { + return mSipService.getListOfProfiles(); + } catch (RemoteException e) { + return new SipProfile[0]; + } + } + + private static class ListenerRelay extends SipSessionAdapter { + private SipRegistrationListener mListener; + private String mUri; + + // listener must not be null + public ListenerRelay(SipRegistrationListener listener, String uri) { + mListener = listener; + mUri = uri; + } + + private String getUri(ISipSession session) { + try { + return ((session == null) + ? mUri + : session.getLocalProfile().getUriString()); + } catch (Throwable e) { + // SipService died? SIP stack died? + Rlog.e(TAG, "getUri(): ", e); + return null; + } + } + + @Override + public void onRegistering(ISipSession session) { + mListener.onRegistering(getUri(session)); + } + + @Override + public void onRegistrationDone(ISipSession session, int duration) { + long expiryTime = duration; + if (duration > 0) expiryTime += System.currentTimeMillis(); + mListener.onRegistrationDone(getUri(session), expiryTime); + } + + @Override + public void onRegistrationFailed(ISipSession session, int errorCode, + String message) { + mListener.onRegistrationFailed(getUri(session), errorCode, message); + } + + @Override + public void onRegistrationTimeout(ISipSession session) { + mListener.onRegistrationFailed(getUri(session), + SipErrorCode.TIME_OUT, "registration timed out"); + } + } +} diff --git a/java/android/net/sip/SipProfile.aidl b/java/android/net/sip/SipProfile.aidl new file mode 100644 index 0000000..3b6f68f --- /dev/null +++ b/java/android/net/sip/SipProfile.aidl @@ -0,0 +1,19 @@ +/* + * 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 android.net.sip; + +parcelable SipProfile; diff --git a/java/android/net/sip/SipProfile.java b/java/android/net/sip/SipProfile.java new file mode 100644 index 0000000..0ef754c --- /dev/null +++ b/java/android/net/sip/SipProfile.java @@ -0,0 +1,502 @@ +/* + * 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 android.net.sip; + +import android.os.Parcel; +import android.os.Parcelable; +import android.text.TextUtils; + +import java.io.ObjectStreamException; +import java.io.Serializable; +import java.text.ParseException; +import javax.sip.InvalidArgumentException; +import javax.sip.ListeningPoint; +import javax.sip.PeerUnavailableException; +import javax.sip.SipFactory; +import javax.sip.address.Address; +import javax.sip.address.AddressFactory; +import javax.sip.address.SipURI; +import javax.sip.address.URI; + +/** + * Defines a SIP profile, including a SIP account, domain and server information. + * <p>You can create a {@link SipProfile} using {@link + * SipProfile.Builder}. You can also retrieve one from a {@link SipSession}, using {@link + * SipSession#getLocalProfile} and {@link SipSession#getPeerProfile}.</p> + * + * <div class="special reference"> + * <h3>Developer Guides</h3> + * <p>For more information about using SIP, read the + * <a href="{@docRoot}guide/topics/network/sip.html">Session Initiation Protocol</a> + * developer guide.</p> + * </div> + */ +public class SipProfile implements Parcelable, Serializable, Cloneable { + private static final long serialVersionUID = 1L; + private static final int DEFAULT_PORT = 5060; + private static final String TCP = "TCP"; + private static final String UDP = "UDP"; + private Address mAddress; + private String mProxyAddress; + private String mPassword; + private String mDomain; + private String mProtocol = UDP; + private String mProfileName; + private String mAuthUserName; + private int mPort = DEFAULT_PORT; + private boolean mSendKeepAlive = false; + private boolean mAutoRegistration = true; + private transient int mCallingUid = 0; + + public static final Parcelable.Creator<SipProfile> CREATOR = + new Parcelable.Creator<SipProfile>() { + public SipProfile createFromParcel(Parcel in) { + return new SipProfile(in); + } + + public SipProfile[] newArray(int size) { + return new SipProfile[size]; + } + }; + + /** + * Helper class for creating a {@link SipProfile}. + */ + public static class Builder { + private AddressFactory mAddressFactory; + private SipProfile mProfile = new SipProfile(); + private SipURI mUri; + private String mDisplayName; + private String mProxyAddress; + + { + try { + mAddressFactory = + SipFactory.getInstance().createAddressFactory(); + } catch (PeerUnavailableException e) { + throw new RuntimeException(e); + } + } + + /** + * Creates a builder based on the given profile. + */ + public Builder(SipProfile profile) { + if (profile == null) throw new NullPointerException(); + try { + mProfile = (SipProfile) profile.clone(); + } catch (CloneNotSupportedException e) { + throw new RuntimeException("should not occur", e); + } + mProfile.mAddress = null; + mUri = profile.getUri(); + mUri.setUserPassword(profile.getPassword()); + mDisplayName = profile.getDisplayName(); + mProxyAddress = profile.getProxyAddress(); + mProfile.mPort = profile.getPort(); + } + + /** + * Constructor. + * + * @param uriString the URI string as "sip:<user_name>@<domain>" + * @throws ParseException if the string is not a valid URI + */ + public Builder(String uriString) throws ParseException { + if (uriString == null) { + throw new NullPointerException("uriString cannot be null"); + } + URI uri = mAddressFactory.createURI(fix(uriString)); + if (uri instanceof SipURI) { + mUri = (SipURI) uri; + } else { + throw new ParseException(uriString + " is not a SIP URI", 0); + } + mProfile.mDomain = mUri.getHost(); + } + + /** + * Constructor. + * + * @param username username of the SIP account + * @param serverDomain the SIP server domain; if the network address + * is different from the domain, use {@link #setOutboundProxy} to + * set server address + * @throws ParseException if the parameters are not valid + */ + public Builder(String username, String serverDomain) + throws ParseException { + if ((username == null) || (serverDomain == null)) { + throw new NullPointerException( + "username and serverDomain cannot be null"); + } + mUri = mAddressFactory.createSipURI(username, serverDomain); + mProfile.mDomain = serverDomain; + } + + private String fix(String uriString) { + return (uriString.trim().toLowerCase().startsWith("sip:") + ? uriString + : "sip:" + uriString); + } + + /** + * Sets the username used for authentication. + * + * @param name authentication username of the profile + * @return this builder object + */ + public Builder setAuthUserName(String name) { + mProfile.mAuthUserName = name; + return this; + } + + /** + * Sets the name of the profile. This name is given by user. + * + * @param name name of the profile + * @return this builder object + */ + public Builder setProfileName(String name) { + mProfile.mProfileName = name; + return this; + } + + /** + * Sets the password of the SIP account + * + * @param password password of the SIP account + * @return this builder object + */ + public Builder setPassword(String password) { + mUri.setUserPassword(password); + return this; + } + + /** + * Sets the port number of the server. By default, it is 5060. + * + * @param port port number of the server + * @return this builder object + * @throws IllegalArgumentException if the port number is out of range + */ + public Builder setPort(int port) throws IllegalArgumentException { + if ((port > 65535) || (port < 1000)) { + throw new IllegalArgumentException("incorrect port arugment: " + port); + } + mProfile.mPort = port; + return this; + } + + /** + * Sets the protocol used to connect to the SIP server. Currently, + * only "UDP" and "TCP" are supported. + * + * @param protocol the protocol string + * @return this builder object + * @throws IllegalArgumentException if the protocol is not recognized + */ + public Builder setProtocol(String protocol) + throws IllegalArgumentException { + if (protocol == null) { + throw new NullPointerException("protocol cannot be null"); + } + protocol = protocol.toUpperCase(); + if (!protocol.equals(UDP) && !protocol.equals(TCP)) { + throw new IllegalArgumentException( + "unsupported protocol: " + protocol); + } + mProfile.mProtocol = protocol; + return this; + } + + /** + * Sets the outbound proxy of the SIP server. + * + * @param outboundProxy the network address of the outbound proxy + * @return this builder object + */ + public Builder setOutboundProxy(String outboundProxy) { + mProxyAddress = outboundProxy; + return this; + } + + /** + * Sets the display name of the user. + * + * @param displayName display name of the user + * @return this builder object + */ + public Builder setDisplayName(String displayName) { + mDisplayName = displayName; + return this; + } + + /** + * Sets the send keep-alive flag. + * + * @param flag true if sending keep-alive message is required, + * false otherwise + * @return this builder object + */ + public Builder setSendKeepAlive(boolean flag) { + mProfile.mSendKeepAlive = flag; + return this; + } + + + /** + * Sets the auto. registration flag. + * + * @param flag true if the profile will be registered automatically, + * false otherwise + * @return this builder object + */ + public Builder setAutoRegistration(boolean flag) { + mProfile.mAutoRegistration = flag; + return this; + } + + /** + * Builds and returns the SIP profile object. + * + * @return the profile object created + */ + public SipProfile build() { + // remove password from URI + mProfile.mPassword = mUri.getUserPassword(); + mUri.setUserPassword(null); + try { + if (!TextUtils.isEmpty(mProxyAddress)) { + SipURI uri = (SipURI) + mAddressFactory.createURI(fix(mProxyAddress)); + mProfile.mProxyAddress = uri.getHost(); + } else { + if (!mProfile.mProtocol.equals(UDP)) { + mUri.setTransportParam(mProfile.mProtocol); + } + if (mProfile.mPort != DEFAULT_PORT) { + mUri.setPort(mProfile.mPort); + } + } + mProfile.mAddress = mAddressFactory.createAddress( + mDisplayName, mUri); + } catch (InvalidArgumentException e) { + throw new RuntimeException(e); + } catch (ParseException e) { + // must not occur + throw new RuntimeException(e); + } + return mProfile; + } + } + + private SipProfile() { + } + + private SipProfile(Parcel in) { + mAddress = (Address) in.readSerializable(); + mProxyAddress = in.readString(); + mPassword = in.readString(); + mDomain = in.readString(); + mProtocol = in.readString(); + mProfileName = in.readString(); + mSendKeepAlive = (in.readInt() == 0) ? false : true; + mAutoRegistration = (in.readInt() == 0) ? false : true; + mCallingUid = in.readInt(); + mPort = in.readInt(); + mAuthUserName = in.readString(); + } + + @Override + public void writeToParcel(Parcel out, int flags) { + out.writeSerializable(mAddress); + out.writeString(mProxyAddress); + out.writeString(mPassword); + out.writeString(mDomain); + out.writeString(mProtocol); + out.writeString(mProfileName); + out.writeInt(mSendKeepAlive ? 1 : 0); + out.writeInt(mAutoRegistration ? 1 : 0); + out.writeInt(mCallingUid); + out.writeInt(mPort); + out.writeString(mAuthUserName); + } + + @Override + public int describeContents() { + return 0; + } + + /** + * Gets the SIP URI of this profile. + * + * @return the SIP URI of this profile + * @hide + */ + public SipURI getUri() { + return (SipURI) mAddress.getURI(); + } + + /** + * Gets the SIP URI string of this profile. + * + * @return the SIP URI string of this profile + */ + public String getUriString() { + // We need to return the sip uri domain instead of + // the SIP URI with transport, port information if + // the outbound proxy address exists. + if (!TextUtils.isEmpty(mProxyAddress)) { + return "sip:" + getUserName() + "@" + mDomain; + } + return getUri().toString(); + } + + /** + * Gets the SIP address of this profile. + * + * @return the SIP address of this profile + * @hide + */ + public Address getSipAddress() { + return mAddress; + } + + /** + * Gets the display name of the user. + * + * @return the display name of the user + */ + public String getDisplayName() { + return mAddress.getDisplayName(); + } + + /** + * Gets the username. + * + * @return the username + */ + public String getUserName() { + return getUri().getUser(); + } + + /** + * Gets the username for authentication. If it is null, then the username + * is used in authentication instead. + * + * @return the authentication username + * @see #getUserName + */ + public String getAuthUserName() { + return mAuthUserName; + } + + /** + * Gets the password. + * + * @return the password + */ + public String getPassword() { + return mPassword; + } + + /** + * Gets the SIP domain. + * + * @return the SIP domain + */ + public String getSipDomain() { + return mDomain; + } + + /** + * Gets the port number of the SIP server. + * + * @return the port number of the SIP server + */ + public int getPort() { + return mPort; + } + + /** + * Gets the protocol used to connect to the server. + * + * @return the protocol + */ + public String getProtocol() { + return mProtocol; + } + + /** + * Gets the network address of the server outbound proxy. + * + * @return the network address of the server outbound proxy + */ + public String getProxyAddress() { + return mProxyAddress; + } + + /** + * Gets the (user-defined) name of the profile. + * + * @return name of the profile + */ + public String getProfileName() { + return mProfileName; + } + + /** + * Gets the flag of 'Sending keep-alive'. + * + * @return the flag of sending SIP keep-alive messages. + */ + public boolean getSendKeepAlive() { + return mSendKeepAlive; + } + + /** + * Gets the flag of 'Auto Registration'. + * + * @return the flag of registering the profile automatically. + */ + public boolean getAutoRegistration() { + return mAutoRegistration; + } + + /** + * Sets the calling process's Uid in the sip service. + * @hide + */ + public void setCallingUid(int uid) { + mCallingUid = uid; + } + + /** + * Gets the calling process's Uid in the sip settings. + * @hide + */ + public int getCallingUid() { + return mCallingUid; + } + + private Object readResolve() throws ObjectStreamException { + // For compatibility. + if (mPort == 0) mPort = DEFAULT_PORT; + return this; + } +} diff --git a/java/android/net/sip/SipRegistrationListener.java b/java/android/net/sip/SipRegistrationListener.java new file mode 100644 index 0000000..9968cc7 --- /dev/null +++ b/java/android/net/sip/SipRegistrationListener.java @@ -0,0 +1,48 @@ +/* + * 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 android.net.sip; + +/** + * Listener for SIP registration events. + */ +public interface SipRegistrationListener { + /** + * Called when a registration request is sent. + * + * @param localProfileUri the URI string of the SIP profile to register with + */ + void onRegistering(String localProfileUri); + + /** + * Called when the registration succeeded. + * + * @param localProfileUri the URI string of the SIP profile to register with + * @param expiryTime duration in seconds before the registration expires + */ + void onRegistrationDone(String localProfileUri, long expiryTime); + + /** + * Called when the registration failed. + * + * @param localProfileUri the URI string of the SIP profile to register with + * @param errorCode error code of this error + * @param errorMessage error message + * @see SipErrorCode + */ + void onRegistrationFailed(String localProfileUri, int errorCode, + String errorMessage); +} diff --git a/java/android/net/sip/SipSession.java b/java/android/net/sip/SipSession.java new file mode 100644 index 0000000..edbc66f --- /dev/null +++ b/java/android/net/sip/SipSession.java @@ -0,0 +1,574 @@ +/* + * 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 android.net.sip; + +import android.os.RemoteException; +import android.telephony.Rlog; + +/** + * Represents a SIP session that is associated with a SIP dialog or a standalone + * transaction not within a dialog. + * <p>You can get a {@link SipSession} from {@link SipManager} with {@link + * SipManager#createSipSession createSipSession()} (when initiating calls) or {@link + * SipManager#getSessionFor getSessionFor()} (when receiving calls).</p> + */ +public final class SipSession { + private static final String TAG = "SipSession"; + + /** + * Defines SIP session states, such as "registering", "outgoing call", and "in call". + */ + public static class State { + /** When session is ready to initiate a call or transaction. */ + public static final int READY_TO_CALL = 0; + + /** When the registration request is sent out. */ + public static final int REGISTERING = 1; + + /** When the unregistration request is sent out. */ + public static final int DEREGISTERING = 2; + + /** When an INVITE request is received. */ + public static final int INCOMING_CALL = 3; + + /** When an OK response is sent for the INVITE request received. */ + public static final int INCOMING_CALL_ANSWERING = 4; + + /** When an INVITE request is sent. */ + public static final int OUTGOING_CALL = 5; + + /** When a RINGING response is received for the INVITE request sent. */ + public static final int OUTGOING_CALL_RING_BACK = 6; + + /** When a CANCEL request is sent for the INVITE request sent. */ + public static final int OUTGOING_CALL_CANCELING = 7; + + /** When a call is established. */ + public static final int IN_CALL = 8; + + /** When an OPTIONS request is sent. */ + public static final int PINGING = 9; + + /** When ending a call. @hide */ + public static final int ENDING_CALL = 10; + + /** Not defined. */ + public static final int NOT_DEFINED = 101; + + /** + * Converts the state to string. + */ + public static String toString(int state) { + switch (state) { + case READY_TO_CALL: + return "READY_TO_CALL"; + case REGISTERING: + return "REGISTERING"; + case DEREGISTERING: + return "DEREGISTERING"; + case INCOMING_CALL: + return "INCOMING_CALL"; + case INCOMING_CALL_ANSWERING: + return "INCOMING_CALL_ANSWERING"; + case OUTGOING_CALL: + return "OUTGOING_CALL"; + case OUTGOING_CALL_RING_BACK: + return "OUTGOING_CALL_RING_BACK"; + case OUTGOING_CALL_CANCELING: + return "OUTGOING_CALL_CANCELING"; + case IN_CALL: + return "IN_CALL"; + case PINGING: + return "PINGING"; + default: + return "NOT_DEFINED"; + } + } + + private State() { + } + } + + /** + * Listener for events relating to a SIP session, such as when a session is being registered + * ("on registering") or a call is outgoing ("on calling"). + * <p>Many of these events are also received by {@link SipAudioCall.Listener}.</p> + */ + public static class Listener { + /** + * Called when an INVITE request is sent to initiate a new call. + * + * @param session the session object that carries out the transaction + */ + public void onCalling(SipSession session) { + } + + /** + * Called when an INVITE request is received. + * + * @param session the session object that carries out the transaction + * @param caller the SIP profile of the caller + * @param sessionDescription the caller's session description + */ + public void onRinging(SipSession session, SipProfile caller, + String sessionDescription) { + } + + /** + * Called when a RINGING response is received for the INVITE request sent + * + * @param session the session object that carries out the transaction + */ + public void onRingingBack(SipSession session) { + } + + /** + * Called when the session is established. + * + * @param session the session object that is associated with the dialog + * @param sessionDescription the peer's session description + */ + public void onCallEstablished(SipSession session, + String sessionDescription) { + } + + /** + * Called when the session is terminated. + * + * @param session the session object that is associated with the dialog + */ + public void onCallEnded(SipSession session) { + } + + /** + * Called when the peer is busy during session initialization. + * + * @param session the session object that carries out the transaction + */ + public void onCallBusy(SipSession session) { + } + + /** + * Called when the call is being transferred to a new one. + * + * @hide + * @param newSession the new session that the call will be transferred to + * @param sessionDescription the new peer's session description + */ + public void onCallTransferring(SipSession newSession, + String sessionDescription) { + } + + /** + * Called when an error occurs during session initialization and + * termination. + * + * @param session the session object that carries out the transaction + * @param errorCode error code defined in {@link SipErrorCode} + * @param errorMessage error message + */ + public void onError(SipSession session, int errorCode, + String errorMessage) { + } + + /** + * Called when an error occurs during session modification negotiation. + * + * @param session the session object that carries out the transaction + * @param errorCode error code defined in {@link SipErrorCode} + * @param errorMessage error message + */ + public void onCallChangeFailed(SipSession session, int errorCode, + String errorMessage) { + } + + /** + * Called when a registration request is sent. + * + * @param session the session object that carries out the transaction + */ + public void onRegistering(SipSession session) { + } + + /** + * Called when registration is successfully done. + * + * @param session the session object that carries out the transaction + * @param duration duration in second before the registration expires + */ + public void onRegistrationDone(SipSession session, int duration) { + } + + /** + * Called when the registration fails. + * + * @param session the session object that carries out the transaction + * @param errorCode error code defined in {@link SipErrorCode} + * @param errorMessage error message + */ + public void onRegistrationFailed(SipSession session, int errorCode, + String errorMessage) { + } + + /** + * Called when the registration gets timed out. + * + * @param session the session object that carries out the transaction + */ + public void onRegistrationTimeout(SipSession session) { + } + } + + private final ISipSession mSession; + private Listener mListener; + + SipSession(ISipSession realSession) { + mSession = realSession; + if (realSession != null) { + try { + realSession.setListener(createListener()); + } catch (RemoteException e) { + loge("SipSession.setListener:", e); + } + } + } + + SipSession(ISipSession realSession, Listener listener) { + this(realSession); + setListener(listener); + } + + /** + * Gets the IP address of the local host on which this SIP session runs. + * + * @return the IP address of the local host + */ + public String getLocalIp() { + try { + return mSession.getLocalIp(); + } catch (RemoteException e) { + loge("getLocalIp:", e); + return "127.0.0.1"; + } + } + + /** + * Gets the SIP profile that this session is associated with. + * + * @return the SIP profile that this session is associated with + */ + public SipProfile getLocalProfile() { + try { + return mSession.getLocalProfile(); + } catch (RemoteException e) { + loge("getLocalProfile:", e); + return null; + } + } + + /** + * Gets the SIP profile that this session is connected to. Only available + * when the session is associated with a SIP dialog. + * + * @return the SIP profile that this session is connected to + */ + public SipProfile getPeerProfile() { + try { + return mSession.getPeerProfile(); + } catch (RemoteException e) { + loge("getPeerProfile:", e); + return null; + } + } + + /** + * Gets the session state. The value returned must be one of the states in + * {@link State}. + * + * @return the session state + */ + public int getState() { + try { + return mSession.getState(); + } catch (RemoteException e) { + loge("getState:", e); + return State.NOT_DEFINED; + } + } + + /** + * Checks if the session is in a call. + * + * @return true if the session is in a call + */ + public boolean isInCall() { + try { + return mSession.isInCall(); + } catch (RemoteException e) { + loge("isInCall:", e); + return false; + } + } + + /** + * Gets the call ID of the session. + * + * @return the call ID + */ + public String getCallId() { + try { + return mSession.getCallId(); + } catch (RemoteException e) { + loge("getCallId:", e); + return null; + } + } + + + /** + * Sets the listener to listen to the session events. A {@code SipSession} + * can only hold one listener at a time. Subsequent calls to this method + * override the previous listener. + * + * @param listener to listen to the session events of this object + */ + public void setListener(Listener listener) { + mListener = listener; + } + + + /** + * Performs registration to the server specified by the associated local + * profile. The session listener is called back upon success or failure of + * registration. The method is only valid to call when the session state is + * in {@link State#READY_TO_CALL}. + * + * @param duration duration in second before the registration expires + * @see Listener + */ + public void register(int duration) { + try { + mSession.register(duration); + } catch (RemoteException e) { + loge("register:", e); + } + } + + /** + * Performs unregistration to the server specified by the associated local + * profile. Unregistration is technically the same as registration with zero + * expiration duration. The session listener is called back upon success or + * failure of unregistration. The method is only valid to call when the + * session state is in {@link State#READY_TO_CALL}. + * + * @see Listener + */ + public void unregister() { + try { + mSession.unregister(); + } catch (RemoteException e) { + loge("unregister:", e); + } + } + + /** + * Initiates a call to the specified profile. The session listener is called + * back upon defined session events. The method is only valid to call when + * the session state is in {@link State#READY_TO_CALL}. + * + * @param callee the SIP profile to make the call to + * @param sessionDescription the session description of this call + * @param timeout the session will be timed out if the call is not + * established within {@code timeout} seconds. Default value (defined + * by SIP protocol) is used if {@code timeout} is zero or negative. + * @see Listener + */ + public void makeCall(SipProfile callee, String sessionDescription, + int timeout) { + try { + mSession.makeCall(callee, sessionDescription, timeout); + } catch (RemoteException e) { + loge("makeCall:", e); + } + } + + /** + * Answers an incoming call with the specified session description. The + * method is only valid to call when the session state is in + * {@link State#INCOMING_CALL}. + * + * @param sessionDescription the session description to answer this call + * @param timeout the session will be timed out if the call is not + * established within {@code timeout} seconds. Default value (defined + * by SIP protocol) is used if {@code timeout} is zero or negative. + */ + public void answerCall(String sessionDescription, int timeout) { + try { + mSession.answerCall(sessionDescription, timeout); + } catch (RemoteException e) { + loge("answerCall:", e); + } + } + + /** + * Ends an established call, terminates an outgoing call or rejects an + * incoming call. The method is only valid to call when the session state is + * in {@link State#IN_CALL}, + * {@link State#INCOMING_CALL}, + * {@link State#OUTGOING_CALL} or + * {@link State#OUTGOING_CALL_RING_BACK}. + */ + public void endCall() { + try { + mSession.endCall(); + } catch (RemoteException e) { + loge("endCall:", e); + } + } + + /** + * Changes the session description during a call. The method is only valid + * to call when the session state is in {@link State#IN_CALL}. + * + * @param sessionDescription the new session description + * @param timeout the session will be timed out if the call is not + * established within {@code timeout} seconds. Default value (defined + * by SIP protocol) is used if {@code timeout} is zero or negative. + */ + public void changeCall(String sessionDescription, int timeout) { + try { + mSession.changeCall(sessionDescription, timeout); + } catch (RemoteException e) { + loge("changeCall:", e); + } + } + + ISipSession getRealSession() { + return mSession; + } + + private ISipSessionListener createListener() { + return new ISipSessionListener.Stub() { + @Override + public void onCalling(ISipSession session) { + if (mListener != null) { + mListener.onCalling(SipSession.this); + } + } + + @Override + public void onRinging(ISipSession session, SipProfile caller, + String sessionDescription) { + if (mListener != null) { + mListener.onRinging(SipSession.this, caller, + sessionDescription); + } + } + + @Override + public void onRingingBack(ISipSession session) { + if (mListener != null) { + mListener.onRingingBack(SipSession.this); + } + } + + @Override + public void onCallEstablished(ISipSession session, + String sessionDescription) { + if (mListener != null) { + mListener.onCallEstablished(SipSession.this, + sessionDescription); + } + } + + @Override + public void onCallEnded(ISipSession session) { + if (mListener != null) { + mListener.onCallEnded(SipSession.this); + } + } + + @Override + public void onCallBusy(ISipSession session) { + if (mListener != null) { + mListener.onCallBusy(SipSession.this); + } + } + + @Override + public void onCallTransferring(ISipSession session, + String sessionDescription) { + if (mListener != null) { + mListener.onCallTransferring( + new SipSession(session, SipSession.this.mListener), + sessionDescription); + + } + } + + @Override + public void onCallChangeFailed(ISipSession session, int errorCode, + String message) { + if (mListener != null) { + mListener.onCallChangeFailed(SipSession.this, errorCode, + message); + } + } + + @Override + public void onError(ISipSession session, int errorCode, String message) { + if (mListener != null) { + mListener.onError(SipSession.this, errorCode, message); + } + } + + @Override + public void onRegistering(ISipSession session) { + if (mListener != null) { + mListener.onRegistering(SipSession.this); + } + } + + @Override + public void onRegistrationDone(ISipSession session, int duration) { + if (mListener != null) { + mListener.onRegistrationDone(SipSession.this, duration); + } + } + + @Override + public void onRegistrationFailed(ISipSession session, int errorCode, + String message) { + if (mListener != null) { + mListener.onRegistrationFailed(SipSession.this, errorCode, + message); + } + } + + @Override + public void onRegistrationTimeout(ISipSession session) { + if (mListener != null) { + mListener.onRegistrationTimeout(SipSession.this); + } + } + }; + } + + private void loge(String s, Throwable t) { + Rlog.e(TAG, s, t); + } +} diff --git a/java/android/net/sip/SipSessionAdapter.java b/java/android/net/sip/SipSessionAdapter.java new file mode 100644 index 0000000..f538983 --- /dev/null +++ b/java/android/net/sip/SipSessionAdapter.java @@ -0,0 +1,68 @@ +/* + * 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 android.net.sip; + +/** + * Adapter class for {@link ISipSessionListener}. Default implementation of all + * callback methods is no-op. + * @hide + */ +public class SipSessionAdapter extends ISipSessionListener.Stub { + public void onCalling(ISipSession session) { + } + + public void onRinging(ISipSession session, SipProfile caller, + String sessionDescription) { + } + + public void onRingingBack(ISipSession session) { + } + + public void onCallEstablished(ISipSession session, + String sessionDescription) { + } + + public void onCallEnded(ISipSession session) { + } + + public void onCallBusy(ISipSession session) { + } + + public void onCallTransferring(ISipSession session, + String sessionDescription) { + } + + public void onCallChangeFailed(ISipSession session, int errorCode, + String message) { + } + + public void onError(ISipSession session, int errorCode, String message) { + } + + public void onRegistering(ISipSession session) { + } + + public void onRegistrationDone(ISipSession session, int duration) { + } + + public void onRegistrationFailed(ISipSession session, int errorCode, + String message) { + } + + public void onRegistrationTimeout(ISipSession session) { + } +} diff --git a/java/android/net/sip/package.html b/java/android/net/sip/package.html new file mode 100644 index 0000000..3c4cc23 --- /dev/null +++ b/java/android/net/sip/package.html @@ -0,0 +1,45 @@ +<HTML> +<BODY> +<p>Provides access to Session Initiation Protocol (SIP) functionality, such as +making and answering VOIP calls using SIP.</p> + +<p>For more information, see the +<a href="{@docRoot}guide/topics/connectivity/sip.html">Session Initiation Protocol</a> +developer guide.</p> +{@more} + +<p>To get started, you need to get an instance of the {@link android.net.sip.SipManager} by +calling {@link android.net.sip.SipManager#newInstance newInstance()}.</p> + +<p>With the {@link android.net.sip.SipManager}, you can initiate SIP audio calls with {@link +android.net.sip.SipManager#makeAudioCall makeAudioCall()} and {@link +android.net.sip.SipManager#takeAudioCall takeAudioCall()}. Both methods require +a {@link android.net.sip.SipAudioCall.Listener} that receives callbacks when the state of the +call changes, such as when the call is ringing, established, or ended.</p> + +<p>Both {@link android.net.sip.SipManager#makeAudioCall makeAudioCall()} also requires two +{@link android.net.sip.SipProfile} objects, representing the local device and the peer +device. You can create a {@link android.net.sip.SipProfile} using the {@link +android.net.sip.SipProfile.Builder} subclass.</p> + +<p>Once you have a {@link android.net.sip.SipAudioCall}, you can perform SIP audio call actions with +the instance, such as make a call, answer a call, mute a call, turn on speaker mode, send DTMF +tones, and more.</p> + +<p>If you want to create generic SIP connections (such as for video calls or other), you can +create a SIP connection from the {@link android.net.sip.SipManager}, using {@link +android.net.sip.SipManager#open open()}. If you only want to create audio SIP calls, though, you +should use the {@link android.net.sip.SipAudioCall} class, as described above.</p> + +<p class="note"><strong>Note:</strong> +Not all Android-powered devices support VOIP functionality with SIP. Before performing any SIP +activity, you should call {@link android.net.sip.SipManager#isVoipSupported isVoipSupported()} +to verify that the device supports VOIP calling and {@link +android.net.sip.SipManager#isApiSupported isApiSupported()} to verify that the device supports the +SIP APIs. +Your application must also request the {@link android.Manifest.permission#INTERNET} and {@link +android.Manifest.permission#USE_SIP} permissions in order to use the SIP APIs. +</p> + +</BODY> +</HTML>
\ No newline at end of file diff --git a/java/com/android/server/sip/SipHelper.java b/java/com/android/server/sip/SipHelper.java new file mode 100644 index 0000000..c708be8 --- /dev/null +++ b/java/com/android/server/sip/SipHelper.java @@ -0,0 +1,537 @@ +/* + * 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.SipStackExt; +import gov.nist.javax.sip.clientauthutils.AccountManager; +import gov.nist.javax.sip.clientauthutils.AuthenticationHelper; +import gov.nist.javax.sip.header.extensions.ReferencesHeader; +import gov.nist.javax.sip.header.extensions.ReferredByHeader; +import gov.nist.javax.sip.header.extensions.ReplacesHeader; + +import android.net.sip.SipProfile; +import android.telephony.Rlog; + +import java.text.ParseException; +import java.util.ArrayList; +import java.util.EventObject; +import java.util.List; +import java.util.regex.Pattern; + +import javax.sip.ClientTransaction; +import javax.sip.Dialog; +import javax.sip.DialogTerminatedEvent; +import javax.sip.InvalidArgumentException; +import javax.sip.ListeningPoint; +import javax.sip.PeerUnavailableException; +import javax.sip.RequestEvent; +import javax.sip.ResponseEvent; +import javax.sip.ServerTransaction; +import javax.sip.SipException; +import javax.sip.SipFactory; +import javax.sip.SipProvider; +import javax.sip.SipStack; +import javax.sip.Transaction; +import javax.sip.TransactionTerminatedEvent; +import javax.sip.TransactionState; +import javax.sip.address.Address; +import javax.sip.address.AddressFactory; +import javax.sip.address.SipURI; +import javax.sip.header.CSeqHeader; +import javax.sip.header.CallIdHeader; +import javax.sip.header.ContactHeader; +import javax.sip.header.FromHeader; +import javax.sip.header.Header; +import javax.sip.header.HeaderFactory; +import javax.sip.header.MaxForwardsHeader; +import javax.sip.header.ToHeader; +import javax.sip.header.ViaHeader; +import javax.sip.message.Message; +import javax.sip.message.MessageFactory; +import javax.sip.message.Request; +import javax.sip.message.Response; + +/** + * Helper class for holding SIP stack related classes and for various low-level + * SIP tasks like sending messages. + */ +class SipHelper { + private static final String TAG = SipHelper.class.getSimpleName(); + private static final boolean DBG = false; + private static final boolean DBG_PING = false; + + private SipStack mSipStack; + private SipProvider mSipProvider; + private AddressFactory mAddressFactory; + private HeaderFactory mHeaderFactory; + private MessageFactory mMessageFactory; + + public SipHelper(SipStack sipStack, SipProvider sipProvider) + throws PeerUnavailableException { + mSipStack = sipStack; + mSipProvider = sipProvider; + + SipFactory sipFactory = SipFactory.getInstance(); + mAddressFactory = sipFactory.createAddressFactory(); + mHeaderFactory = sipFactory.createHeaderFactory(); + mMessageFactory = sipFactory.createMessageFactory(); + } + + private FromHeader createFromHeader(SipProfile profile, String tag) + throws ParseException { + return mHeaderFactory.createFromHeader(profile.getSipAddress(), tag); + } + + private ToHeader createToHeader(SipProfile profile) throws ParseException { + return createToHeader(profile, null); + } + + private ToHeader createToHeader(SipProfile profile, String tag) + throws ParseException { + return mHeaderFactory.createToHeader(profile.getSipAddress(), tag); + } + + private CallIdHeader createCallIdHeader() { + return mSipProvider.getNewCallId(); + } + + private CSeqHeader createCSeqHeader(String method) + throws ParseException, InvalidArgumentException { + long sequence = (long) (Math.random() * 10000); + return mHeaderFactory.createCSeqHeader(sequence, method); + } + + private MaxForwardsHeader createMaxForwardsHeader() + throws InvalidArgumentException { + return mHeaderFactory.createMaxForwardsHeader(70); + } + + private MaxForwardsHeader createMaxForwardsHeader(int max) + throws InvalidArgumentException { + return mHeaderFactory.createMaxForwardsHeader(max); + } + + private ListeningPoint getListeningPoint() throws SipException { + ListeningPoint lp = mSipProvider.getListeningPoint(ListeningPoint.UDP); + if (lp == null) lp = mSipProvider.getListeningPoint(ListeningPoint.TCP); + if (lp == null) { + ListeningPoint[] lps = mSipProvider.getListeningPoints(); + if ((lps != null) && (lps.length > 0)) lp = lps[0]; + } + if (lp == null) { + throw new SipException("no listening point is available"); + } + return lp; + } + + private List<ViaHeader> createViaHeaders() + throws ParseException, SipException { + List<ViaHeader> viaHeaders = new ArrayList<ViaHeader>(1); + ListeningPoint lp = getListeningPoint(); + ViaHeader viaHeader = mHeaderFactory.createViaHeader(lp.getIPAddress(), + lp.getPort(), lp.getTransport(), null); + viaHeader.setRPort(); + viaHeaders.add(viaHeader); + return viaHeaders; + } + + private ContactHeader createContactHeader(SipProfile profile) + throws ParseException, SipException { + return createContactHeader(profile, null, 0); + } + + private ContactHeader createContactHeader(SipProfile profile, + String ip, int port) throws ParseException, + SipException { + SipURI contactURI = (ip == null) + ? createSipUri(profile.getUserName(), profile.getProtocol(), + getListeningPoint()) + : createSipUri(profile.getUserName(), profile.getProtocol(), + ip, port); + + Address contactAddress = mAddressFactory.createAddress(contactURI); + contactAddress.setDisplayName(profile.getDisplayName()); + + return mHeaderFactory.createContactHeader(contactAddress); + } + + private ContactHeader createWildcardContactHeader() { + ContactHeader contactHeader = mHeaderFactory.createContactHeader(); + contactHeader.setWildCard(); + return contactHeader; + } + + private SipURI createSipUri(String username, String transport, + ListeningPoint lp) throws ParseException { + return createSipUri(username, transport, lp.getIPAddress(), lp.getPort()); + } + + private SipURI createSipUri(String username, String transport, + String ip, int port) throws ParseException { + SipURI uri = mAddressFactory.createSipURI(username, ip); + try { + uri.setPort(port); + uri.setTransportParam(transport); + } catch (InvalidArgumentException e) { + throw new RuntimeException(e); + } + return uri; + } + + public ClientTransaction sendOptions(SipProfile caller, SipProfile callee, + String tag) throws SipException { + try { + Request request = (caller == callee) + ? createRequest(Request.OPTIONS, caller, tag) + : createRequest(Request.OPTIONS, caller, callee, tag); + + ClientTransaction clientTransaction = + mSipProvider.getNewClientTransaction(request); + clientTransaction.sendRequest(); + return clientTransaction; + } catch (Exception e) { + throw new SipException("sendOptions()", e); + } + } + + public ClientTransaction sendRegister(SipProfile userProfile, String tag, + int expiry) throws SipException { + try { + Request request = createRequest(Request.REGISTER, userProfile, tag); + if (expiry == 0) { + // remove all previous registrations by wildcard + // rfc3261#section-10.2.2 + request.addHeader(createWildcardContactHeader()); + } else { + request.addHeader(createContactHeader(userProfile)); + } + request.addHeader(mHeaderFactory.createExpiresHeader(expiry)); + + ClientTransaction clientTransaction = + mSipProvider.getNewClientTransaction(request); + clientTransaction.sendRequest(); + return clientTransaction; + } catch (ParseException e) { + throw new SipException("sendRegister()", e); + } + } + + private Request createRequest(String requestType, SipProfile userProfile, + String tag) throws ParseException, SipException { + FromHeader fromHeader = createFromHeader(userProfile, tag); + ToHeader toHeader = createToHeader(userProfile); + + String replaceStr = Pattern.quote(userProfile.getUserName() + "@"); + SipURI requestURI = mAddressFactory.createSipURI( + userProfile.getUriString().replaceFirst(replaceStr, "")); + + List<ViaHeader> viaHeaders = createViaHeaders(); + CallIdHeader callIdHeader = createCallIdHeader(); + CSeqHeader cSeqHeader = createCSeqHeader(requestType); + MaxForwardsHeader maxForwards = createMaxForwardsHeader(); + Request request = mMessageFactory.createRequest(requestURI, + requestType, callIdHeader, cSeqHeader, fromHeader, + toHeader, viaHeaders, maxForwards); + Header userAgentHeader = mHeaderFactory.createHeader("User-Agent", + "SIPAUA/0.1.001"); + request.addHeader(userAgentHeader); + return request; + } + + public ClientTransaction handleChallenge(ResponseEvent responseEvent, + AccountManager accountManager) throws SipException { + AuthenticationHelper authenticationHelper = + ((SipStackExt) mSipStack).getAuthenticationHelper( + accountManager, mHeaderFactory); + ClientTransaction tid = responseEvent.getClientTransaction(); + ClientTransaction ct = authenticationHelper.handleChallenge( + responseEvent.getResponse(), tid, mSipProvider, 5); + if (DBG) log("send request with challenge response: " + + ct.getRequest()); + ct.sendRequest(); + return ct; + } + + private Request createRequest(String requestType, SipProfile caller, + SipProfile callee, String tag) throws ParseException, SipException { + FromHeader fromHeader = createFromHeader(caller, tag); + ToHeader toHeader = createToHeader(callee); + SipURI requestURI = callee.getUri(); + List<ViaHeader> viaHeaders = createViaHeaders(); + CallIdHeader callIdHeader = createCallIdHeader(); + CSeqHeader cSeqHeader = createCSeqHeader(requestType); + MaxForwardsHeader maxForwards = createMaxForwardsHeader(); + + Request request = mMessageFactory.createRequest(requestURI, + requestType, callIdHeader, cSeqHeader, fromHeader, + toHeader, viaHeaders, maxForwards); + + request.addHeader(createContactHeader(caller)); + return request; + } + + public ClientTransaction sendInvite(SipProfile caller, SipProfile callee, + String sessionDescription, String tag, ReferredByHeader referredBy, + String replaces) throws SipException { + try { + Request request = createRequest(Request.INVITE, caller, callee, tag); + if (referredBy != null) request.addHeader(referredBy); + if (replaces != null) { + request.addHeader(mHeaderFactory.createHeader( + ReplacesHeader.NAME, replaces)); + } + request.setContent(sessionDescription, + mHeaderFactory.createContentTypeHeader( + "application", "sdp")); + ClientTransaction clientTransaction = + mSipProvider.getNewClientTransaction(request); + if (DBG) log("send INVITE: " + request); + clientTransaction.sendRequest(); + return clientTransaction; + } catch (ParseException e) { + throw new SipException("sendInvite()", e); + } + } + + public ClientTransaction sendReinvite(Dialog dialog, + String sessionDescription) throws SipException { + try { + Request request = dialog.createRequest(Request.INVITE); + request.setContent(sessionDescription, + mHeaderFactory.createContentTypeHeader( + "application", "sdp")); + + // Adding rport argument in the request could fix some SIP servers + // in resolving the initiator's NAT port mapping for relaying the + // response message from the other end. + + ViaHeader viaHeader = (ViaHeader) request.getHeader(ViaHeader.NAME); + if (viaHeader != null) viaHeader.setRPort(); + + ClientTransaction clientTransaction = + mSipProvider.getNewClientTransaction(request); + if (DBG) log("send RE-INVITE: " + request); + dialog.sendRequest(clientTransaction); + return clientTransaction; + } catch (ParseException e) { + throw new SipException("sendReinvite()", e); + } + } + + public ServerTransaction getServerTransaction(RequestEvent event) + throws SipException { + ServerTransaction transaction = event.getServerTransaction(); + if (transaction == null) { + Request request = event.getRequest(); + return mSipProvider.getNewServerTransaction(request); + } else { + return transaction; + } + } + + /** + * @param event the INVITE request event + */ + public ServerTransaction sendRinging(RequestEvent event, String tag) + throws SipException { + try { + Request request = event.getRequest(); + ServerTransaction transaction = getServerTransaction(event); + + Response response = mMessageFactory.createResponse(Response.RINGING, + request); + + ToHeader toHeader = (ToHeader) response.getHeader(ToHeader.NAME); + toHeader.setTag(tag); + response.addHeader(toHeader); + if (DBG) log("send RINGING: " + response); + transaction.sendResponse(response); + return transaction; + } catch (ParseException e) { + throw new SipException("sendRinging()", e); + } + } + + /** + * @param event the INVITE request event + */ + public ServerTransaction sendInviteOk(RequestEvent event, + SipProfile localProfile, String sessionDescription, + ServerTransaction inviteTransaction, String externalIp, + int externalPort) throws SipException { + try { + Request request = event.getRequest(); + Response response = mMessageFactory.createResponse(Response.OK, + request); + response.addHeader(createContactHeader(localProfile, externalIp, + externalPort)); + response.setContent(sessionDescription, + mHeaderFactory.createContentTypeHeader( + "application", "sdp")); + + if (inviteTransaction == null) { + inviteTransaction = getServerTransaction(event); + } + + if (inviteTransaction.getState() != TransactionState.COMPLETED) { + if (DBG) log("send OK: " + response); + inviteTransaction.sendResponse(response); + } + + return inviteTransaction; + } catch (ParseException e) { + throw new SipException("sendInviteOk()", e); + } + } + + public void sendInviteBusyHere(RequestEvent event, + ServerTransaction inviteTransaction) throws SipException { + try { + Request request = event.getRequest(); + Response response = mMessageFactory.createResponse( + Response.BUSY_HERE, request); + + if (inviteTransaction == null) { + inviteTransaction = getServerTransaction(event); + } + + if (inviteTransaction.getState() != TransactionState.COMPLETED) { + if (DBG) log("send BUSY HERE: " + response); + inviteTransaction.sendResponse(response); + } + } catch (ParseException e) { + throw new SipException("sendInviteBusyHere()", e); + } + } + + /** + * @param event the INVITE ACK request event + */ + public void sendInviteAck(ResponseEvent event, Dialog dialog) + throws SipException { + Response response = event.getResponse(); + long cseq = ((CSeqHeader) response.getHeader(CSeqHeader.NAME)) + .getSeqNumber(); + Request ack = dialog.createAck(cseq); + if (DBG) log("send ACK: " + ack); + dialog.sendAck(ack); + } + + public void sendBye(Dialog dialog) throws SipException { + Request byeRequest = dialog.createRequest(Request.BYE); + if (DBG) log("send BYE: " + byeRequest); + dialog.sendRequest(mSipProvider.getNewClientTransaction(byeRequest)); + } + + public void sendCancel(ClientTransaction inviteTransaction) + throws SipException { + Request cancelRequest = inviteTransaction.createCancel(); + if (DBG) log("send CANCEL: " + cancelRequest); + mSipProvider.getNewClientTransaction(cancelRequest).sendRequest(); + } + + public void sendResponse(RequestEvent event, int responseCode) + throws SipException { + try { + Request request = event.getRequest(); + Response response = mMessageFactory.createResponse( + responseCode, request); + if (DBG && (!Request.OPTIONS.equals(request.getMethod()) + || DBG_PING)) { + log("send response: " + response); + } + getServerTransaction(event).sendResponse(response); + } catch (ParseException e) { + throw new SipException("sendResponse()", e); + } + } + + public void sendReferNotify(Dialog dialog, String content) + throws SipException { + try { + Request request = dialog.createRequest(Request.NOTIFY); + request.addHeader(mHeaderFactory.createSubscriptionStateHeader( + "active;expires=60")); + // set content here + request.setContent(content, + mHeaderFactory.createContentTypeHeader( + "message", "sipfrag")); + request.addHeader(mHeaderFactory.createEventHeader( + ReferencesHeader.REFER)); + if (DBG) log("send NOTIFY: " + request); + dialog.sendRequest(mSipProvider.getNewClientTransaction(request)); + } catch (ParseException e) { + throw new SipException("sendReferNotify()", e); + } + } + + public void sendInviteRequestTerminated(Request inviteRequest, + ServerTransaction inviteTransaction) throws SipException { + try { + Response response = mMessageFactory.createResponse( + Response.REQUEST_TERMINATED, inviteRequest); + if (DBG) log("send response: " + response); + inviteTransaction.sendResponse(response); + } catch (ParseException e) { + throw new SipException("sendInviteRequestTerminated()", e); + } + } + + public static String getCallId(EventObject event) { + if (event == null) return null; + if (event instanceof RequestEvent) { + return getCallId(((RequestEvent) event).getRequest()); + } else if (event instanceof ResponseEvent) { + return getCallId(((ResponseEvent) event).getResponse()); + } else if (event instanceof DialogTerminatedEvent) { + Dialog dialog = ((DialogTerminatedEvent) event).getDialog(); + return getCallId(((DialogTerminatedEvent) event).getDialog()); + } else if (event instanceof TransactionTerminatedEvent) { + TransactionTerminatedEvent e = (TransactionTerminatedEvent) event; + return getCallId(e.isServerTransaction() + ? e.getServerTransaction() + : e.getClientTransaction()); + } else { + Object source = event.getSource(); + if (source instanceof Transaction) { + return getCallId(((Transaction) source)); + } else if (source instanceof Dialog) { + return getCallId((Dialog) source); + } + } + return ""; + } + + public static String getCallId(Transaction transaction) { + return ((transaction != null) ? getCallId(transaction.getRequest()) + : ""); + } + + private static String getCallId(Message message) { + CallIdHeader callIdHeader = + (CallIdHeader) message.getHeader(CallIdHeader.NAME); + return callIdHeader.getCallId(); + } + + private static String getCallId(Dialog dialog) { + return dialog.getCallId().getCallId(); + } + + private void log(String s) { + Rlog.d(TAG, s); + } +} diff --git a/java/com/android/server/sip/SipService.java b/java/com/android/server/sip/SipService.java new file mode 100644 index 0000000..80fe68c --- /dev/null +++ b/java/com/android/server/sip/SipService.java @@ -0,0 +1,1262 @@ +/* + * 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 android.app.PendingIntent; +import android.content.BroadcastReceiver; +import android.content.Context; +import android.content.Intent; +import android.content.IntentFilter; +import android.net.ConnectivityManager; +import android.net.NetworkInfo; +import android.net.sip.ISipService; +import android.net.sip.ISipSession; +import android.net.sip.ISipSessionListener; +import android.net.sip.SipErrorCode; +import android.net.sip.SipManager; +import android.net.sip.SipProfile; +import android.net.sip.SipSession; +import android.net.sip.SipSessionAdapter; +import android.net.wifi.WifiManager; +import android.os.Binder; +import android.os.Bundle; +import android.os.Handler; +import android.os.HandlerThread; +import android.os.Looper; +import android.os.Message; +import android.os.PowerManager; +import android.os.Process; +import android.os.RemoteException; +import android.os.ServiceManager; +import android.os.SystemClock; +import android.telephony.Rlog; + +import java.io.IOException; +import java.net.DatagramSocket; +import java.net.InetAddress; +import java.net.UnknownHostException; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.Map; +import java.util.concurrent.Executor; +import javax.sip.SipException; + +/** + * @hide + */ +public final class SipService extends ISipService.Stub { + static final String TAG = "SipService"; + static final boolean DBG = true; + private static final int EXPIRY_TIME = 3600; + private static final int SHORT_EXPIRY_TIME = 10; + private static final int MIN_EXPIRY_TIME = 60; + private static final int DEFAULT_KEEPALIVE_INTERVAL = 10; // in seconds + private static final int DEFAULT_MAX_KEEPALIVE_INTERVAL = 120; // in seconds + + private Context mContext; + private String mLocalIp; + private int mNetworkType = -1; + private SipWakeupTimer mTimer; + private WifiManager.WifiLock mWifiLock; + private boolean mSipOnWifiOnly; + + private SipKeepAliveProcessCallback mSipKeepAliveProcessCallback; + + private MyExecutor mExecutor = new MyExecutor(); + + // SipProfile URI --> group + private Map<String, SipSessionGroupExt> mSipGroups = + new HashMap<String, SipSessionGroupExt>(); + + // session ID --> session + private Map<String, ISipSession> mPendingSessions = + new HashMap<String, ISipSession>(); + + private ConnectivityReceiver mConnectivityReceiver; + private SipWakeLock mMyWakeLock; + private int mKeepAliveInterval; + private int mLastGoodKeepAliveInterval = DEFAULT_KEEPALIVE_INTERVAL; + + /** + * Starts the SIP service. Do nothing if the SIP API is not supported on the + * device. + */ + public static void start(Context context) { + if (SipManager.isApiSupported(context)) { + ServiceManager.addService("sip", new SipService(context)); + context.sendBroadcast(new Intent(SipManager.ACTION_SIP_SERVICE_UP)); + if (DBG) slog("start:"); + } + } + + private SipService(Context context) { + if (DBG) log("SipService: started!"); + mContext = context; + mConnectivityReceiver = new ConnectivityReceiver(); + + mWifiLock = ((WifiManager) + context.getSystemService(Context.WIFI_SERVICE)) + .createWifiLock(WifiManager.WIFI_MODE_FULL, TAG); + mWifiLock.setReferenceCounted(false); + mSipOnWifiOnly = SipManager.isSipWifiOnly(context); + + mMyWakeLock = new SipWakeLock((PowerManager) + context.getSystemService(Context.POWER_SERVICE)); + + mTimer = new SipWakeupTimer(context, mExecutor); + } + + @Override + public synchronized SipProfile[] getListOfProfiles() { + mContext.enforceCallingOrSelfPermission( + android.Manifest.permission.USE_SIP, null); + boolean isCallerRadio = isCallerRadio(); + ArrayList<SipProfile> profiles = new ArrayList<SipProfile>(); + for (SipSessionGroupExt group : mSipGroups.values()) { + if (isCallerRadio || isCallerCreator(group)) { + profiles.add(group.getLocalProfile()); + } + } + return profiles.toArray(new SipProfile[profiles.size()]); + } + + @Override + public synchronized void open(SipProfile localProfile) { + mContext.enforceCallingOrSelfPermission( + android.Manifest.permission.USE_SIP, null); + localProfile.setCallingUid(Binder.getCallingUid()); + try { + createGroup(localProfile); + } catch (SipException e) { + loge("openToMakeCalls()", e); + // TODO: how to send the exception back + } + } + + @Override + public synchronized void open3(SipProfile localProfile, + PendingIntent incomingCallPendingIntent, + ISipSessionListener listener) { + mContext.enforceCallingOrSelfPermission( + android.Manifest.permission.USE_SIP, null); + localProfile.setCallingUid(Binder.getCallingUid()); + if (incomingCallPendingIntent == null) { + if (DBG) log("open3: incomingCallPendingIntent cannot be null; " + + "the profile is not opened"); + return; + } + if (DBG) log("open3: " + localProfile.getUriString() + ": " + + incomingCallPendingIntent + ": " + listener); + try { + SipSessionGroupExt group = createGroup(localProfile, + incomingCallPendingIntent, listener); + if (localProfile.getAutoRegistration()) { + group.openToReceiveCalls(); + updateWakeLocks(); + } + } catch (SipException e) { + loge("open3:", e); + // TODO: how to send the exception back + } + } + + private boolean isCallerCreator(SipSessionGroupExt group) { + SipProfile profile = group.getLocalProfile(); + return (profile.getCallingUid() == Binder.getCallingUid()); + } + + private boolean isCallerCreatorOrRadio(SipSessionGroupExt group) { + return (isCallerRadio() || isCallerCreator(group)); + } + + private boolean isCallerRadio() { + return (Binder.getCallingUid() == Process.PHONE_UID); + } + + @Override + public synchronized void close(String localProfileUri) { + mContext.enforceCallingOrSelfPermission( + android.Manifest.permission.USE_SIP, null); + SipSessionGroupExt group = mSipGroups.get(localProfileUri); + if (group == null) return; + if (!isCallerCreatorOrRadio(group)) { + if (DBG) log("only creator or radio can close this profile"); + return; + } + + group = mSipGroups.remove(localProfileUri); + notifyProfileRemoved(group.getLocalProfile()); + group.close(); + + updateWakeLocks(); + } + + @Override + public synchronized boolean isOpened(String localProfileUri) { + mContext.enforceCallingOrSelfPermission( + android.Manifest.permission.USE_SIP, null); + SipSessionGroupExt group = mSipGroups.get(localProfileUri); + if (group == null) return false; + if (isCallerCreatorOrRadio(group)) { + return true; + } else { + if (DBG) log("only creator or radio can query on the profile"); + return false; + } + } + + @Override + public synchronized boolean isRegistered(String localProfileUri) { + mContext.enforceCallingOrSelfPermission( + android.Manifest.permission.USE_SIP, null); + SipSessionGroupExt group = mSipGroups.get(localProfileUri); + if (group == null) return false; + if (isCallerCreatorOrRadio(group)) { + return group.isRegistered(); + } else { + if (DBG) log("only creator or radio can query on the profile"); + return false; + } + } + + @Override + public synchronized void setRegistrationListener(String localProfileUri, + ISipSessionListener listener) { + mContext.enforceCallingOrSelfPermission( + android.Manifest.permission.USE_SIP, null); + SipSessionGroupExt group = mSipGroups.get(localProfileUri); + if (group == null) return; + if (isCallerCreator(group)) { + group.setListener(listener); + } else { + if (DBG) log("only creator can set listener on the profile"); + } + } + + @Override + public synchronized ISipSession createSession(SipProfile localProfile, + ISipSessionListener listener) { + if (DBG) log("createSession: profile" + localProfile); + mContext.enforceCallingOrSelfPermission( + android.Manifest.permission.USE_SIP, null); + localProfile.setCallingUid(Binder.getCallingUid()); + if (mNetworkType == -1) { + if (DBG) log("createSession: mNetworkType==-1 ret=null"); + return null; + } + try { + SipSessionGroupExt group = createGroup(localProfile); + return group.createSession(listener); + } catch (SipException e) { + if (DBG) loge("createSession;", e); + return null; + } + } + + @Override + public synchronized ISipSession getPendingSession(String callId) { + mContext.enforceCallingOrSelfPermission( + android.Manifest.permission.USE_SIP, null); + if (callId == null) return null; + return mPendingSessions.get(callId); + } + + private String determineLocalIp() { + try { + DatagramSocket s = new DatagramSocket(); + s.connect(InetAddress.getByName("192.168.1.1"), 80); + return s.getLocalAddress().getHostAddress(); + } catch (IOException e) { + if (DBG) loge("determineLocalIp()", e); + // dont do anything; there should be a connectivity change going + return null; + } + } + + private SipSessionGroupExt createGroup(SipProfile localProfile) + throws SipException { + String key = localProfile.getUriString(); + SipSessionGroupExt group = mSipGroups.get(key); + if (group == null) { + group = new SipSessionGroupExt(localProfile, null, null); + mSipGroups.put(key, group); + notifyProfileAdded(localProfile); + } else if (!isCallerCreator(group)) { + throw new SipException("only creator can access the profile"); + } + return group; + } + + private SipSessionGroupExt createGroup(SipProfile localProfile, + PendingIntent incomingCallPendingIntent, + ISipSessionListener listener) throws SipException { + String key = localProfile.getUriString(); + SipSessionGroupExt group = mSipGroups.get(key); + if (group != null) { + if (!isCallerCreator(group)) { + throw new SipException("only creator can access the profile"); + } + group.setIncomingCallPendingIntent(incomingCallPendingIntent); + group.setListener(listener); + } else { + group = new SipSessionGroupExt(localProfile, + incomingCallPendingIntent, listener); + mSipGroups.put(key, group); + notifyProfileAdded(localProfile); + } + return group; + } + + private void notifyProfileAdded(SipProfile localProfile) { + if (DBG) log("notify: profile added: " + localProfile); + Intent intent = new Intent(SipManager.ACTION_SIP_ADD_PHONE); + intent.putExtra(SipManager.EXTRA_LOCAL_URI, localProfile.getUriString()); + mContext.sendBroadcast(intent); + if (mSipGroups.size() == 1) { + registerReceivers(); + } + } + + private void notifyProfileRemoved(SipProfile localProfile) { + if (DBG) log("notify: profile removed: " + localProfile); + Intent intent = new Intent(SipManager.ACTION_SIP_REMOVE_PHONE); + intent.putExtra(SipManager.EXTRA_LOCAL_URI, localProfile.getUriString()); + mContext.sendBroadcast(intent); + if (mSipGroups.size() == 0) { + unregisterReceivers(); + } + } + + private void stopPortMappingMeasurement() { + if (mSipKeepAliveProcessCallback != null) { + mSipKeepAliveProcessCallback.stop(); + mSipKeepAliveProcessCallback = null; + } + } + + private void startPortMappingLifetimeMeasurement( + SipProfile localProfile) { + startPortMappingLifetimeMeasurement(localProfile, + DEFAULT_MAX_KEEPALIVE_INTERVAL); + } + + private void startPortMappingLifetimeMeasurement( + SipProfile localProfile, int maxInterval) { + if ((mSipKeepAliveProcessCallback == null) + && (mKeepAliveInterval == -1) + && isBehindNAT(mLocalIp)) { + if (DBG) log("startPortMappingLifetimeMeasurement: profile=" + + localProfile.getUriString()); + + int minInterval = mLastGoodKeepAliveInterval; + if (minInterval >= maxInterval) { + // If mLastGoodKeepAliveInterval also does not work, reset it + // to the default min + minInterval = mLastGoodKeepAliveInterval + = DEFAULT_KEEPALIVE_INTERVAL; + log(" reset min interval to " + minInterval); + } + mSipKeepAliveProcessCallback = new SipKeepAliveProcessCallback( + localProfile, minInterval, maxInterval); + mSipKeepAliveProcessCallback.start(); + } + } + + private void restartPortMappingLifetimeMeasurement( + SipProfile localProfile, int maxInterval) { + stopPortMappingMeasurement(); + mKeepAliveInterval = -1; + startPortMappingLifetimeMeasurement(localProfile, maxInterval); + } + + private synchronized void addPendingSession(ISipSession session) { + try { + cleanUpPendingSessions(); + mPendingSessions.put(session.getCallId(), session); + if (DBG) log("#pending sess=" + mPendingSessions.size()); + } catch (RemoteException e) { + // should not happen with a local call + loge("addPendingSession()", e); + } + } + + private void cleanUpPendingSessions() throws RemoteException { + Map.Entry<String, ISipSession>[] entries = + mPendingSessions.entrySet().toArray( + new Map.Entry[mPendingSessions.size()]); + for (Map.Entry<String, ISipSession> entry : entries) { + if (entry.getValue().getState() != SipSession.State.INCOMING_CALL) { + mPendingSessions.remove(entry.getKey()); + } + } + } + + private synchronized boolean callingSelf(SipSessionGroupExt ringingGroup, + SipSessionGroup.SipSessionImpl ringingSession) { + String callId = ringingSession.getCallId(); + for (SipSessionGroupExt group : mSipGroups.values()) { + if ((group != ringingGroup) && group.containsSession(callId)) { + if (DBG) log("call self: " + + ringingSession.getLocalProfile().getUriString() + + " -> " + group.getLocalProfile().getUriString()); + return true; + } + } + return false; + } + + private synchronized void onKeepAliveIntervalChanged() { + for (SipSessionGroupExt group : mSipGroups.values()) { + group.onKeepAliveIntervalChanged(); + } + } + + private int getKeepAliveInterval() { + return (mKeepAliveInterval < 0) + ? mLastGoodKeepAliveInterval + : mKeepAliveInterval; + } + + private boolean isBehindNAT(String address) { + try { + // TODO: How is isBehindNAT used and why these constanst address: + // 10.x.x.x | 192.168.x.x | 172.16.x.x .. 172.19.x.x + byte[] d = InetAddress.getByName(address).getAddress(); + if ((d[0] == 10) || + (((0x000000FF & d[0]) == 172) && + ((0x000000F0 & d[1]) == 16)) || + (((0x000000FF & d[0]) == 192) && + ((0x000000FF & d[1]) == 168))) { + return true; + } + } catch (UnknownHostException e) { + loge("isBehindAT()" + address, e); + } + return false; + } + + private class SipSessionGroupExt extends SipSessionAdapter { + private static final String SSGE_TAG = "SipSessionGroupExt"; + private static final boolean SSGE_DBG = true; + private SipSessionGroup mSipGroup; + private PendingIntent mIncomingCallPendingIntent; + private boolean mOpenedToReceiveCalls; + + private SipAutoReg mAutoRegistration = + new SipAutoReg(); + + public SipSessionGroupExt(SipProfile localProfile, + PendingIntent incomingCallPendingIntent, + ISipSessionListener listener) throws SipException { + if (SSGE_DBG) log("SipSessionGroupExt: profile=" + localProfile); + mSipGroup = new SipSessionGroup(duplicate(localProfile), + localProfile.getPassword(), mTimer, mMyWakeLock); + mIncomingCallPendingIntent = incomingCallPendingIntent; + mAutoRegistration.setListener(listener); + } + + public SipProfile getLocalProfile() { + return mSipGroup.getLocalProfile(); + } + + public boolean containsSession(String callId) { + return mSipGroup.containsSession(callId); + } + + public void onKeepAliveIntervalChanged() { + mAutoRegistration.onKeepAliveIntervalChanged(); + } + + // TODO: remove this method once SipWakeupTimer can better handle variety + // of timeout values + void setWakeupTimer(SipWakeupTimer timer) { + mSipGroup.setWakeupTimer(timer); + } + + private SipProfile duplicate(SipProfile p) { + try { + return new SipProfile.Builder(p).setPassword("*").build(); + } catch (Exception e) { + loge("duplicate()", e); + throw new RuntimeException("duplicate profile", e); + } + } + + public void setListener(ISipSessionListener listener) { + mAutoRegistration.setListener(listener); + } + + public void setIncomingCallPendingIntent(PendingIntent pIntent) { + mIncomingCallPendingIntent = pIntent; + } + + public void openToReceiveCalls() throws SipException { + mOpenedToReceiveCalls = true; + if (mNetworkType != -1) { + mSipGroup.openToReceiveCalls(this); + mAutoRegistration.start(mSipGroup); + } + if (SSGE_DBG) log("openToReceiveCalls: " + getUri() + ": " + + mIncomingCallPendingIntent); + } + + public void onConnectivityChanged(boolean connected) + throws SipException { + if (SSGE_DBG) { + log("onConnectivityChanged: connected=" + connected + " uri=" + + getUri() + ": " + mIncomingCallPendingIntent); + } + mSipGroup.onConnectivityChanged(); + if (connected) { + mSipGroup.reset(); + if (mOpenedToReceiveCalls) openToReceiveCalls(); + } else { + mSipGroup.close(); + mAutoRegistration.stop(); + } + } + + public void close() { + mOpenedToReceiveCalls = false; + mSipGroup.close(); + mAutoRegistration.stop(); + if (SSGE_DBG) log("close: " + getUri() + ": " + mIncomingCallPendingIntent); + } + + public ISipSession createSession(ISipSessionListener listener) { + if (SSGE_DBG) log("createSession"); + return mSipGroup.createSession(listener); + } + + @Override + public void onRinging(ISipSession s, SipProfile caller, + String sessionDescription) { + SipSessionGroup.SipSessionImpl session = + (SipSessionGroup.SipSessionImpl) s; + synchronized (SipService.this) { + try { + if (!isRegistered() || callingSelf(this, session)) { + if (SSGE_DBG) log("onRinging: end notReg or self"); + session.endCall(); + return; + } + + // send out incoming call broadcast + addPendingSession(session); + Intent intent = SipManager.createIncomingCallBroadcast( + session.getCallId(), sessionDescription); + if (SSGE_DBG) log("onRinging: uri=" + getUri() + ": " + + caller.getUri() + ": " + session.getCallId() + + " " + mIncomingCallPendingIntent); + mIncomingCallPendingIntent.send(mContext, + SipManager.INCOMING_CALL_RESULT_CODE, intent); + } catch (PendingIntent.CanceledException e) { + loge("onRinging: pendingIntent is canceled, drop incoming call", e); + session.endCall(); + } + } + } + + @Override + public void onError(ISipSession session, int errorCode, + String message) { + if (SSGE_DBG) log("onError: errorCode=" + errorCode + " desc=" + + SipErrorCode.toString(errorCode) + ": " + message); + } + + public boolean isOpenedToReceiveCalls() { + return mOpenedToReceiveCalls; + } + + public boolean isRegistered() { + return mAutoRegistration.isRegistered(); + } + + private String getUri() { + return mSipGroup.getLocalProfileUri(); + } + + private void log(String s) { + Rlog.d(SSGE_TAG, s); + } + + private void loge(String s, Throwable t) { + Rlog.e(SSGE_TAG, s, t); + } + + } + + private class SipKeepAliveProcessCallback implements Runnable, + SipSessionGroup.KeepAliveProcessCallback { + private static final String SKAI_TAG = "SipKeepAliveProcessCallback"; + private static final boolean SKAI_DBG = true; + private static final int MIN_INTERVAL = 5; // in seconds + private static final int PASS_THRESHOLD = 10; + private static final int NAT_MEASUREMENT_RETRY_INTERVAL = 120; // in seconds + private SipProfile mLocalProfile; + private SipSessionGroupExt mGroup; + private SipSessionGroup.SipSessionImpl mSession; + private int mMinInterval; + private int mMaxInterval; + private int mInterval; + private int mPassCount; + + public SipKeepAliveProcessCallback(SipProfile localProfile, + int minInterval, int maxInterval) { + mMaxInterval = maxInterval; + mMinInterval = minInterval; + mLocalProfile = localProfile; + } + + public void start() { + synchronized (SipService.this) { + if (mSession != null) { + return; + } + + mInterval = (mMaxInterval + mMinInterval) / 2; + mPassCount = 0; + + // Don't start measurement if the interval is too small + if (mInterval < DEFAULT_KEEPALIVE_INTERVAL || checkTermination()) { + if (SKAI_DBG) log("start: measurement aborted; interval=[" + + mMinInterval + "," + mMaxInterval + "]"); + return; + } + + try { + if (SKAI_DBG) log("start: interval=" + mInterval); + + mGroup = new SipSessionGroupExt(mLocalProfile, null, null); + // TODO: remove this line once SipWakeupTimer can better handle + // variety of timeout values + mGroup.setWakeupTimer(new SipWakeupTimer(mContext, mExecutor)); + + mSession = (SipSessionGroup.SipSessionImpl) + mGroup.createSession(null); + mSession.startKeepAliveProcess(mInterval, this); + } catch (Throwable t) { + onError(SipErrorCode.CLIENT_ERROR, t.toString()); + } + } + } + + public void stop() { + synchronized (SipService.this) { + if (mSession != null) { + mSession.stopKeepAliveProcess(); + mSession = null; + } + if (mGroup != null) { + mGroup.close(); + mGroup = null; + } + mTimer.cancel(this); + if (SKAI_DBG) log("stop"); + } + } + + private void restart() { + synchronized (SipService.this) { + // Return immediately if the measurement process is stopped + if (mSession == null) return; + + if (SKAI_DBG) log("restart: interval=" + mInterval); + try { + mSession.stopKeepAliveProcess(); + mPassCount = 0; + mSession.startKeepAliveProcess(mInterval, this); + } catch (SipException e) { + loge("restart", e); + } + } + } + + private boolean checkTermination() { + return ((mMaxInterval - mMinInterval) < MIN_INTERVAL); + } + + // SipSessionGroup.KeepAliveProcessCallback + @Override + public void onResponse(boolean portChanged) { + synchronized (SipService.this) { + if (!portChanged) { + if (++mPassCount != PASS_THRESHOLD) return; + // update the interval, since the current interval is good to + // keep the port mapping. + if (mKeepAliveInterval > 0) { + mLastGoodKeepAliveInterval = mKeepAliveInterval; + } + mKeepAliveInterval = mMinInterval = mInterval; + if (SKAI_DBG) { + log("onResponse: portChanged=" + portChanged + " mKeepAliveInterval=" + + mKeepAliveInterval); + } + onKeepAliveIntervalChanged(); + } else { + // Since the rport is changed, shorten the interval. + mMaxInterval = mInterval; + } + if (checkTermination()) { + // update mKeepAliveInterval and stop measurement. + stop(); + // If all the measurements failed, we still set it to + // mMinInterval; If mMinInterval still doesn't work, a new + // measurement with min interval=DEFAULT_KEEPALIVE_INTERVAL + // will be conducted. + mKeepAliveInterval = mMinInterval; + if (SKAI_DBG) { + log("onResponse: checkTermination mKeepAliveInterval=" + + mKeepAliveInterval); + } + } else { + // calculate the new interval and continue. + mInterval = (mMaxInterval + mMinInterval) / 2; + if (SKAI_DBG) { + log("onResponse: mKeepAliveInterval=" + mKeepAliveInterval + + ", new mInterval=" + mInterval); + } + restart(); + } + } + } + + // SipSessionGroup.KeepAliveProcessCallback + @Override + public void onError(int errorCode, String description) { + if (SKAI_DBG) loge("onError: errorCode=" + errorCode + " desc=" + description); + restartLater(); + } + + // timeout handler + @Override + public void run() { + mTimer.cancel(this); + restart(); + } + + private void restartLater() { + synchronized (SipService.this) { + int interval = NAT_MEASUREMENT_RETRY_INTERVAL; + mTimer.cancel(this); + mTimer.set(interval * 1000, this); + } + } + + private void log(String s) { + Rlog.d(SKAI_TAG, s); + } + + private void loge(String s) { + Rlog.d(SKAI_TAG, s); + } + + private void loge(String s, Throwable t) { + Rlog.d(SKAI_TAG, s, t); + } + } + + private class SipAutoReg extends SipSessionAdapter + implements Runnable, SipSessionGroup.KeepAliveProcessCallback { + private String SAR_TAG; + private static final boolean SAR_DBG = true; + private static final int MIN_KEEPALIVE_SUCCESS_COUNT = 10; + + private SipSessionGroup.SipSessionImpl mSession; + private SipSessionGroup.SipSessionImpl mKeepAliveSession; + private SipSessionListenerProxy mProxy = new SipSessionListenerProxy(); + private int mBackoff = 1; + private boolean mRegistered; + private long mExpiryTime; + private int mErrorCode; + private String mErrorMessage; + private boolean mRunning = false; + + private int mKeepAliveSuccessCount = 0; + + public void start(SipSessionGroup group) { + if (!mRunning) { + mRunning = true; + mBackoff = 1; + mSession = (SipSessionGroup.SipSessionImpl) + group.createSession(this); + // return right away if no active network connection. + if (mSession == null) return; + + // start unregistration to clear up old registration at server + // TODO: when rfc5626 is deployed, use reg-id and sip.instance + // in registration to avoid adding duplicate entries to server + mMyWakeLock.acquire(mSession); + mSession.unregister(); + SAR_TAG = "SipAutoReg:" + mSession.getLocalProfile().getUriString(); + if (SAR_DBG) log("start: group=" + group); + } + } + + private void startKeepAliveProcess(int interval) { + if (SAR_DBG) log("startKeepAliveProcess: interval=" + interval); + if (mKeepAliveSession == null) { + mKeepAliveSession = mSession.duplicate(); + } else { + mKeepAliveSession.stopKeepAliveProcess(); + } + try { + mKeepAliveSession.startKeepAliveProcess(interval, this); + } catch (SipException e) { + loge("startKeepAliveProcess: interval=" + interval, e); + } + } + + private void stopKeepAliveProcess() { + if (mKeepAliveSession != null) { + mKeepAliveSession.stopKeepAliveProcess(); + mKeepAliveSession = null; + } + mKeepAliveSuccessCount = 0; + } + + // SipSessionGroup.KeepAliveProcessCallback + @Override + public void onResponse(boolean portChanged) { + synchronized (SipService.this) { + if (portChanged) { + int interval = getKeepAliveInterval(); + if (mKeepAliveSuccessCount < MIN_KEEPALIVE_SUCCESS_COUNT) { + if (SAR_DBG) { + log("onResponse: keepalive doesn't work with interval " + + interval + ", past success count=" + + mKeepAliveSuccessCount); + } + if (interval > DEFAULT_KEEPALIVE_INTERVAL) { + restartPortMappingLifetimeMeasurement( + mSession.getLocalProfile(), interval); + mKeepAliveSuccessCount = 0; + } + } else { + if (SAR_DBG) { + log("keep keepalive going with interval " + + interval + ", past success count=" + + mKeepAliveSuccessCount); + } + mKeepAliveSuccessCount /= 2; + } + } else { + // Start keep-alive interval measurement on the first + // successfully kept-alive SipSessionGroup + startPortMappingLifetimeMeasurement( + mSession.getLocalProfile()); + mKeepAliveSuccessCount++; + } + + if (!mRunning || !portChanged) return; + + // The keep alive process is stopped when port is changed; + // Nullify the session so that the process can be restarted + // again when the re-registration is done + mKeepAliveSession = null; + + // Acquire wake lock for the registration process. The + // lock will be released when registration is complete. + mMyWakeLock.acquire(mSession); + mSession.register(EXPIRY_TIME); + } + } + + // SipSessionGroup.KeepAliveProcessCallback + @Override + public void onError(int errorCode, String description) { + if (SAR_DBG) { + loge("onError: errorCode=" + errorCode + " desc=" + description); + } + onResponse(true); // re-register immediately + } + + public void stop() { + if (!mRunning) return; + mRunning = false; + mMyWakeLock.release(mSession); + if (mSession != null) { + mSession.setListener(null); + if (mNetworkType != -1 && mRegistered) mSession.unregister(); + } + + mTimer.cancel(this); + stopKeepAliveProcess(); + + mRegistered = false; + setListener(mProxy.getListener()); + } + + public void onKeepAliveIntervalChanged() { + if (mKeepAliveSession != null) { + int newInterval = getKeepAliveInterval(); + if (SAR_DBG) { + log("onKeepAliveIntervalChanged: interval=" + newInterval); + } + mKeepAliveSuccessCount = 0; + startKeepAliveProcess(newInterval); + } + } + + public void setListener(ISipSessionListener listener) { + synchronized (SipService.this) { + mProxy.setListener(listener); + + try { + int state = (mSession == null) + ? SipSession.State.READY_TO_CALL + : mSession.getState(); + if ((state == SipSession.State.REGISTERING) + || (state == SipSession.State.DEREGISTERING)) { + mProxy.onRegistering(mSession); + } else if (mRegistered) { + int duration = (int) + (mExpiryTime - SystemClock.elapsedRealtime()); + mProxy.onRegistrationDone(mSession, duration); + } else if (mErrorCode != SipErrorCode.NO_ERROR) { + if (mErrorCode == SipErrorCode.TIME_OUT) { + mProxy.onRegistrationTimeout(mSession); + } else { + mProxy.onRegistrationFailed(mSession, mErrorCode, + mErrorMessage); + } + } else if (mNetworkType == -1) { + mProxy.onRegistrationFailed(mSession, + SipErrorCode.DATA_CONNECTION_LOST, + "no data connection"); + } else if (!mRunning) { + mProxy.onRegistrationFailed(mSession, + SipErrorCode.CLIENT_ERROR, + "registration not running"); + } else { + mProxy.onRegistrationFailed(mSession, + SipErrorCode.IN_PROGRESS, + String.valueOf(state)); + } + } catch (Throwable t) { + loge("setListener: ", t); + } + } + } + + public boolean isRegistered() { + return mRegistered; + } + + // timeout handler: re-register + @Override + public void run() { + synchronized (SipService.this) { + if (!mRunning) return; + + mErrorCode = SipErrorCode.NO_ERROR; + mErrorMessage = null; + if (SAR_DBG) log("run: registering"); + if (mNetworkType != -1) { + mMyWakeLock.acquire(mSession); + mSession.register(EXPIRY_TIME); + } + } + } + + private void restart(int duration) { + if (SAR_DBG) log("restart: duration=" + duration + "s later."); + mTimer.cancel(this); + mTimer.set(duration * 1000, this); + } + + private int backoffDuration() { + int duration = SHORT_EXPIRY_TIME * mBackoff; + if (duration > 3600) { + duration = 3600; + } else { + mBackoff *= 2; + } + return duration; + } + + @Override + public void onRegistering(ISipSession session) { + if (SAR_DBG) log("onRegistering: " + session); + synchronized (SipService.this) { + if (notCurrentSession(session)) return; + + mRegistered = false; + mProxy.onRegistering(session); + } + } + + private boolean notCurrentSession(ISipSession session) { + if (session != mSession) { + ((SipSessionGroup.SipSessionImpl) session).setListener(null); + mMyWakeLock.release(session); + return true; + } + return !mRunning; + } + + @Override + public void onRegistrationDone(ISipSession session, int duration) { + if (SAR_DBG) log("onRegistrationDone: " + session); + synchronized (SipService.this) { + if (notCurrentSession(session)) return; + + mProxy.onRegistrationDone(session, duration); + + if (duration > 0) { + mExpiryTime = SystemClock.elapsedRealtime() + + (duration * 1000); + + if (!mRegistered) { + mRegistered = true; + // allow some overlap to avoid call drop during renew + duration -= MIN_EXPIRY_TIME; + if (duration < MIN_EXPIRY_TIME) { + duration = MIN_EXPIRY_TIME; + } + restart(duration); + + SipProfile localProfile = mSession.getLocalProfile(); + if ((mKeepAliveSession == null) && (isBehindNAT(mLocalIp) + || localProfile.getSendKeepAlive())) { + startKeepAliveProcess(getKeepAliveInterval()); + } + } + mMyWakeLock.release(session); + } else { + mRegistered = false; + mExpiryTime = -1L; + if (SAR_DBG) log("Refresh registration immediately"); + run(); + } + } + } + + @Override + public void onRegistrationFailed(ISipSession session, int errorCode, + String message) { + if (SAR_DBG) log("onRegistrationFailed: " + session + ": " + + SipErrorCode.toString(errorCode) + ": " + message); + synchronized (SipService.this) { + if (notCurrentSession(session)) return; + + switch (errorCode) { + case SipErrorCode.INVALID_CREDENTIALS: + case SipErrorCode.SERVER_UNREACHABLE: + if (SAR_DBG) log(" pause auto-registration"); + stop(); + break; + default: + restartLater(); + } + + mErrorCode = errorCode; + mErrorMessage = message; + mProxy.onRegistrationFailed(session, errorCode, message); + mMyWakeLock.release(session); + } + } + + @Override + public void onRegistrationTimeout(ISipSession session) { + if (SAR_DBG) log("onRegistrationTimeout: " + session); + synchronized (SipService.this) { + if (notCurrentSession(session)) return; + + mErrorCode = SipErrorCode.TIME_OUT; + mProxy.onRegistrationTimeout(session); + restartLater(); + mMyWakeLock.release(session); + } + } + + private void restartLater() { + if (SAR_DBG) loge("restartLater"); + mRegistered = false; + restart(backoffDuration()); + } + + private void log(String s) { + Rlog.d(SAR_TAG, s); + } + + private void loge(String s) { + Rlog.e(SAR_TAG, s); + } + + private void loge(String s, Throwable e) { + Rlog.e(SAR_TAG, s, e); + } + } + + private class ConnectivityReceiver extends BroadcastReceiver { + @Override + public void onReceive(Context context, Intent intent) { + Bundle bundle = intent.getExtras(); + if (bundle != null) { + final NetworkInfo info = (NetworkInfo) + bundle.get(ConnectivityManager.EXTRA_NETWORK_INFO); + + // Run the handler in MyExecutor to be protected by wake lock + mExecutor.execute(new Runnable() { + @Override + public void run() { + onConnectivityChanged(info); + } + }); + } + } + } + + private void registerReceivers() { + mContext.registerReceiver(mConnectivityReceiver, + new IntentFilter(ConnectivityManager.CONNECTIVITY_ACTION)); + if (DBG) log("registerReceivers:"); + } + + private void unregisterReceivers() { + mContext.unregisterReceiver(mConnectivityReceiver); + if (DBG) log("unregisterReceivers:"); + + // Reset variables maintained by ConnectivityReceiver. + mWifiLock.release(); + mNetworkType = -1; + } + + private void updateWakeLocks() { + for (SipSessionGroupExt group : mSipGroups.values()) { + if (group.isOpenedToReceiveCalls()) { + // Also grab the WifiLock when we are disconnected, so the + // system will keep trying to reconnect. It will be released + // when the system eventually connects to something else. + if (mNetworkType == ConnectivityManager.TYPE_WIFI || mNetworkType == -1) { + mWifiLock.acquire(); + } else { + mWifiLock.release(); + } + return; + } + } + mWifiLock.release(); + mMyWakeLock.reset(); // in case there's a leak + } + + private synchronized void onConnectivityChanged(NetworkInfo info) { + // We only care about the default network, and getActiveNetworkInfo() + // is the only way to distinguish them. However, as broadcasts are + // delivered asynchronously, we might miss DISCONNECTED events from + // getActiveNetworkInfo(), which is critical to our SIP stack. To + // solve this, if it is a DISCONNECTED event to our current network, + // respect it. Otherwise get a new one from getActiveNetworkInfo(). + if (info == null || info.isConnected() || info.getType() != mNetworkType) { + ConnectivityManager cm = (ConnectivityManager) + mContext.getSystemService(Context.CONNECTIVITY_SERVICE); + info = cm.getActiveNetworkInfo(); + } + + // Some devices limit SIP on Wi-Fi. In this case, if we are not on + // Wi-Fi, treat it as a DISCONNECTED event. + int networkType = (info != null && info.isConnected()) ? info.getType() : -1; + if (mSipOnWifiOnly && networkType != ConnectivityManager.TYPE_WIFI) { + networkType = -1; + } + + // Ignore the event if the current active network is not changed. + if (mNetworkType == networkType) { + // TODO: Maybe we need to send seq/generation number + return; + } + if (DBG) { + log("onConnectivityChanged: " + mNetworkType + + " -> " + networkType); + } + + try { + if (mNetworkType != -1) { + mLocalIp = null; + stopPortMappingMeasurement(); + for (SipSessionGroupExt group : mSipGroups.values()) { + group.onConnectivityChanged(false); + } + } + mNetworkType = networkType; + + if (mNetworkType != -1) { + mLocalIp = determineLocalIp(); + mKeepAliveInterval = -1; + mLastGoodKeepAliveInterval = DEFAULT_KEEPALIVE_INTERVAL; + for (SipSessionGroupExt group : mSipGroups.values()) { + group.onConnectivityChanged(true); + } + } + updateWakeLocks(); + } catch (SipException e) { + loge("onConnectivityChanged()", e); + } + } + + private static Looper createLooper() { + HandlerThread thread = new HandlerThread("SipService.Executor"); + thread.start(); + return thread.getLooper(); + } + + // Executes immediate tasks in a single thread. + // Hold/release wake lock for running tasks + private class MyExecutor extends Handler implements Executor { + MyExecutor() { + super(createLooper()); + } + + @Override + public void execute(Runnable task) { + mMyWakeLock.acquire(task); + Message.obtain(this, 0/* don't care */, task).sendToTarget(); + } + + @Override + public void handleMessage(Message msg) { + if (msg.obj instanceof Runnable) { + executeInternal((Runnable) msg.obj); + } else { + if (DBG) log("handleMessage: not Runnable ignore msg=" + msg); + } + } + + private void executeInternal(Runnable task) { + try { + task.run(); + } catch (Throwable t) { + loge("run task: " + task, t); + } finally { + mMyWakeLock.release(task); + } + } + } + + private void log(String s) { + Rlog.d(TAG, s); + } + + private static void slog(String s) { + Rlog.d(TAG, s); + } + + private void loge(String s, Throwable e) { + Rlog.e(TAG, s, e); + } +} diff --git a/java/com/android/server/sip/SipSessionGroup.java b/java/com/android/server/sip/SipSessionGroup.java new file mode 100644 index 0000000..e820f35 --- /dev/null +++ b/java/com/android/server/sip/SipSessionGroup.java @@ -0,0 +1,1863 @@ +/* + * 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: " + 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); + } +} diff --git a/java/com/android/server/sip/SipSessionListenerProxy.java b/java/com/android/server/sip/SipSessionListenerProxy.java new file mode 100644 index 0000000..7a4ae8d --- /dev/null +++ b/java/com/android/server/sip/SipSessionListenerProxy.java @@ -0,0 +1,265 @@ +/* + * 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 android.net.sip.ISipSession; +import android.net.sip.ISipSessionListener; +import android.net.sip.SipProfile; +import android.os.DeadObjectException; +import android.telephony.Rlog; + +/** Class to help safely run a callback in a different thread. */ +class SipSessionListenerProxy extends ISipSessionListener.Stub { + private static final String TAG = "SipSessionListnerProxy"; + + private ISipSessionListener mListener; + + public void setListener(ISipSessionListener listener) { + mListener = listener; + } + + public ISipSessionListener getListener() { + return mListener; + } + + 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, "SipSessionCallbackThread").start(); + } + + @Override + public void onCalling(final ISipSession session) { + if (mListener == null) return; + proxy(new Runnable() { + @Override + public void run() { + try { + mListener.onCalling(session); + } catch (Throwable t) { + handle(t, "onCalling()"); + } + } + }); + } + + @Override + public void onRinging(final ISipSession session, final SipProfile caller, + final String sessionDescription) { + if (mListener == null) return; + proxy(new Runnable() { + @Override + public void run() { + try { + mListener.onRinging(session, caller, sessionDescription); + } catch (Throwable t) { + handle(t, "onRinging()"); + } + } + }); + } + + @Override + public void onRingingBack(final ISipSession session) { + if (mListener == null) return; + proxy(new Runnable() { + @Override + public void run() { + try { + mListener.onRingingBack(session); + } catch (Throwable t) { + handle(t, "onRingingBack()"); + } + } + }); + } + + @Override + public void onCallEstablished(final ISipSession session, + final String sessionDescription) { + if (mListener == null) return; + proxy(new Runnable() { + @Override + public void run() { + try { + mListener.onCallEstablished(session, sessionDescription); + } catch (Throwable t) { + handle(t, "onCallEstablished()"); + } + } + }); + } + + @Override + public void onCallEnded(final ISipSession session) { + if (mListener == null) return; + proxy(new Runnable() { + @Override + public void run() { + try { + mListener.onCallEnded(session); + } catch (Throwable t) { + handle(t, "onCallEnded()"); + } + } + }); + } + + @Override + public void onCallTransferring(final ISipSession newSession, + final String sessionDescription) { + if (mListener == null) return; + proxy(new Runnable() { + @Override + public void run() { + try { + mListener.onCallTransferring(newSession, sessionDescription); + } catch (Throwable t) { + handle(t, "onCallTransferring()"); + } + } + }); + } + + @Override + public void onCallBusy(final ISipSession session) { + if (mListener == null) return; + proxy(new Runnable() { + @Override + public void run() { + try { + mListener.onCallBusy(session); + } catch (Throwable t) { + handle(t, "onCallBusy()"); + } + } + }); + } + + @Override + public void onCallChangeFailed(final ISipSession session, + final int errorCode, final String message) { + if (mListener == null) return; + proxy(new Runnable() { + @Override + public void run() { + try { + mListener.onCallChangeFailed(session, errorCode, message); + } catch (Throwable t) { + handle(t, "onCallChangeFailed()"); + } + } + }); + } + + @Override + public void onError(final ISipSession session, final int errorCode, + final String message) { + if (mListener == null) return; + proxy(new Runnable() { + @Override + public void run() { + try { + mListener.onError(session, errorCode, message); + } catch (Throwable t) { + handle(t, "onError()"); + } + } + }); + } + + @Override + public void onRegistering(final ISipSession session) { + if (mListener == null) return; + proxy(new Runnable() { + @Override + public void run() { + try { + mListener.onRegistering(session); + } catch (Throwable t) { + handle(t, "onRegistering()"); + } + } + }); + } + + @Override + public void onRegistrationDone(final ISipSession session, + final int duration) { + if (mListener == null) return; + proxy(new Runnable() { + @Override + public void run() { + try { + mListener.onRegistrationDone(session, duration); + } catch (Throwable t) { + handle(t, "onRegistrationDone()"); + } + } + }); + } + + @Override + public void onRegistrationFailed(final ISipSession session, + final int errorCode, final String message) { + if (mListener == null) return; + proxy(new Runnable() { + @Override + public void run() { + try { + mListener.onRegistrationFailed(session, errorCode, message); + } catch (Throwable t) { + handle(t, "onRegistrationFailed()"); + } + } + }); + } + + @Override + public void onRegistrationTimeout(final ISipSession session) { + if (mListener == null) return; + proxy(new Runnable() { + @Override + public void run() { + try { + mListener.onRegistrationTimeout(session); + } catch (Throwable t) { + handle(t, "onRegistrationTimeout()"); + } + } + }); + } + + private void handle(Throwable t, String message) { + if (t instanceof DeadObjectException) { + mListener = null; + // This creates race but it's harmless. Just don't log the error + // when it happens. + } else if (mListener != null) { + loge(message, t); + } + } + + private void log(String s) { + Rlog.d(TAG, s); + } + + private void loge(String s, Throwable t) { + Rlog.e(TAG, s, t); + } +} diff --git a/java/com/android/server/sip/SipWakeLock.java b/java/com/android/server/sip/SipWakeLock.java new file mode 100644 index 0000000..b3fbb56 --- /dev/null +++ b/java/com/android/server/sip/SipWakeLock.java @@ -0,0 +1,73 @@ +/* + * 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 android.os.PowerManager; +import android.telephony.Rlog; + +import java.util.HashSet; + +class SipWakeLock { + private static final String TAG = "SipWakeLock"; + private static final boolean DBG = false; + private PowerManager mPowerManager; + private PowerManager.WakeLock mWakeLock; + private PowerManager.WakeLock mTimerWakeLock; + private HashSet<Object> mHolders = new HashSet<Object>(); + + SipWakeLock(PowerManager powerManager) { + mPowerManager = powerManager; + } + + synchronized void reset() { + if (DBG) log("reset count=" + mHolders.size()); + mHolders.clear(); + release(null); + } + + synchronized void acquire(long timeout) { + if (mTimerWakeLock == null) { + mTimerWakeLock = mPowerManager.newWakeLock( + PowerManager.PARTIAL_WAKE_LOCK, "SipWakeLock.timer"); + mTimerWakeLock.setReferenceCounted(true); + } + mTimerWakeLock.acquire(timeout); + } + + synchronized void acquire(Object holder) { + mHolders.add(holder); + if (mWakeLock == null) { + mWakeLock = mPowerManager.newWakeLock( + PowerManager.PARTIAL_WAKE_LOCK, "SipWakeLock"); + } + if (!mWakeLock.isHeld()) mWakeLock.acquire(); + if (DBG) log("acquire count=" + mHolders.size()); + } + + synchronized void release(Object holder) { + mHolders.remove(holder); + if ((mWakeLock != null) && mHolders.isEmpty() + && mWakeLock.isHeld()) { + mWakeLock.release(); + } + if (DBG) log("release count=" + mHolders.size()); + } + + private void log(String s) { + Rlog.d(TAG, s); + } +} diff --git a/java/com/android/server/sip/SipWakeupTimer.java b/java/com/android/server/sip/SipWakeupTimer.java new file mode 100644 index 0000000..3ba4331 --- /dev/null +++ b/java/com/android/server/sip/SipWakeupTimer.java @@ -0,0 +1,336 @@ +/* + * Copyright (C) 2011, 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 android.app.AlarmManager; +import android.app.PendingIntent; +import android.content.BroadcastReceiver; +import android.content.Context; +import android.content.Intent; +import android.content.IntentFilter; +import android.os.SystemClock; +import android.telephony.Rlog; + +import java.util.Comparator; +import java.util.Iterator; +import java.util.TreeSet; +import java.util.concurrent.Executor; + +/** + * Timer that can schedule events to occur even when the device is in sleep. + */ +class SipWakeupTimer extends BroadcastReceiver { + private static final String TAG = "SipWakeupTimer"; + private static final boolean DBG = SipService.DBG && true; // STOPSHIP if true + private static final String TRIGGER_TIME = "TriggerTime"; + + private Context mContext; + private AlarmManager mAlarmManager; + + // runnable --> time to execute in SystemClock + private TreeSet<MyEvent> mEventQueue = + new TreeSet<MyEvent>(new MyEventComparator()); + + private PendingIntent mPendingIntent; + + private Executor mExecutor; + + public SipWakeupTimer(Context context, Executor executor) { + mContext = context; + mAlarmManager = (AlarmManager) + context.getSystemService(Context.ALARM_SERVICE); + + IntentFilter filter = new IntentFilter(getAction()); + context.registerReceiver(this, filter); + mExecutor = executor; + } + + /** + * Stops the timer. No event can be scheduled after this method is called. + */ + public synchronized void stop() { + mContext.unregisterReceiver(this); + if (mPendingIntent != null) { + mAlarmManager.cancel(mPendingIntent); + mPendingIntent = null; + } + mEventQueue.clear(); + mEventQueue = null; + } + + private boolean stopped() { + if (mEventQueue == null) { + if (DBG) log("Timer stopped"); + return true; + } else { + return false; + } + } + + private void cancelAlarm() { + mAlarmManager.cancel(mPendingIntent); + mPendingIntent = null; + } + + private void recalculatePeriods() { + if (mEventQueue.isEmpty()) return; + + MyEvent firstEvent = mEventQueue.first(); + int minPeriod = firstEvent.mMaxPeriod; + long minTriggerTime = firstEvent.mTriggerTime; + for (MyEvent e : mEventQueue) { + e.mPeriod = e.mMaxPeriod / minPeriod * minPeriod; + int interval = (int) (e.mLastTriggerTime + e.mMaxPeriod + - minTriggerTime); + interval = interval / minPeriod * minPeriod; + e.mTriggerTime = minTriggerTime + interval; + } + TreeSet<MyEvent> newQueue = new TreeSet<MyEvent>( + mEventQueue.comparator()); + newQueue.addAll(mEventQueue); + mEventQueue.clear(); + mEventQueue = newQueue; + if (DBG) { + log("queue re-calculated"); + printQueue(); + } + } + + // Determines the period and the trigger time of the new event and insert it + // to the queue. + private void insertEvent(MyEvent event) { + long now = SystemClock.elapsedRealtime(); + if (mEventQueue.isEmpty()) { + event.mTriggerTime = now + event.mPeriod; + mEventQueue.add(event); + return; + } + MyEvent firstEvent = mEventQueue.first(); + int minPeriod = firstEvent.mPeriod; + if (minPeriod <= event.mMaxPeriod) { + event.mPeriod = event.mMaxPeriod / minPeriod * minPeriod; + int interval = event.mMaxPeriod; + interval -= (int) (firstEvent.mTriggerTime - now); + interval = interval / minPeriod * minPeriod; + event.mTriggerTime = firstEvent.mTriggerTime + interval; + mEventQueue.add(event); + } else { + long triggerTime = now + event.mPeriod; + if (firstEvent.mTriggerTime < triggerTime) { + event.mTriggerTime = firstEvent.mTriggerTime; + event.mLastTriggerTime -= event.mPeriod; + } else { + event.mTriggerTime = triggerTime; + } + mEventQueue.add(event); + recalculatePeriods(); + } + } + + /** + * Sets a periodic timer. + * + * @param period the timer period; in milli-second + * @param callback is called back when the timer goes off; the same callback + * can be specified in multiple timer events + */ + public synchronized void set(int period, Runnable callback) { + if (stopped()) return; + + long now = SystemClock.elapsedRealtime(); + MyEvent event = new MyEvent(period, callback, now); + insertEvent(event); + + if (mEventQueue.first() == event) { + if (mEventQueue.size() > 1) cancelAlarm(); + scheduleNext(); + } + + long triggerTime = event.mTriggerTime; + if (DBG) { + log("set: add event " + event + " scheduled on " + + showTime(triggerTime) + " at " + showTime(now) + + ", #events=" + mEventQueue.size()); + printQueue(); + } + } + + /** + * Cancels all the timer events with the specified callback. + * + * @param callback the callback + */ + public synchronized void cancel(Runnable callback) { + if (stopped() || mEventQueue.isEmpty()) return; + if (DBG) log("cancel:" + callback); + + MyEvent firstEvent = mEventQueue.first(); + for (Iterator<MyEvent> iter = mEventQueue.iterator(); + iter.hasNext();) { + MyEvent event = iter.next(); + if (event.mCallback == callback) { + iter.remove(); + if (DBG) log(" cancel found:" + event); + } + } + if (mEventQueue.isEmpty()) { + cancelAlarm(); + } else if (mEventQueue.first() != firstEvent) { + cancelAlarm(); + firstEvent = mEventQueue.first(); + firstEvent.mPeriod = firstEvent.mMaxPeriod; + firstEvent.mTriggerTime = firstEvent.mLastTriggerTime + + firstEvent.mPeriod; + recalculatePeriods(); + scheduleNext(); + } + if (DBG) { + log("cancel: X"); + printQueue(); + } + } + + private void scheduleNext() { + if (stopped() || mEventQueue.isEmpty()) return; + + if (mPendingIntent != null) { + throw new RuntimeException("pendingIntent is not null!"); + } + + MyEvent event = mEventQueue.first(); + Intent intent = new Intent(getAction()); + intent.putExtra(TRIGGER_TIME, event.mTriggerTime); + PendingIntent pendingIntent = mPendingIntent = + PendingIntent.getBroadcast(mContext, 0, intent, + PendingIntent.FLAG_UPDATE_CURRENT); + mAlarmManager.set(AlarmManager.ELAPSED_REALTIME_WAKEUP, + event.mTriggerTime, pendingIntent); + } + + @Override + public synchronized void onReceive(Context context, Intent intent) { + // This callback is already protected by AlarmManager's wake lock. + String action = intent.getAction(); + if (getAction().equals(action) + && intent.getExtras().containsKey(TRIGGER_TIME)) { + mPendingIntent = null; + long triggerTime = intent.getLongExtra(TRIGGER_TIME, -1L); + execute(triggerTime); + } else { + log("onReceive: unrecognized intent: " + intent); + } + } + + private void printQueue() { + int count = 0; + for (MyEvent event : mEventQueue) { + log(" " + event + ": scheduled at " + + showTime(event.mTriggerTime) + ": last at " + + showTime(event.mLastTriggerTime)); + if (++count >= 5) break; + } + if (mEventQueue.size() > count) { + log(" ....."); + } else if (count == 0) { + log(" <empty>"); + } + } + + private void execute(long triggerTime) { + if (DBG) log("time's up, triggerTime = " + + showTime(triggerTime) + ": " + mEventQueue.size()); + if (stopped() || mEventQueue.isEmpty()) return; + + for (MyEvent event : mEventQueue) { + if (event.mTriggerTime != triggerTime) continue; + if (DBG) log("execute " + event); + + event.mLastTriggerTime = triggerTime; + event.mTriggerTime += event.mPeriod; + + // run the callback in the handler thread to prevent deadlock + mExecutor.execute(event.mCallback); + } + if (DBG) { + log("after timeout execution"); + printQueue(); + } + scheduleNext(); + } + + private String getAction() { + return toString(); + } + + private String showTime(long time) { + int ms = (int) (time % 1000); + int s = (int) (time / 1000); + int m = s / 60; + s %= 60; + return String.format("%d.%d.%d", m, s, ms); + } + + private static class MyEvent { + int mPeriod; + int mMaxPeriod; + long mTriggerTime; + long mLastTriggerTime; + Runnable mCallback; + + MyEvent(int period, Runnable callback, long now) { + mPeriod = mMaxPeriod = period; + mCallback = callback; + mLastTriggerTime = now; + } + + @Override + public String toString() { + String s = super.toString(); + s = s.substring(s.indexOf("@")); + return s + ":" + (mPeriod / 1000) + ":" + (mMaxPeriod / 1000) + ":" + + toString(mCallback); + } + + private String toString(Object o) { + String s = o.toString(); + int index = s.indexOf("$"); + if (index > 0) s = s.substring(index + 1); + return s; + } + } + + // Sort the events by mMaxPeriod so that the first event can be used to + // align events with larger periods + private static class MyEventComparator implements Comparator<MyEvent> { + @Override + public int compare(MyEvent e1, MyEvent e2) { + if (e1 == e2) return 0; + int diff = e1.mMaxPeriod - e2.mMaxPeriod; + if (diff == 0) diff = -1; + return diff; + } + + @Override + public boolean equals(Object that) { + return (this == that); + } + } + + private void log(String s) { + Rlog.d(TAG, s); + } +} diff --git a/jni/rtp/AmrCodec.cpp b/jni/rtp/AmrCodec.cpp new file mode 100644 index 0000000..e2d820e --- /dev/null +++ b/jni/rtp/AmrCodec.cpp @@ -0,0 +1,272 @@ +/* + * Copyrightm (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. + */ + +#include <string.h> + +#include "AudioCodec.h" + +#include "gsmamr_dec.h" +#include "gsmamr_enc.h" + +namespace { + +const int gFrameBits[8] = {95, 103, 118, 134, 148, 159, 204, 244}; + +//------------------------------------------------------------------------------ + +// See RFC 4867 for the encoding details. + +class AmrCodec : public AudioCodec +{ +public: + AmrCodec() { + if (AMREncodeInit(&mEncoder, &mSidSync, false)) { + mEncoder = NULL; + } + if (GSMInitDecode(&mDecoder, (Word8 *)"RTP")) { + mDecoder = NULL; + } + } + + ~AmrCodec() { + if (mEncoder) { + AMREncodeExit(&mEncoder, &mSidSync); + } + if (mDecoder) { + GSMDecodeFrameExit(&mDecoder); + } + } + + int set(int sampleRate, const char *fmtp); + int encode(void *payload, int16_t *samples); + int decode(int16_t *samples, int count, void *payload, int length); + +private: + void *mEncoder; + void *mSidSync; + void *mDecoder; + + int mMode; + int mModeSet; + bool mOctetAligned; +}; + +int AmrCodec::set(int sampleRate, const char *fmtp) +{ + // These parameters are not supported. + if (strcasestr(fmtp, "crc=1") || strcasestr(fmtp, "robust-sorting=1") || + strcasestr(fmtp, "interleaving=")) { + return -1; + } + + // Handle mode-set and octet-align. + const char *modes = strcasestr(fmtp, "mode-set="); + if (modes) { + mMode = 0; + mModeSet = 0; + for (char c = *modes; c && c != ' '; c = *++modes) { + if (c >= '0' && c <= '7') { + int mode = c - '0'; + if (mode > mMode) { + mMode = mode; + } + mModeSet |= 1 << mode; + } + } + } else { + mMode = 7; + mModeSet = 0xFF; + } + mOctetAligned = (strcasestr(fmtp, "octet-align=1") != NULL); + + // TODO: handle mode-change-*. + + return (sampleRate == 8000 && mEncoder && mDecoder) ? 160 : -1; +} + +int AmrCodec::encode(void *payload, int16_t *samples) +{ + unsigned char *bytes = (unsigned char *)payload; + Frame_Type_3GPP type; + + int length = AMREncode(mEncoder, mSidSync, (Mode)mMode, + samples, bytes + 1, &type, AMR_TX_WMF); + + if (type != mMode || length != (8 + gFrameBits[mMode] + 7) >> 3) { + return -1; + } + + if (mOctetAligned) { + bytes[0] = 0xF0; + bytes[1] = (mMode << 3) | 0x04; + ++length; + } else { + // CMR = 15 (4-bit), F = 0 (1-bit), FT = mMode (4-bit), Q = 1 (1-bit). + bytes[0] = 0xFF; + bytes[1] = 0xC0 | (mMode << 1) | 1; + + // Shift left 6 bits and update the length. + bytes[length + 1] = 0; + for (int i = 0; i <= length; ++i) { + bytes[i] = (bytes[i] << 6) | (bytes[i + 1] >> 2); + } + length = (10 + gFrameBits[mMode] + 7) >> 3; + } + return length; +} + +int AmrCodec::decode(int16_t *samples, int count, void *payload, int length) +{ + unsigned char *bytes = (unsigned char *)payload; + Frame_Type_3GPP type; + if (length < 2) { + return -1; + } + int request = bytes[0] >> 4; + + if (mOctetAligned) { + if ((bytes[1] & 0xC4) != 0x04) { + return -1; + } + type = (Frame_Type_3GPP)(bytes[1] >> 3); + if (length != (16 + gFrameBits[type] + 7) >> 3) { + return -1; + } + length -= 2; + bytes += 2; + } else { + if ((bytes[0] & 0x0C) || !(bytes[1] & 0x40)) { + return -1; + } + type = (Frame_Type_3GPP)((bytes[0] << 1 | bytes[1] >> 7) & 0x07); + if (length != (10 + gFrameBits[type] + 7) >> 3) { + return -1; + } + + // Shift left 2 bits and update the length. + --length; + for (int i = 1; i < length; ++i) { + bytes[i] = (bytes[i] << 2) | (bytes[i + 1] >> 6); + } + bytes[length] <<= 2; + length = (gFrameBits[type] + 7) >> 3; + ++bytes; + } + + if (AMRDecode(mDecoder, type, bytes, samples, MIME_IETF) != length) { + return -1; + } + + // Handle CMR + if (request < 8 && request != mMode) { + for (int i = request; i >= 0; --i) { + if (mModeSet & (1 << i)) { + mMode = request; + break; + } + } + } + + return 160; +} + +//------------------------------------------------------------------------------ + +// See RFC 3551 for the encoding details. + +class GsmEfrCodec : public AudioCodec +{ +public: + GsmEfrCodec() { + if (AMREncodeInit(&mEncoder, &mSidSync, false)) { + mEncoder = NULL; + } + if (GSMInitDecode(&mDecoder, (Word8 *)"RTP")) { + mDecoder = NULL; + } + } + + ~GsmEfrCodec() { + if (mEncoder) { + AMREncodeExit(&mEncoder, &mSidSync); + } + if (mDecoder) { + GSMDecodeFrameExit(&mDecoder); + } + } + + int set(int sampleRate, const char *fmtp) { + return (sampleRate == 8000 && mEncoder && mDecoder) ? 160 : -1; + } + + int encode(void *payload, int16_t *samples); + int decode(int16_t *samples, int count, void *payload, int length); + +private: + void *mEncoder; + void *mSidSync; + void *mDecoder; +}; + +int GsmEfrCodec::encode(void *payload, int16_t *samples) +{ + unsigned char *bytes = (unsigned char *)payload; + Frame_Type_3GPP type; + + int length = AMREncode(mEncoder, mSidSync, MR122, + samples, bytes, &type, AMR_TX_WMF); + + if (type == AMR_122 && length == 32) { + bytes[0] = 0xC0 | (bytes[1] >> 4); + for (int i = 1; i < 31; ++i) { + bytes[i] = (bytes[i] << 4) | (bytes[i + 1] >> 4); + } + return 31; + } + return -1; +} + +int GsmEfrCodec::decode(int16_t *samples, int count, void *payload, int length) +{ + unsigned char *bytes = (unsigned char *)payload; + int n = 0; + while (n + 160 <= count && length >= 31 && (bytes[0] >> 4) == 0x0C) { + for (int i = 0; i < 30; ++i) { + bytes[i] = (bytes[i] << 4) | (bytes[i + 1] >> 4); + } + bytes[30] <<= 4; + + if (AMRDecode(mDecoder, AMR_122, bytes, &samples[n], MIME_IETF) != 31) { + break; + } + n += 160; + length -= 31; + bytes += 31; + } + return n; +} + +} // namespace + +AudioCodec *newAmrCodec() +{ + return new AmrCodec; +} + +AudioCodec *newGsmEfrCodec() +{ + return new GsmEfrCodec; +} diff --git a/jni/rtp/Android.mk b/jni/rtp/Android.mk new file mode 100644 index 0000000..b265cdd --- /dev/null +++ b/jni/rtp/Android.mk @@ -0,0 +1,59 @@ +# +# 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. +# + +LOCAL_PATH := $(call my-dir) +include $(CLEAR_VARS) + +LOCAL_MODULE := librtp_jni + +LOCAL_SRC_FILES := \ + AudioCodec.cpp \ + AudioGroup.cpp \ + EchoSuppressor.cpp \ + RtpStream.cpp \ + util.cpp \ + rtp_jni.cpp + +LOCAL_SRC_FILES += \ + AmrCodec.cpp \ + G711Codec.cpp \ + GsmCodec.cpp + +LOCAL_SHARED_LIBRARIES := \ + libnativehelper \ + libcutils \ + libutils \ + libmedia \ + libstagefright_amrnb_common + +LOCAL_STATIC_LIBRARIES := libgsm libstagefright_amrnbdec libstagefright_amrnbenc + +LOCAL_C_INCLUDES += \ + $(JNI_H_INCLUDE) \ + external/libgsm/inc \ + frameworks/av/media/libstagefright/codecs/amrnb/common/include \ + frameworks/av/media/libstagefright/codecs/amrnb/common/ \ + frameworks/av/media/libstagefright/codecs/amrnb/enc/include \ + frameworks/av/media/libstagefright/codecs/amrnb/enc/src \ + frameworks/av/media/libstagefright/codecs/amrnb/dec/include \ + frameworks/av/media/libstagefright/codecs/amrnb/dec/src \ + $(call include-path-for, audio-effects) + +LOCAL_CFLAGS += -fvisibility=hidden + + + +include $(BUILD_SHARED_LIBRARY) diff --git a/jni/rtp/AudioCodec.cpp b/jni/rtp/AudioCodec.cpp new file mode 100644 index 0000000..c75fbc9 --- /dev/null +++ b/jni/rtp/AudioCodec.cpp @@ -0,0 +1,51 @@ +/* + * Copyrightm (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. + */ + +#include <strings.h> + +#include "AudioCodec.h" + +extern AudioCodec *newAlawCodec(); +extern AudioCodec *newUlawCodec(); +extern AudioCodec *newGsmCodec(); +extern AudioCodec *newAmrCodec(); +extern AudioCodec *newGsmEfrCodec(); + +struct AudioCodecType { + const char *name; + AudioCodec *(*create)(); +} gAudioCodecTypes[] = { + {"PCMA", newAlawCodec}, + {"PCMU", newUlawCodec}, + {"GSM", newGsmCodec}, + {"AMR", newAmrCodec}, + {"GSM-EFR", newGsmEfrCodec}, + {NULL, NULL}, +}; + +AudioCodec *newAudioCodec(const char *codecName) +{ + AudioCodecType *type = gAudioCodecTypes; + while (type->name != NULL) { + if (strcasecmp(codecName, type->name) == 0) { + AudioCodec *codec = type->create(); + codec->name = type->name; + return codec; + } + ++type; + } + return NULL; +} diff --git a/jni/rtp/AudioCodec.h b/jni/rtp/AudioCodec.h new file mode 100644 index 0000000..741730b --- /dev/null +++ b/jni/rtp/AudioCodec.h @@ -0,0 +1,38 @@ +/* + * Copyrightm (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. + */ + +#include <stdint.h> + +#ifndef __AUDIO_CODEC_H__ +#define __AUDIO_CODEC_H__ + +class AudioCodec +{ +public: + const char *name; + // Needed by destruction through base class pointers. + virtual ~AudioCodec() {} + // Returns sampleCount or non-positive value if unsupported. + virtual int set(int sampleRate, const char *fmtp) = 0; + // Returns the length of payload in bytes. + virtual int encode(void *payload, int16_t *samples) = 0; + // Returns the number of decoded samples. + virtual int decode(int16_t *samples, int count, void *payload, int length) = 0; +}; + +AudioCodec *newAudioCodec(const char *codecName); + +#endif diff --git a/jni/rtp/AudioGroup.cpp b/jni/rtp/AudioGroup.cpp new file mode 100644 index 0000000..2f0829e --- /dev/null +++ b/jni/rtp/AudioGroup.cpp @@ -0,0 +1,1073 @@ +/* + * 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. + */ + +#include <stdio.h> +#include <stdint.h> +#include <string.h> +#include <errno.h> +#include <fcntl.h> +#include <sys/epoll.h> +#include <sys/types.h> +#include <sys/socket.h> +#include <sys/stat.h> +#include <sys/time.h> +#include <time.h> +#include <arpa/inet.h> +#include <netinet/in.h> + +// #define LOG_NDEBUG 0 +#define LOG_TAG "AudioGroup" +#include <cutils/atomic.h> +#include <cutils/properties.h> +#include <utils/Log.h> +#include <utils/Errors.h> +#include <utils/RefBase.h> +#include <utils/threads.h> +#include <utils/SystemClock.h> +#include <media/AudioSystem.h> +#include <media/AudioRecord.h> +#include <media/AudioTrack.h> +#include <media/mediarecorder.h> +#include <media/AudioEffect.h> +#include <audio_effects/effect_aec.h> +#include <system/audio.h> + +#include "jni.h" +#include "JNIHelp.h" + +#include "AudioCodec.h" +#include "EchoSuppressor.h" + +extern int parse(JNIEnv *env, jstring jAddress, int port, sockaddr_storage *ss); + +namespace { + +using namespace android; + +int gRandom = -1; + +// We use a circular array to implement jitter buffer. The simplest way is doing +// a modulo operation on the index while accessing the array. However modulo can +// be expensive on some platforms, such as ARM. Thus we round up the size of the +// array to the nearest power of 2 and then use bitwise-and instead of modulo. +// Currently we make it 2048ms long and assume packet interval is 50ms or less. +// The first 100ms is the place where samples get mixed. The rest is the real +// jitter buffer. For a stream at 8000Hz it takes 32 kilobytes. These numbers +// are chosen by experiments and each of them can be adjusted as needed. + +// Originally a stream does not send packets when it is receive-only or there is +// nothing to mix. However, this causes some problems with certain firewalls and +// proxies. A firewall might remove a port mapping when there is no outgoing +// packet for a preiod of time, and a proxy might wait for incoming packets from +// both sides before start forwarding. To solve these problems, we send out a +// silence packet on the stream for every second. It should be good enough to +// keep the stream alive with relatively low resources. + +// Other notes: +// + We use elapsedRealtime() to get the time. Since we use 32bit variables +// instead of 64bit ones, comparison must be done by subtraction. +// + Sampling rate must be multiple of 1000Hz, and packet length must be in +// milliseconds. No floating points. +// + If we cannot get enough CPU, we drop samples and simulate packet loss. +// + Resampling is not done yet, so streams in one group must use the same rate. +// For the first release only 8000Hz is supported. + +#define BUFFER_SIZE 2048 +#define HISTORY_SIZE 100 +#define MEASURE_BASE 100 +#define MEASURE_PERIOD 5000 +#define DTMF_PERIOD 200 + +class AudioStream +{ +public: + AudioStream(); + ~AudioStream(); + bool set(int mode, int socket, sockaddr_storage *remote, + AudioCodec *codec, int sampleRate, int sampleCount, + int codecType, int dtmfType); + + void sendDtmf(int event); + bool mix(int32_t *output, int head, int tail, int sampleRate); + void encode(int tick, AudioStream *chain); + void decode(int tick); + +private: + enum { + NORMAL = 0, + SEND_ONLY = 1, + RECEIVE_ONLY = 2, + LAST_MODE = 2, + }; + + int mMode; + int mSocket; + sockaddr_storage mRemote; + AudioCodec *mCodec; + uint32_t mCodecMagic; + uint32_t mDtmfMagic; + bool mFixRemote; + + int mTick; + int mSampleRate; + int mSampleCount; + int mInterval; + int mKeepAlive; + + int16_t *mBuffer; + int mBufferMask; + int mBufferHead; + int mBufferTail; + int mLatencyTimer; + int mLatencyScore; + + uint16_t mSequence; + uint32_t mTimestamp; + uint32_t mSsrc; + + int mDtmfEvent; + int mDtmfStart; + + AudioStream *mNext; + + friend class AudioGroup; +}; + +AudioStream::AudioStream() +{ + mSocket = -1; + mCodec = NULL; + mBuffer = NULL; + mNext = NULL; +} + +AudioStream::~AudioStream() +{ + close(mSocket); + delete mCodec; + delete [] mBuffer; + ALOGD("stream[%d] is dead", mSocket); +} + +bool AudioStream::set(int mode, int socket, sockaddr_storage *remote, + AudioCodec *codec, int sampleRate, int sampleCount, + int codecType, int dtmfType) +{ + if (mode < 0 || mode > LAST_MODE) { + return false; + } + mMode = mode; + + mCodecMagic = (0x8000 | codecType) << 16; + mDtmfMagic = (dtmfType == -1) ? 0 : (0x8000 | dtmfType) << 16; + + mTick = elapsedRealtime(); + mSampleRate = sampleRate / 1000; + mSampleCount = sampleCount; + mInterval = mSampleCount / mSampleRate; + + // Allocate jitter buffer. + for (mBufferMask = 8; mBufferMask < mSampleRate; mBufferMask <<= 1); + mBufferMask *= BUFFER_SIZE; + mBuffer = new int16_t[mBufferMask]; + --mBufferMask; + mBufferHead = 0; + mBufferTail = 0; + mLatencyTimer = 0; + mLatencyScore = 0; + + // Initialize random bits. + read(gRandom, &mSequence, sizeof(mSequence)); + read(gRandom, &mTimestamp, sizeof(mTimestamp)); + read(gRandom, &mSsrc, sizeof(mSsrc)); + + mDtmfEvent = -1; + mDtmfStart = 0; + + // Only take over these things when succeeded. + mSocket = socket; + if (codec) { + mRemote = *remote; + mCodec = codec; + + // Here we should never get an private address, but some buggy proxy + // servers do give us one. To solve this, we replace the address when + // the first time we successfully decode an incoming packet. + mFixRemote = false; + if (remote->ss_family == AF_INET) { + unsigned char *address = + (unsigned char *)&((sockaddr_in *)remote)->sin_addr; + if (address[0] == 10 || + (address[0] == 172 && (address[1] >> 4) == 1) || + (address[0] == 192 && address[1] == 168)) { + mFixRemote = true; + } + } + } + + ALOGD("stream[%d] is configured as %s %dkHz %dms mode %d", mSocket, + (codec ? codec->name : "RAW"), mSampleRate, mInterval, mMode); + return true; +} + +void AudioStream::sendDtmf(int event) +{ + if (mDtmfMagic != 0) { + mDtmfEvent = event << 24; + mDtmfStart = mTimestamp + mSampleCount; + } +} + +bool AudioStream::mix(int32_t *output, int head, int tail, int sampleRate) +{ + if (mMode == SEND_ONLY) { + return false; + } + + if (head - mBufferHead < 0) { + head = mBufferHead; + } + if (tail - mBufferTail > 0) { + tail = mBufferTail; + } + if (tail - head <= 0) { + return false; + } + + head *= mSampleRate; + tail *= mSampleRate; + + if (sampleRate == mSampleRate) { + for (int i = head; i - tail < 0; ++i) { + output[i - head] += mBuffer[i & mBufferMask]; + } + } else { + // TODO: implement resampling. + return false; + } + return true; +} + +void AudioStream::encode(int tick, AudioStream *chain) +{ + if (tick - mTick >= mInterval) { + // We just missed the train. Pretend that packets in between are lost. + int skipped = (tick - mTick) / mInterval; + mTick += skipped * mInterval; + mSequence += skipped; + mTimestamp += skipped * mSampleCount; + ALOGV("stream[%d] skips %d packets", mSocket, skipped); + } + + tick = mTick; + mTick += mInterval; + ++mSequence; + mTimestamp += mSampleCount; + + // If there is an ongoing DTMF event, send it now. + if (mMode != RECEIVE_ONLY && mDtmfEvent != -1) { + int duration = mTimestamp - mDtmfStart; + // Make sure duration is reasonable. + if (duration >= 0 && duration < mSampleRate * DTMF_PERIOD) { + duration += mSampleCount; + int32_t buffer[4] = { + htonl(mDtmfMagic | mSequence), + htonl(mDtmfStart), + mSsrc, + htonl(mDtmfEvent | duration), + }; + if (duration >= mSampleRate * DTMF_PERIOD) { + buffer[3] |= htonl(1 << 23); + mDtmfEvent = -1; + } + sendto(mSocket, buffer, sizeof(buffer), MSG_DONTWAIT, + (sockaddr *)&mRemote, sizeof(mRemote)); + return; + } + mDtmfEvent = -1; + } + + int32_t buffer[mSampleCount + 3]; + bool data = false; + if (mMode != RECEIVE_ONLY) { + // Mix all other streams. + memset(buffer, 0, sizeof(buffer)); + while (chain) { + if (chain != this) { + data |= chain->mix(buffer, tick - mInterval, tick, mSampleRate); + } + chain = chain->mNext; + } + } + + int16_t samples[mSampleCount]; + if (data) { + // Saturate into 16 bits. + for (int i = 0; i < mSampleCount; ++i) { + int32_t sample = buffer[i]; + if (sample < -32768) { + sample = -32768; + } + if (sample > 32767) { + sample = 32767; + } + samples[i] = sample; + } + } else { + if ((mTick ^ mKeepAlive) >> 10 == 0) { + return; + } + mKeepAlive = mTick; + memset(samples, 0, sizeof(samples)); + + if (mMode != RECEIVE_ONLY) { + ALOGV("stream[%d] no data", mSocket); + } + } + + if (!mCodec) { + // Special case for device stream. + send(mSocket, samples, sizeof(samples), MSG_DONTWAIT); + return; + } + + // Cook the packet and send it out. + buffer[0] = htonl(mCodecMagic | mSequence); + buffer[1] = htonl(mTimestamp); + buffer[2] = mSsrc; + int length = mCodec->encode(&buffer[3], samples); + if (length <= 0) { + ALOGV("stream[%d] encoder error", mSocket); + return; + } + sendto(mSocket, buffer, length + 12, MSG_DONTWAIT, (sockaddr *)&mRemote, + sizeof(mRemote)); +} + +void AudioStream::decode(int tick) +{ + char c; + if (mMode == SEND_ONLY) { + recv(mSocket, &c, 1, MSG_DONTWAIT); + return; + } + + // Make sure mBufferHead and mBufferTail are reasonable. + if ((unsigned int)(tick + BUFFER_SIZE - mBufferHead) > BUFFER_SIZE * 2) { + mBufferHead = tick - HISTORY_SIZE; + mBufferTail = mBufferHead; + } + + if (tick - mBufferHead > HISTORY_SIZE) { + // Throw away outdated samples. + mBufferHead = tick - HISTORY_SIZE; + if (mBufferTail - mBufferHead < 0) { + mBufferTail = mBufferHead; + } + } + + // Adjust the jitter buffer if the latency keeps larger than the threshold + // in the measurement period. + int score = mBufferTail - tick - MEASURE_BASE; + if (mLatencyScore > score || mLatencyScore <= 0) { + mLatencyScore = score; + mLatencyTimer = tick; + } else if (tick - mLatencyTimer >= MEASURE_PERIOD) { + ALOGV("stream[%d] reduces latency of %dms", mSocket, mLatencyScore); + mBufferTail -= mLatencyScore; + mLatencyScore = -1; + } + + int count = (BUFFER_SIZE - (mBufferTail - mBufferHead)) * mSampleRate; + if (count < mSampleCount) { + // Buffer overflow. Drop the packet. + ALOGV("stream[%d] buffer overflow", mSocket); + recv(mSocket, &c, 1, MSG_DONTWAIT); + return; + } + + // Receive the packet and decode it. + int16_t samples[count]; + if (!mCodec) { + // Special case for device stream. + count = recv(mSocket, samples, sizeof(samples), + MSG_TRUNC | MSG_DONTWAIT) >> 1; + } else { + __attribute__((aligned(4))) uint8_t buffer[2048]; + sockaddr_storage remote; + socklen_t addrlen = sizeof(remote); + + int length = recvfrom(mSocket, buffer, sizeof(buffer), + MSG_TRUNC | MSG_DONTWAIT, (sockaddr *)&remote, &addrlen); + + // Do we need to check SSRC, sequence, and timestamp? They are not + // reliable but at least they can be used to identify duplicates? + if (length < 12 || length > (int)sizeof(buffer) || + (ntohl(*(uint32_t *)buffer) & 0xC07F0000) != mCodecMagic) { + ALOGV("stream[%d] malformed packet", mSocket); + return; + } + int offset = 12 + ((buffer[0] & 0x0F) << 2); + if ((buffer[0] & 0x10) != 0) { + offset += 4 + (ntohs(*(uint16_t *)&buffer[offset + 2]) << 2); + } + if ((buffer[0] & 0x20) != 0) { + length -= buffer[length - 1]; + } + length -= offset; + if (length >= 0) { + length = mCodec->decode(samples, count, &buffer[offset], length); + } + if (length > 0 && mFixRemote) { + mRemote = remote; + mFixRemote = false; + } + count = length; + } + if (count <= 0) { + ALOGV("stream[%d] decoder error", mSocket); + return; + } + + if (tick - mBufferTail > 0) { + // Buffer underrun. Reset the jitter buffer. + ALOGV("stream[%d] buffer underrun", mSocket); + if (mBufferTail - mBufferHead <= 0) { + mBufferHead = tick + mInterval; + mBufferTail = mBufferHead; + } else { + int tail = (tick + mInterval) * mSampleRate; + for (int i = mBufferTail * mSampleRate; i - tail < 0; ++i) { + mBuffer[i & mBufferMask] = 0; + } + mBufferTail = tick + mInterval; + } + } + + // Append to the jitter buffer. + int tail = mBufferTail * mSampleRate; + for (int i = 0; i < count; ++i) { + mBuffer[tail & mBufferMask] = samples[i]; + ++tail; + } + mBufferTail += mInterval; +} + +//------------------------------------------------------------------------------ + +class AudioGroup +{ +public: + AudioGroup(); + ~AudioGroup(); + bool set(int sampleRate, int sampleCount); + + bool setMode(int mode); + bool sendDtmf(int event); + bool add(AudioStream *stream); + bool remove(AudioStream *stream); + bool platformHasAec() { return mPlatformHasAec; } + +private: + enum { + ON_HOLD = 0, + MUTED = 1, + NORMAL = 2, + ECHO_SUPPRESSION = 3, + LAST_MODE = 3, + }; + + bool checkPlatformAec(); + + AudioStream *mChain; + int mEventQueue; + volatile int mDtmfEvent; + + int mMode; + int mSampleRate; + int mSampleCount; + int mDeviceSocket; + bool mPlatformHasAec; + + class NetworkThread : public Thread + { + public: + NetworkThread(AudioGroup *group) : Thread(false), mGroup(group) {} + + bool start() + { + if (run("Network", ANDROID_PRIORITY_AUDIO) != NO_ERROR) { + ALOGE("cannot start network thread"); + return false; + } + return true; + } + + private: + AudioGroup *mGroup; + bool threadLoop(); + }; + sp<NetworkThread> mNetworkThread; + + class DeviceThread : public Thread + { + public: + DeviceThread(AudioGroup *group) : Thread(false), mGroup(group) {} + + bool start() + { + if (run("Device", ANDROID_PRIORITY_AUDIO) != NO_ERROR) { + ALOGE("cannot start device thread"); + return false; + } + return true; + } + + private: + AudioGroup *mGroup; + bool threadLoop(); + }; + sp<DeviceThread> mDeviceThread; +}; + +AudioGroup::AudioGroup() +{ + mMode = ON_HOLD; + mChain = NULL; + mEventQueue = -1; + mDtmfEvent = -1; + mDeviceSocket = -1; + mNetworkThread = new NetworkThread(this); + mDeviceThread = new DeviceThread(this); + mPlatformHasAec = checkPlatformAec(); +} + +AudioGroup::~AudioGroup() +{ + mNetworkThread->requestExitAndWait(); + mDeviceThread->requestExitAndWait(); + close(mEventQueue); + close(mDeviceSocket); + while (mChain) { + AudioStream *next = mChain->mNext; + delete mChain; + mChain = next; + } + ALOGD("group[%d] is dead", mDeviceSocket); +} + +bool AudioGroup::set(int sampleRate, int sampleCount) +{ + mEventQueue = epoll_create(2); + if (mEventQueue == -1) { + ALOGE("epoll_create: %s", strerror(errno)); + return false; + } + + mSampleRate = sampleRate; + mSampleCount = sampleCount; + + // Create device socket. + int pair[2]; + if (socketpair(AF_UNIX, SOCK_DGRAM, 0, pair)) { + ALOGE("socketpair: %s", strerror(errno)); + return false; + } + mDeviceSocket = pair[0]; + + // Create device stream. + mChain = new AudioStream; + if (!mChain->set(AudioStream::NORMAL, pair[1], NULL, NULL, + sampleRate, sampleCount, -1, -1)) { + close(pair[1]); + ALOGE("cannot initialize device stream"); + return false; + } + + // Give device socket a reasonable timeout. + timeval tv; + tv.tv_sec = 0; + tv.tv_usec = 1000 * sampleCount / sampleRate * 500; + if (setsockopt(pair[0], SOL_SOCKET, SO_RCVTIMEO, &tv, sizeof(tv))) { + ALOGE("setsockopt: %s", strerror(errno)); + return false; + } + + // Add device stream into event queue. + epoll_event event; + event.events = EPOLLIN; + event.data.ptr = mChain; + if (epoll_ctl(mEventQueue, EPOLL_CTL_ADD, pair[1], &event)) { + ALOGE("epoll_ctl: %s", strerror(errno)); + return false; + } + + // Anything else? + ALOGD("stream[%d] joins group[%d]", pair[1], pair[0]); + return true; +} + +bool AudioGroup::setMode(int mode) +{ + if (mode < 0 || mode > LAST_MODE) { + return false; + } + // FIXME: temporary code to overcome echo and mic gain issues on herring and tuna boards. + // Must be modified/removed when the root cause of the issue is fixed in the hardware or + // driver + char value[PROPERTY_VALUE_MAX]; + property_get("ro.product.board", value, ""); + if (mode == NORMAL && + (!strcmp(value, "herring") || !strcmp(value, "tuna"))) { + mode = ECHO_SUPPRESSION; + } + if (mMode == mode) { + return true; + } + + mDeviceThread->requestExitAndWait(); + ALOGD("group[%d] switches from mode %d to %d", mDeviceSocket, mMode, mode); + mMode = mode; + return (mode == ON_HOLD) || mDeviceThread->start(); +} + +bool AudioGroup::sendDtmf(int event) +{ + if (event < 0 || event > 15) { + return false; + } + + // DTMF is rarely used, so we try to make it as lightweight as possible. + // Using volatile might be dodgy, but using a pipe or pthread primitives + // or stop-set-restart threads seems too heavy. Will investigate later. + timespec ts; + ts.tv_sec = 0; + ts.tv_nsec = 100000000; + for (int i = 0; mDtmfEvent != -1 && i < 20; ++i) { + nanosleep(&ts, NULL); + } + if (mDtmfEvent != -1) { + return false; + } + mDtmfEvent = event; + nanosleep(&ts, NULL); + return true; +} + +bool AudioGroup::add(AudioStream *stream) +{ + mNetworkThread->requestExitAndWait(); + + epoll_event event; + event.events = EPOLLIN; + event.data.ptr = stream; + if (epoll_ctl(mEventQueue, EPOLL_CTL_ADD, stream->mSocket, &event)) { + ALOGE("epoll_ctl: %s", strerror(errno)); + return false; + } + + stream->mNext = mChain->mNext; + mChain->mNext = stream; + if (!mNetworkThread->start()) { + // Only take over the stream when succeeded. + mChain->mNext = stream->mNext; + return false; + } + + ALOGD("stream[%d] joins group[%d]", stream->mSocket, mDeviceSocket); + return true; +} + +bool AudioGroup::remove(AudioStream *stream) +{ + mNetworkThread->requestExitAndWait(); + + for (AudioStream *chain = mChain; chain->mNext; chain = chain->mNext) { + if (chain->mNext == stream) { + if (epoll_ctl(mEventQueue, EPOLL_CTL_DEL, stream->mSocket, NULL)) { + ALOGE("epoll_ctl: %s", strerror(errno)); + return false; + } + chain->mNext = stream->mNext; + ALOGD("stream[%d] leaves group[%d]", stream->mSocket, mDeviceSocket); + delete stream; + break; + } + } + + // Do not start network thread if there is only one stream. + if (!mChain->mNext || !mNetworkThread->start()) { + return false; + } + return true; +} + +bool AudioGroup::NetworkThread::threadLoop() +{ + AudioStream *chain = mGroup->mChain; + int tick = elapsedRealtime(); + int deadline = tick + 10; + int count = 0; + + for (AudioStream *stream = chain; stream; stream = stream->mNext) { + if (tick - stream->mTick >= 0) { + stream->encode(tick, chain); + } + if (deadline - stream->mTick > 0) { + deadline = stream->mTick; + } + ++count; + } + + int event = mGroup->mDtmfEvent; + if (event != -1) { + for (AudioStream *stream = chain; stream; stream = stream->mNext) { + stream->sendDtmf(event); + } + mGroup->mDtmfEvent = -1; + } + + deadline -= tick; + if (deadline < 1) { + deadline = 1; + } + + epoll_event events[count]; + count = epoll_wait(mGroup->mEventQueue, events, count, deadline); + if (count == -1) { + ALOGE("epoll_wait: %s", strerror(errno)); + return false; + } + for (int i = 0; i < count; ++i) { + ((AudioStream *)events[i].data.ptr)->decode(tick); + } + + return true; +} + +bool AudioGroup::checkPlatformAec() +{ + effect_descriptor_t fxDesc; + uint32_t numFx; + + if (AudioEffect::queryNumberEffects(&numFx) != NO_ERROR) { + return false; + } + for (uint32_t i = 0; i < numFx; i++) { + if (AudioEffect::queryEffect(i, &fxDesc) != NO_ERROR) { + continue; + } + if (memcmp(&fxDesc.type, FX_IID_AEC, sizeof(effect_uuid_t)) == 0) { + return true; + } + } + return false; +} + +bool AudioGroup::DeviceThread::threadLoop() +{ + int mode = mGroup->mMode; + int sampleRate = mGroup->mSampleRate; + int sampleCount = mGroup->mSampleCount; + int deviceSocket = mGroup->mDeviceSocket; + + // Find out the frame count for AudioTrack and AudioRecord. + size_t output = 0; + size_t input = 0; + if (AudioTrack::getMinFrameCount(&output, AUDIO_STREAM_VOICE_CALL, + sampleRate) != NO_ERROR || output <= 0 || + AudioRecord::getMinFrameCount(&input, sampleRate, + AUDIO_FORMAT_PCM_16_BIT, AUDIO_CHANNEL_IN_MONO) != NO_ERROR || input <= 0) { + ALOGE("cannot compute frame count"); + return false; + } + ALOGD("reported frame count: output %d, input %d", output, input); + + if (output < sampleCount * 2) { + output = sampleCount * 2; + } + if (input < sampleCount * 2) { + input = sampleCount * 2; + } + ALOGD("adjusted frame count: output %d, input %d", output, input); + + // Initialize AudioTrack and AudioRecord. + AudioTrack track; + AudioRecord record; + if (track.set(AUDIO_STREAM_VOICE_CALL, sampleRate, AUDIO_FORMAT_PCM_16_BIT, + AUDIO_CHANNEL_OUT_MONO, output) != NO_ERROR || + record.set(AUDIO_SOURCE_VOICE_COMMUNICATION, sampleRate, AUDIO_FORMAT_PCM_16_BIT, + AUDIO_CHANNEL_IN_MONO, input) != NO_ERROR) { + ALOGE("cannot initialize audio device"); + return false; + } + ALOGD("latency: output %d, input %d", track.latency(), record.latency()); + + // Give device socket a reasonable buffer size. + setsockopt(deviceSocket, SOL_SOCKET, SO_RCVBUF, &output, sizeof(output)); + setsockopt(deviceSocket, SOL_SOCKET, SO_SNDBUF, &output, sizeof(output)); + + // Drain device socket. + char c; + while (recv(deviceSocket, &c, 1, MSG_DONTWAIT) == 1); + + // check if platform supports echo cancellation and do not active local echo suppression in + // this case + EchoSuppressor *echo = NULL; + AudioEffect *aec = NULL; + if (mode == ECHO_SUPPRESSION) { + if (mGroup->platformHasAec()) { + aec = new AudioEffect(FX_IID_AEC, + NULL, + 0, + 0, + 0, + record.getSessionId(), + record.getInput()); + status_t status = aec->initCheck(); + if (status == NO_ERROR || status == ALREADY_EXISTS) { + aec->setEnabled(true); + } else { + delete aec; + aec = NULL; + } + } + // Create local echo suppressor if platform AEC cannot be used. + if (aec == NULL) { + echo = new EchoSuppressor(sampleCount, + (track.latency() + record.latency()) * sampleRate / 1000); + } + } + // Start AudioRecord before AudioTrack. This prevents AudioTrack from being + // disabled due to buffer underrun while waiting for AudioRecord. + if (mode != MUTED) { + record.start(); + int16_t one; + record.read(&one, sizeof(one)); + } + track.start(); + + while (!exitPending()) { + int16_t output[sampleCount]; + if (recv(deviceSocket, output, sizeof(output), 0) <= 0) { + memset(output, 0, sizeof(output)); + } + + int16_t input[sampleCount]; + int toWrite = sampleCount; + int toRead = (mode == MUTED) ? 0 : sampleCount; + int chances = 100; + + while (--chances > 0 && (toWrite > 0 || toRead > 0)) { + if (toWrite > 0) { + AudioTrack::Buffer buffer; + buffer.frameCount = toWrite; + + status_t status = track.obtainBuffer(&buffer, 1); + if (status == NO_ERROR) { + int offset = sampleCount - toWrite; + memcpy(buffer.i8, &output[offset], buffer.size); + toWrite -= buffer.frameCount; + track.releaseBuffer(&buffer); + } else if (status != TIMED_OUT && status != WOULD_BLOCK) { + ALOGE("cannot write to AudioTrack"); + goto exit; + } + } + + if (toRead > 0) { + AudioRecord::Buffer buffer; + buffer.frameCount = toRead; + + status_t status = record.obtainBuffer(&buffer, 1); + if (status == NO_ERROR) { + int offset = sampleCount - toRead; + memcpy(&input[offset], buffer.i8, buffer.size); + toRead -= buffer.frameCount; + record.releaseBuffer(&buffer); + } else if (status != TIMED_OUT && status != WOULD_BLOCK) { + ALOGE("cannot read from AudioRecord"); + goto exit; + } + } + } + + if (chances <= 0) { + ALOGW("device loop timeout"); + while (recv(deviceSocket, &c, 1, MSG_DONTWAIT) == 1); + } + + if (mode != MUTED) { + if (echo != NULL) { + ALOGV("echo->run()"); + echo->run(output, input); + } + send(deviceSocket, input, sizeof(input), MSG_DONTWAIT); + } + } + +exit: + delete echo; + delete aec; + return true; +} + +//------------------------------------------------------------------------------ + +static jfieldID gNative; +static jfieldID gMode; + +int add(JNIEnv *env, jobject thiz, jint mode, + jint socket, jstring jRemoteAddress, jint remotePort, + jstring jCodecSpec, jint dtmfType) +{ + AudioCodec *codec = NULL; + AudioStream *stream = NULL; + AudioGroup *group = NULL; + + // Sanity check. + sockaddr_storage remote; + if (parse(env, jRemoteAddress, remotePort, &remote) < 0) { + // Exception already thrown. + return 0; + } + if (!jCodecSpec) { + jniThrowNullPointerException(env, "codecSpec"); + return 0; + } + const char *codecSpec = env->GetStringUTFChars(jCodecSpec, NULL); + if (!codecSpec) { + // Exception already thrown. + return 0; + } + socket = dup(socket); + if (socket == -1) { + jniThrowException(env, "java/lang/IllegalStateException", + "cannot get stream socket"); + return 0; + } + + // Create audio codec. + int codecType = -1; + char codecName[16]; + int sampleRate = -1; + sscanf(codecSpec, "%d %15[^/]%*c%d", &codecType, codecName, &sampleRate); + codec = newAudioCodec(codecName); + int sampleCount = (codec ? codec->set(sampleRate, codecSpec) : -1); + env->ReleaseStringUTFChars(jCodecSpec, codecSpec); + if (sampleCount <= 0) { + jniThrowException(env, "java/lang/IllegalStateException", + "cannot initialize audio codec"); + goto error; + } + + // Create audio stream. + stream = new AudioStream; + if (!stream->set(mode, socket, &remote, codec, sampleRate, sampleCount, + codecType, dtmfType)) { + jniThrowException(env, "java/lang/IllegalStateException", + "cannot initialize audio stream"); + goto error; + } + socket = -1; + codec = NULL; + + // Create audio group. + group = (AudioGroup *)env->GetIntField(thiz, gNative); + if (!group) { + int mode = env->GetIntField(thiz, gMode); + group = new AudioGroup; + if (!group->set(8000, 256) || !group->setMode(mode)) { + jniThrowException(env, "java/lang/IllegalStateException", + "cannot initialize audio group"); + goto error; + } + } + + // Add audio stream into audio group. + if (!group->add(stream)) { + jniThrowException(env, "java/lang/IllegalStateException", + "cannot add audio stream"); + goto error; + } + + // Succeed. + env->SetIntField(thiz, gNative, (int)group); + return (int)stream; + +error: + delete group; + delete stream; + delete codec; + close(socket); + env->SetIntField(thiz, gNative, 0); + return 0; +} + +void remove(JNIEnv *env, jobject thiz, jint stream) +{ + AudioGroup *group = (AudioGroup *)env->GetIntField(thiz, gNative); + if (group) { + if (!stream || !group->remove((AudioStream *)stream)) { + delete group; + env->SetIntField(thiz, gNative, 0); + } + } +} + +void setMode(JNIEnv *env, jobject thiz, jint mode) +{ + AudioGroup *group = (AudioGroup *)env->GetIntField(thiz, gNative); + if (group && !group->setMode(mode)) { + jniThrowException(env, "java/lang/IllegalArgumentException", NULL); + } +} + +void sendDtmf(JNIEnv *env, jobject thiz, jint event) +{ + AudioGroup *group = (AudioGroup *)env->GetIntField(thiz, gNative); + if (group && !group->sendDtmf(event)) { + jniThrowException(env, "java/lang/IllegalArgumentException", NULL); + } +} + +JNINativeMethod gMethods[] = { + {"nativeAdd", "(IILjava/lang/String;ILjava/lang/String;I)I", (void *)add}, + {"nativeRemove", "(I)V", (void *)remove}, + {"nativeSetMode", "(I)V", (void *)setMode}, + {"nativeSendDtmf", "(I)V", (void *)sendDtmf}, +}; + +} // namespace + +int registerAudioGroup(JNIEnv *env) +{ + gRandom = open("/dev/urandom", O_RDONLY); + if (gRandom == -1) { + ALOGE("urandom: %s", strerror(errno)); + return -1; + } + + jclass clazz; + if ((clazz = env->FindClass("android/net/rtp/AudioGroup")) == NULL || + (gNative = env->GetFieldID(clazz, "mNative", "I")) == NULL || + (gMode = env->GetFieldID(clazz, "mMode", "I")) == NULL || + env->RegisterNatives(clazz, gMethods, NELEM(gMethods)) < 0) { + ALOGE("JNI registration failed"); + return -1; + } + return 0; +} diff --git a/jni/rtp/EchoSuppressor.cpp b/jni/rtp/EchoSuppressor.cpp new file mode 100644 index 0000000..e223136 --- /dev/null +++ b/jni/rtp/EchoSuppressor.cpp @@ -0,0 +1,196 @@ +/* + * Copyrightm (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. + */ + +#include <stdio.h> +#include <string.h> +#include <stdint.h> +#include <string.h> +#include <math.h> + +#define LOG_TAG "Echo" +#include <utils/Log.h> + +#include "EchoSuppressor.h" + +// It is very difficult to do echo cancellation at this level due to the lack of +// the timing information of the samples being played and recorded. Therefore, +// for the first release only echo suppression is implemented. + +// The algorithm is derived from the "previous works" summarized in +// A new class of doubletalk detectors based on cross-correlation, +// J Benesty, DR Morgan, JH Cho, IEEE Trans. on Speech and Audio Processing. +// The method proposed in that paper is not used because of its high complexity. + +// It is well known that cross-correlation can be computed using convolution, +// but unfortunately not every mobile processor has a (fast enough) FPU. Thus +// we use integer arithmetic as much as possible and do lots of bookkeeping. +// Again, parameters and thresholds are chosen by experiments. + +EchoSuppressor::EchoSuppressor(int sampleCount, int tailLength) +{ + tailLength += sampleCount * 4; + + int shift = 0; + while ((sampleCount >> shift) > 1 && (tailLength >> shift) > 256) { + ++shift; + } + + mShift = shift + 4; + mScale = 1 << shift; + mSampleCount = sampleCount; + mWindowSize = sampleCount >> shift; + mTailLength = tailLength >> shift; + mRecordLength = tailLength * 2 / sampleCount; + mRecordOffset = 0; + + mXs = new uint16_t[mTailLength + mWindowSize]; + memset(mXs, 0, sizeof(*mXs) * (mTailLength + mWindowSize)); + mXSums = new uint32_t[mTailLength]; + memset(mXSums, 0, sizeof(*mXSums) * mTailLength); + mX2Sums = new uint32_t[mTailLength]; + memset(mX2Sums, 0, sizeof(*mX2Sums) * mTailLength); + mXRecords = new uint16_t[mRecordLength * mWindowSize]; + memset(mXRecords, 0, sizeof(*mXRecords) * mRecordLength * mWindowSize); + + mYSum = 0; + mY2Sum = 0; + mYRecords = new uint32_t[mRecordLength]; + memset(mYRecords, 0, sizeof(*mYRecords) * mRecordLength); + mY2Records = new uint32_t[mRecordLength]; + memset(mY2Records, 0, sizeof(*mY2Records) * mRecordLength); + + mXYSums = new uint32_t[mTailLength]; + memset(mXYSums, 0, sizeof(*mXYSums) * mTailLength); + mXYRecords = new uint32_t[mRecordLength * mTailLength]; + memset(mXYRecords, 0, sizeof(*mXYRecords) * mRecordLength * mTailLength); + + mLastX = 0; + mLastY = 0; + mWeight = 1.0f / (mRecordLength * mWindowSize); +} + +EchoSuppressor::~EchoSuppressor() +{ + delete [] mXs; + delete [] mXSums; + delete [] mX2Sums; + delete [] mXRecords; + delete [] mYRecords; + delete [] mY2Records; + delete [] mXYSums; + delete [] mXYRecords; +} + +void EchoSuppressor::run(int16_t *playbacked, int16_t *recorded) +{ + // Update Xs. + for (int i = mTailLength - 1; i >= 0; --i) { + mXs[i + mWindowSize] = mXs[i]; + } + for (int i = mWindowSize - 1, j = 0; i >= 0; --i, j += mScale) { + uint32_t sum = 0; + for (int k = 0; k < mScale; ++k) { + int32_t x = playbacked[j + k] << 15; + mLastX += x; + sum += ((mLastX >= 0) ? mLastX : -mLastX) >> 15; + mLastX -= (mLastX >> 10) + x; + } + mXs[i] = sum >> mShift; + } + + // Update XSums, X2Sums, and XRecords. + for (int i = mTailLength - mWindowSize - 1; i >= 0; --i) { + mXSums[i + mWindowSize] = mXSums[i]; + mX2Sums[i + mWindowSize] = mX2Sums[i]; + } + uint16_t *xRecords = &mXRecords[mRecordOffset * mWindowSize]; + for (int i = mWindowSize - 1; i >= 0; --i) { + uint16_t x = mXs[i]; + mXSums[i] = mXSums[i + 1] + x - xRecords[i]; + mX2Sums[i] = mX2Sums[i + 1] + x * x - xRecords[i] * xRecords[i]; + xRecords[i] = x; + } + + // Compute Ys. + uint16_t ys[mWindowSize]; + for (int i = mWindowSize - 1, j = 0; i >= 0; --i, j += mScale) { + uint32_t sum = 0; + for (int k = 0; k < mScale; ++k) { + int32_t y = recorded[j + k] << 15; + mLastY += y; + sum += ((mLastY >= 0) ? mLastY : -mLastY) >> 15; + mLastY -= (mLastY >> 10) + y; + } + ys[i] = sum >> mShift; + } + + // Update YSum, Y2Sum, YRecords, and Y2Records. + uint32_t ySum = 0; + uint32_t y2Sum = 0; + for (int i = mWindowSize - 1; i >= 0; --i) { + ySum += ys[i]; + y2Sum += ys[i] * ys[i]; + } + mYSum += ySum - mYRecords[mRecordOffset]; + mY2Sum += y2Sum - mY2Records[mRecordOffset]; + mYRecords[mRecordOffset] = ySum; + mY2Records[mRecordOffset] = y2Sum; + + // Update XYSums and XYRecords. + uint32_t *xyRecords = &mXYRecords[mRecordOffset * mTailLength]; + for (int i = mTailLength - 1; i >= 0; --i) { + uint32_t xySum = 0; + for (int j = mWindowSize - 1; j >= 0; --j) { + xySum += mXs[i + j] * ys[j]; + } + mXYSums[i] += xySum - xyRecords[i]; + xyRecords[i] = xySum; + } + + // Compute correlations. + int latency = 0; + float corr2 = 0.0f; + float varX = 0.0f; + float varY = mY2Sum - mWeight * mYSum * mYSum; + for (int i = mTailLength - 1; i >= 0; --i) { + float cov = mXYSums[i] - mWeight * mXSums[i] * mYSum; + if (cov > 0.0f) { + float varXi = mX2Sums[i] - mWeight * mXSums[i] * mXSums[i]; + float corr2i = cov * cov / (varXi * varY + 1); + if (corr2i > corr2) { + varX = varXi; + corr2 = corr2i; + latency = i; + } + } + } + //ALOGI("corr^2 %.5f, var %8.0f %8.0f, latency %d", corr2, varX, varY, + // latency * mScale); + + // Do echo suppression. + if (corr2 > 0.1f && varX > 10000.0f) { + int factor = (corr2 > 1.0f) ? 0 : (1.0f - sqrtf(corr2)) * 4096; + for (int i = 0; i < mSampleCount; ++i) { + recorded[i] = recorded[i] * factor >> 16; + } + } + + // Increase RecordOffset. + ++mRecordOffset; + if (mRecordOffset == mRecordLength) { + mRecordOffset = 0; + } +} diff --git a/jni/rtp/EchoSuppressor.h b/jni/rtp/EchoSuppressor.h new file mode 100644 index 0000000..2f3b593 --- /dev/null +++ b/jni/rtp/EchoSuppressor.h @@ -0,0 +1,58 @@ +/* + * Copyrightm (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. + */ + +#ifndef __ECHO_SUPPRESSOR_H__ +#define __ECHO_SUPPRESSOR_H__ + +#include <stdint.h> + +class EchoSuppressor +{ +public: + // The sampleCount must be power of 2. + EchoSuppressor(int sampleCount, int tailLength); + ~EchoSuppressor(); + void run(int16_t *playbacked, int16_t *recorded); + +private: + int mShift; + int mScale; + int mSampleCount; + int mWindowSize; + int mTailLength; + int mRecordLength; + int mRecordOffset; + + uint16_t *mXs; + uint32_t *mXSums; + uint32_t *mX2Sums; + uint16_t *mXRecords; + + uint32_t mYSum; + uint32_t mY2Sum; + uint32_t *mYRecords; + uint32_t *mY2Records; + + uint32_t *mXYSums; + uint32_t *mXYRecords; + + int32_t mLastX; + int32_t mLastY; + + float mWeight; +}; + +#endif diff --git a/jni/rtp/G711Codec.cpp b/jni/rtp/G711Codec.cpp new file mode 100644 index 0000000..ef54863 --- /dev/null +++ b/jni/rtp/G711Codec.cpp @@ -0,0 +1,144 @@ +/* + * Copyrightm (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. + */ + +#include "AudioCodec.h" + +namespace { + +const int8_t gExponents[128] = { + 0, 1, 2, 2, 3, 3, 3, 3, 4, 4, 4, 4, 4, 4, 4, 4, + 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, + 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, + 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, + 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, + 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, + 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, + 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, +}; + +//------------------------------------------------------------------------------ + +class UlawCodec : public AudioCodec +{ +public: + int set(int sampleRate, const char *fmtp) { + mSampleCount = sampleRate / 50; + return mSampleCount; + } + int encode(void *payload, int16_t *samples); + int decode(int16_t *samples, int count, void *payload, int length); +private: + int mSampleCount; +}; + +int UlawCodec::encode(void *payload, int16_t *samples) +{ + int8_t *ulaws = (int8_t *)payload; + for (int i = 0; i < mSampleCount; ++i) { + int sample = samples[i]; + int sign = (sample >> 8) & 0x80; + if (sample < 0) { + sample = -sample; + } + sample += 132; + if (sample > 32767) { + sample = 32767; + } + int exponent = gExponents[sample >> 8]; + int mantissa = (sample >> (exponent + 3)) & 0x0F; + ulaws[i] = ~(sign | (exponent << 4) | mantissa); + } + return mSampleCount; +} + +int UlawCodec::decode(int16_t *samples, int count, void *payload, int length) +{ + int8_t *ulaws = (int8_t *)payload; + if (length > count) { + length = count; + } + for (int i = 0; i < length; ++i) { + int ulaw = ~ulaws[i]; + int exponent = (ulaw >> 4) & 0x07; + int mantissa = ulaw & 0x0F; + int sample = (((mantissa << 3) + 132) << exponent) - 132; + samples[i] = (ulaw < 0 ? -sample : sample); + } + return length; +} + +//------------------------------------------------------------------------------ + +class AlawCodec : public AudioCodec +{ +public: + int set(int sampleRate, const char *fmtp) { + mSampleCount = sampleRate / 50; + return mSampleCount; + } + int encode(void *payload, int16_t *samples); + int decode(int16_t *samples, int count, void *payload, int length); +private: + int mSampleCount; +}; + +int AlawCodec::encode(void *payload, int16_t *samples) +{ + int8_t *alaws = (int8_t *)payload; + for (int i = 0; i < mSampleCount; ++i) { + int sample = samples[i]; + int sign = (sample >> 8) & 0x80; + if (sample < 0) { + sample = -sample; + } + if (sample > 32767) { + sample = 32767; + } + int exponent = gExponents[sample >> 8]; + int mantissa = (sample >> (exponent == 0 ? 4 : exponent + 3)) & 0x0F; + alaws[i] = (sign | (exponent << 4) | mantissa) ^ 0xD5; + } + return mSampleCount; +} + +int AlawCodec::decode(int16_t *samples, int count, void *payload, int length) +{ + int8_t *alaws = (int8_t *)payload; + if (length > count) { + length = count; + } + for (int i = 0; i < length; ++i) { + int alaw = alaws[i] ^ 0x55; + int exponent = (alaw >> 4) & 0x07; + int mantissa = alaw & 0x0F; + int sample = (exponent == 0 ? (mantissa << 4) + 8 : + ((mantissa << 3) + 132) << exponent); + samples[i] = (alaw < 0 ? sample : -sample); + } + return length; +} + +} // namespace + +AudioCodec *newUlawCodec() +{ + return new UlawCodec; +} + +AudioCodec *newAlawCodec() +{ + return new AlawCodec; +} diff --git a/jni/rtp/GsmCodec.cpp b/jni/rtp/GsmCodec.cpp new file mode 100644 index 0000000..61dfdc9 --- /dev/null +++ b/jni/rtp/GsmCodec.cpp @@ -0,0 +1,78 @@ +/* + * Copyrightm (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. + */ + +#include "AudioCodec.h" + +extern "C" { +#include "gsm.h" +} + +namespace { + +class GsmCodec : public AudioCodec +{ +public: + GsmCodec() { + mEncode = gsm_create(); + mDecode = gsm_create(); + } + + ~GsmCodec() { + if (mEncode) { + gsm_destroy(mEncode); + } + if (mDecode) { + gsm_destroy(mDecode); + } + } + + int set(int sampleRate, const char *fmtp) { + return (sampleRate == 8000 && mEncode && mDecode) ? 160 : -1; + } + + int encode(void *payload, int16_t *samples); + int decode(int16_t *samples, int count, void *payload, int length); + +private: + gsm mEncode; + gsm mDecode; +}; + +int GsmCodec::encode(void *payload, int16_t *samples) +{ + gsm_encode(mEncode, samples, (unsigned char *)payload); + return 33; +} + +int GsmCodec::decode(int16_t *samples, int count, void *payload, int length) +{ + unsigned char *bytes = (unsigned char *)payload; + int n = 0; + while (n + 160 <= count && length >= 33 && + gsm_decode(mDecode, bytes, &samples[n]) == 0) { + n += 160; + length -= 33; + bytes += 33; + } + return n; +} + +} // namespace + +AudioCodec *newGsmCodec() +{ + return new GsmCodec; +} diff --git a/jni/rtp/RtpStream.cpp b/jni/rtp/RtpStream.cpp new file mode 100644 index 0000000..bfe8e24 --- /dev/null +++ b/jni/rtp/RtpStream.cpp @@ -0,0 +1,113 @@ +/* + * 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. + */ + +#include <stdio.h> +#include <stdint.h> +#include <string.h> +#include <errno.h> +#include <sys/types.h> +#include <sys/socket.h> +#include <arpa/inet.h> +#include <netinet/in.h> + +#define LOG_TAG "RtpStream" +#include <utils/Log.h> + +#include "jni.h" +#include "JNIHelp.h" + +extern int parse(JNIEnv *env, jstring jAddress, int port, sockaddr_storage *ss); + +namespace { + +jfieldID gSocket; + +jint create(JNIEnv *env, jobject thiz, jstring jAddress) +{ + env->SetIntField(thiz, gSocket, -1); + + sockaddr_storage ss; + if (parse(env, jAddress, 0, &ss) < 0) { + // Exception already thrown. + return -1; + } + + int socket = ::socket(ss.ss_family, SOCK_DGRAM, 0); + socklen_t len = sizeof(ss); + if (socket == -1 || bind(socket, (sockaddr *)&ss, sizeof(ss)) != 0 || + getsockname(socket, (sockaddr *)&ss, &len) != 0) { + jniThrowException(env, "java/net/SocketException", strerror(errno)); + ::close(socket); + return -1; + } + + uint16_t *p = (ss.ss_family == AF_INET) ? + &((sockaddr_in *)&ss)->sin_port : &((sockaddr_in6 *)&ss)->sin6_port; + uint16_t port = ntohs(*p); + if ((port & 1) == 0) { + env->SetIntField(thiz, gSocket, socket); + return port; + } + ::close(socket); + + socket = ::socket(ss.ss_family, SOCK_DGRAM, 0); + if (socket != -1) { + uint16_t delta = port << 1; + ++port; + + for (int i = 0; i < 1000; ++i) { + do { + port += delta; + } while (port < 1024); + *p = htons(port); + + if (bind(socket, (sockaddr *)&ss, sizeof(ss)) == 0) { + env->SetIntField(thiz, gSocket, socket); + return port; + } + } + } + + jniThrowException(env, "java/net/SocketException", strerror(errno)); + ::close(socket); + return -1; +} + +void close(JNIEnv *env, jobject thiz) +{ + int socket = env->GetIntField(thiz, gSocket); + ::close(socket); + env->SetIntField(thiz, gSocket, -1); +} + +JNINativeMethod gMethods[] = { + {"create", "(Ljava/lang/String;)I", (void *)create}, + {"close", "()V", (void *)close}, +}; + +} // namespace + +int registerRtpStream(JNIEnv *env) +{ + jclass clazz; + if ((clazz = env->FindClass("android/net/rtp/RtpStream")) == NULL || + (gSocket = env->GetFieldID(clazz, "mSocket", "I")) == NULL || + env->RegisterNatives(clazz, gMethods, NELEM(gMethods)) < 0) { + ALOGE("JNI registration failed"); + return -1; + } + return 0; +} diff --git a/jni/rtp/rtp_jni.cpp b/jni/rtp/rtp_jni.cpp new file mode 100644 index 0000000..9f4bff9 --- /dev/null +++ b/jni/rtp/rtp_jni.cpp @@ -0,0 +1,32 @@ +/* + * 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. + */ + +#include <stdio.h> + +#include "jni.h" + +extern int registerRtpStream(JNIEnv *env); +extern int registerAudioGroup(JNIEnv *env); + +__attribute__((visibility("default"))) jint JNI_OnLoad(JavaVM *vm, void *unused) +{ + JNIEnv *env = NULL; + if (vm->GetEnv((void **)&env, JNI_VERSION_1_4) != JNI_OK || + registerRtpStream(env) < 0 || registerAudioGroup(env) < 0) { + return -1; + } + return JNI_VERSION_1_4; +} diff --git a/jni/rtp/util.cpp b/jni/rtp/util.cpp new file mode 100644 index 0000000..1d702fc --- /dev/null +++ b/jni/rtp/util.cpp @@ -0,0 +1,61 @@ +/* + * 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. + */ + +#include <stdio.h> +#include <string.h> +#include <arpa/inet.h> +#include <netinet/in.h> + +#include "jni.h" +#include "JNIHelp.h" + +int parse(JNIEnv *env, jstring jAddress, int port, sockaddr_storage *ss) +{ + if (!jAddress) { + jniThrowNullPointerException(env, "address"); + return -1; + } + if (port < 0 || port > 65535) { + jniThrowException(env, "java/lang/IllegalArgumentException", "port"); + return -1; + } + const char *address = env->GetStringUTFChars(jAddress, NULL); + if (!address) { + // Exception already thrown. + return -1; + } + memset(ss, 0, sizeof(*ss)); + + sockaddr_in *sin = (sockaddr_in *)ss; + if (inet_pton(AF_INET, address, &(sin->sin_addr)) > 0) { + sin->sin_family = AF_INET; + sin->sin_port = htons(port); + env->ReleaseStringUTFChars(jAddress, address); + return 0; + } + + sockaddr_in6 *sin6 = (sockaddr_in6 *)ss; + if (inet_pton(AF_INET6, address, &(sin6->sin6_addr)) > 0) { + sin6->sin6_family = AF_INET6; + sin6->sin6_port = htons(port); + env->ReleaseStringUTFChars(jAddress, address); + return 0; + } + + env->ReleaseStringUTFChars(jAddress, address); + jniThrowException(env, "java/lang/IllegalArgumentException", "address"); + return -1; +} |
