blob: 77bf3f3a7f2209b885910a4ee425450ad4cd5b32 [file] [log] [blame]
/*
* Copyright (C) 2021 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.services.telephony.rcs;
import android.telephony.ims.SipMessage;
import android.util.ArrayMap;
import android.util.ArraySet;
import android.util.LocalLog;
import android.util.Log;
import com.android.internal.annotations.VisibleForTesting;
import com.android.internal.telephony.SipMessageParsingUtils;
import com.android.internal.util.IndentingPrintWriter;
import java.io.PrintWriter;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collections;
import java.util.List;
import java.util.Set;
import java.util.stream.Collectors;
/**
* Tracks the state of SIP sessions started by a SIP INVITE (see RFC 3261)
* <p>
* Each SIP session created will consist of one or more SIP with, each dialog in the session
* having the same call-ID. Each SIP dialog will be in one of three states: EARLY, CONFIRMED, and
* CLOSED.
* <p>
* The SIP session will be closed once all of the associated dialogs are closed.
*/
public class SipSessionTracker {
private static final String TAG = "SessionT";
/**
* SIP request methods that will start a new SIP Dialog and move it into the PENDING state
* while we wait for a response. Note: INVITE is not the only SIP dialog that will create a
* dialog, however it is the only one that we wish to track for this use case.
*/
public static final String[] SIP_REQUEST_DIALOG_START_METHODS = new String[] { "invite" };
/**
* The SIP request method that will close a SIP Dialog in the ACTIVE state with the same
* Call-Id.
*/
private static final String SIP_CLOSE_DIALOG_REQUEST_METHOD = "bye";
private final LocalLog mLocalLog = new LocalLog(SipTransportController.LOG_SIZE);
private final ArrayList<SipDialog> mTrackedDialogs = new ArrayList<>();
// Operations that are pending an ack from the remote application processing the message before
// they can be applied here. Maps the via header branch parameter of the message to the
// associated pending operation.
private final ArrayMap<String, Runnable> mPendingAck = new ArrayMap<>();
/**
* Filter a SIP message to determine if it will result in a new SIP dialog. This will need to be
* successfully acknowledged by the remote IMS stack using
* {@link #acknowledgePendingMessage(String)} before we do any further processing.
*
* @param message The Incoming SIP message.
*/
public void filterSipMessage(SipMessage message) {
final Runnable r;
if (startsEarlyDialog(message)) {
r = getCreateDialogRunnable(message);
} else if (closesDialog(message)) {
r = getCloseDialogRunnable(message);
} else if (SipMessageParsingUtils.isSipResponse(message.getStartLine())) {
r = getDialogStateChangeRunnable(message);
} else {
r = null;
}
if (r != null) {
if (mPendingAck.containsKey(message.getViaBranchParameter())) {
Runnable lastEvent = mPendingAck.get(message.getViaBranchParameter());
logw("Adding new message when there was already a pending event for branch: "
+ message.getViaBranchParameter());
Runnable concatRunnable = () -> {
// No choice but to concatenate the Runnables together.
if (lastEvent != null) lastEvent.run();
r.run();
};
mPendingAck.put(message.getViaBranchParameter(), concatRunnable);
} else {
mPendingAck.put(message.getViaBranchParameter(), r);
}
}
}
/**
* The pending SIP message has been received by the remote IMS stack. We can now track dialogs
* associated with this message.
* message.
* @param viaBranchId The SIP message's Via header's branch parameter, which is used as a
* unique token.
*/
public void acknowledgePendingMessage(String viaBranchId) {
Runnable r = mPendingAck.get(viaBranchId);
if (r != null) {
mPendingAck.remove(viaBranchId);
r.run();
}
}
/**
* The pending SIP message has failed to be sent to the remote so remove the pending task.
* @param viaBranchId The failed message's Via header's branch parameter.
*/
public void pendingMessageFailed(String viaBranchId) {
mPendingAck.remove(viaBranchId);
}
/**
* A SIP session tracked by the remote application's IMS stack has been closed, so we can stop
* tracking it.
* @param callId The callId of the SIP session that has been closed.
*/
public void cleanupSession(String callId) {
List<SipDialog> dialogsToCleanup = mTrackedDialogs.stream()
.filter(d -> d.getCallId().equals(callId))
.collect(Collectors.toList());
if (dialogsToCleanup.isEmpty()) return;
logi("Cleanup dialogs associated with call id: " + callId);
for (SipDialog d : dialogsToCleanup) {
d.close();
logi("Dialog closed: " + d);
}
mTrackedDialogs.removeAll(dialogsToCleanup);
}
/**
* @return the call IDs of the dialogs associated with the provided feature tags.
*/
public Set<String> getCallIdsAssociatedWithFeatureTag(Set<String> featureTags) {
if (featureTags.isEmpty()) return Collections.emptySet();
Set<String> associatedIds = new ArraySet<>();
for (String featureTag : featureTags) {
for (SipDialog dialog : mTrackedDialogs) {
boolean isAssociated = dialog.getAcceptContactFeatureTags().stream().anyMatch(
d -> d.equalsIgnoreCase(featureTag));
if (isAssociated) associatedIds.add(dialog.getCallId());
}
}
return associatedIds;
}
/**
* @return All dialogs that have not received a final response yet 2XX or 3XX+.
*/
public Set<SipDialog> getEarlyDialogs() {
return mTrackedDialogs.stream().filter(d -> d.getState() == SipDialog.STATE_EARLY)
.collect(Collectors.toSet());
}
/**
* @return All confirmed dialogs that have received a 2XX response and are active.
*/
public Set<SipDialog> getConfirmedDialogs() {
return mTrackedDialogs.stream().filter(d -> d.getState() == SipDialog.STATE_CONFIRMED)
.collect(Collectors.toSet());
}
/**
* @return Dialogs that have been closed via a BYE or 3XX+ response and
* {@link #cleanupSession(String)} has not been called yet.
*/
@VisibleForTesting
public Set<SipDialog> getClosedDialogs() {
return mTrackedDialogs.stream().filter(d -> d.getState() == SipDialog.STATE_CLOSED)
.collect(Collectors.toSet());
}
/**
* @return All of the tracked dialogs, even the ones that have been closed but
* {@link #cleanupSession(String)} has not been called.
*/
public Set<SipDialog> getTrackedDialogs() {
return new ArraySet<>(mTrackedDialogs);
}
/**
* Clears all tracked sessions.
*/
public void clearAllSessions() {
mTrackedDialogs.clear();
mPendingAck.clear();
}
/**
* Dump the state of this tracker to the provided PrintWriter.
*/
public void dump(PrintWriter printWriter) {
IndentingPrintWriter pw = new IndentingPrintWriter(printWriter, " ");
pw.println("SipSessionTracker:");
pw.increaseIndent();
pw.print("Early Call IDs: ");
pw.println(getEarlyDialogs().stream().map(SipDialog::getCallId)
.collect(Collectors.toSet()));
pw.print("Confirmed Call IDs: ");
pw.println(getConfirmedDialogs().stream().map(SipDialog::getCallId)
.collect(Collectors.toSet()));
pw.print("Closed Call IDs: ");
pw.println(getClosedDialogs().stream().map(SipDialog::getCallId)
.collect(Collectors.toSet()));
pw.println("Tracked Dialogs:");
pw.increaseIndent();
for (SipDialog d : mTrackedDialogs) {
pw.println(d);
}
pw.decreaseIndent();
pw.println();
pw.println("Local Logs");
mLocalLog.dump(pw);
pw.decreaseIndent();
}
/**
* @return {@code true}, if the SipMessage passed in should start a new SIP dialog,
* {@code false} if it should not.
*/
private boolean startsEarlyDialog(SipMessage m) {
if (!SipMessageParsingUtils.isSipRequest(m.getStartLine())) {
return false;
}
String[] startLineSegments = SipMessageParsingUtils.splitStartLineAndVerify(
m.getStartLine());
if (startLineSegments == null) {
return false;
}
return Arrays.stream(SIP_REQUEST_DIALOG_START_METHODS)
.anyMatch(r -> r.equalsIgnoreCase(startLineSegments[0]));
}
/**
* @return {@code true}, if the SipMessage passed in should close a confirmed dialog,
* {@code false} if it should not.
*/
private boolean closesDialog(SipMessage m) {
if (!SipMessageParsingUtils.isSipRequest(m.getStartLine())) {
return false;
}
String[] startLineSegments = SipMessageParsingUtils.splitStartLineAndVerify(
m.getStartLine());
if (startLineSegments == null) {
return false;
}
return SIP_CLOSE_DIALOG_REQUEST_METHOD.equalsIgnoreCase(startLineSegments[0]);
}
private Runnable getCreateDialogRunnable(SipMessage m) {
return () -> {
List<SipDialog> duplicateDialogs = mTrackedDialogs.stream()
.filter(d -> d.getCallId().equals(m.getCallIdParameter()))
.collect(Collectors.toList());
if (duplicateDialogs.size() > 0) {
logi("trying to create a dialog for a call ID that already exists, skip: "
+ duplicateDialogs);
return;
}
SipDialog dialog = SipDialog.fromSipMessage(m);
logi("Starting new SipDialog: " + dialog);
mTrackedDialogs.add(dialog);
};
}
private Runnable getCloseDialogRunnable(SipMessage m) {
return () -> {
List<SipDialog> dialogsToClose = mTrackedDialogs.stream()
.filter(d -> d.isRequestAssociatedWithDialog(m))
.collect(Collectors.toList());
if (dialogsToClose.isEmpty()) return;
logi("Closing dialogs associated with: " + m);
for (SipDialog d : dialogsToClose) {
d.close();
logi("Dialog closed: " + d);
}
};
}
private Runnable getDialogStateChangeRunnable(SipMessage m) {
return () -> {
// This will return a dialog and all of its potential forks
List<SipDialog> associatedDialogs = mTrackedDialogs.stream()
.filter(d -> d.isResponseAssociatedWithDialog(m))
.collect(Collectors.toList());
if (associatedDialogs.isEmpty()) return;
String messageToTag = SipMessageParsingUtils.getToTag(m.getHeaderSection());
// If the to tag matches (or message to tag doesn't exist in dialog yet because this is
// the first response), then we are done.
SipDialog match = associatedDialogs.stream()
.filter(d -> d.getToTag() == null || d.getToTag().equals(messageToTag))
.findFirst().orElse(null);
if (match == null) {
// If it doesn't then we have a situation where we need to fork the existing dialog.
// The dialog used to fork doesn't matter, since the required params are the same,
// so simply use the first one in the returned list.
logi("Dialog forked");
match = associatedDialogs.get(0).forkDialog();
mTrackedDialogs.add(match);
}
if (match != null) {
logi("Dialog: " + match + " is associated with: " + m);
updateSipDialogState(match, m);
logi("Dialog state updated to " + match);
} else {
logi("No Dialogs are associated with: " + m);
}
};
}
private void updateSipDialogState(SipDialog d, SipMessage m) {
String[] startLineSegments = SipMessageParsingUtils.splitStartLineAndVerify(
m.getStartLine());
if (startLineSegments == null) {
logw("Could not parse start line for SIP message: " + m.getStartLine());
return;
}
int statusCode = 0;
try {
statusCode = Integer.parseInt(startLineSegments[1]);
} catch (NumberFormatException e) {
logw("Could not parse status code for SIP message: " + m.getStartLine());
return;
}
String toTag = SipMessageParsingUtils.getToTag(m.getHeaderSection());
logi("updateSipDialogState: message has statusCode: " + statusCode + ", and to tag: "
+ toTag);
// If specifically 100 Trying, then do not do anything.
if (statusCode <= 100) return;
// If 300+, then this dialog has received an error response and should move to closed state.
if (statusCode >= 300) {
d.close();
return;
}
if (toTag == null) logw("updateSipDialogState: No to tag for message: " + m);
if (statusCode >= 200) {
d.confirm(toTag);
return;
}
// 1XX responses still require updates to dialogs.
d.earlyResponse(toTag);
}
private void logi(String log) {
Log.w(SipTransportController.LOG_TAG, TAG + ": " + log);
mLocalLog.log("[I] " + log);
}
private void logw(String log) {
Log.w(SipTransportController.LOG_TAG, TAG + ": " + log);
mLocalLog.log("[W] " + log);
}
}