Skip to content

Commit 4a52586

Browse files
committed
Send broadcast events for voicemail available messages (RFC 3842) - closes #236
1 parent 199b882 commit 4a52586

6 files changed

Lines changed: 287 additions & 1 deletion

File tree

sipservice/src/main/java/net/gotev/sipservice/BroadcastEventEmitter.java

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,8 @@ public enum BroadcastAction {
3636
CALL_STATS,
3737
CALL_RECONNECTION_STATE,
3838
SILENT_CALL_STATUS,
39-
NOTIFY_TLS_VERIFY_STATUS_FAILED
39+
NOTIFY_TLS_VERIFY_STATUS_FAILED,
40+
VOICEMAIL_WAITING
4041
}
4142

4243
public BroadcastEventEmitter(Context context) {
@@ -230,6 +231,18 @@ void notifyTlsVerifyStatusFailed() {
230231
sendExplicitBroadcast(intent);
231232
}
232233

234+
/**
235+
* Emit an unsolicited MWI (voicemail waiting) broadcast intent.
236+
*
237+
* @param status the parsed voice-message counts
238+
*/
239+
void voicemailWaiting(VoicemailStatus status) {
240+
final Intent intent = new Intent();
241+
intent.setAction(getAction(BroadcastAction.VOICEMAIL_WAITING));
242+
intent.putExtra(PARAM_VOICEMAIL_STATUS, status);
243+
sendBroadcast(intent);
244+
}
245+
233246
private void sendBroadcast(Intent intent) {
234247
intent.setPackage(mContext.getPackageName());
235248
mContext.sendBroadcast(intent);

sipservice/src/main/java/net/gotev/sipservice/BroadcastEventReceiver.java

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -102,6 +102,9 @@ public void onReceive(Context context, Intent intent) {
102102

103103
} else if (BroadcastEventEmitter.getAction(BroadcastEventEmitter.BroadcastAction.NOTIFY_TLS_VERIFY_STATUS_FAILED).equals(action)) {
104104
onTlsVerifyStatusFailed();
105+
106+
} else if (BroadcastEventEmitter.getAction(BroadcastEventEmitter.BroadcastAction.VOICEMAIL_WAITING).equals(action)) {
107+
onVoicemailWaiting(intent.getParcelableExtra(PARAM_VOICEMAIL_STATUS));
105108
}
106109
}
107110

@@ -147,6 +150,8 @@ public void register(final Context context) {
147150
BroadcastEventEmitter.BroadcastAction.SILENT_CALL_STATUS));
148151
intentFilter.addAction(BroadcastEventEmitter.getAction(
149152
BroadcastEventEmitter.BroadcastAction.NOTIFY_TLS_VERIFY_STATUS_FAILED));
153+
intentFilter.addAction(BroadcastEventEmitter.getAction(
154+
BroadcastEventEmitter.BroadcastAction.VOICEMAIL_WAITING));
150155
if (Build.VERSION.SDK_INT >= 34) {
151156
context.registerReceiver( this, intentFilter, Context.RECEIVER_NOT_EXPORTED);
152157
} else {
@@ -253,4 +258,11 @@ protected void onSilentCallStatus(boolean success, String number) {
253258
protected void onTlsVerifyStatusFailed() {
254259
Logger.debug(LOG_TAG, "TlsVerifyStatusFailed");
255260
}
261+
262+
/**
263+
* Unsolicited MWI NOTIFY received: [status] carries the parsed voice-message counts.
264+
*/
265+
protected void onVoicemailWaiting(VoicemailStatus status) {
266+
Logger.debug(LOG_TAG, "onVoicemailWaiting - " + status);
267+
}
256268
}

sipservice/src/main/java/net/gotev/sipservice/SipAccount.java

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,9 @@
44
import org.pjsip.pjsua2.CallInfo;
55
import org.pjsip.pjsua2.CallOpParam;
66
import org.pjsip.pjsua2.OnIncomingCallParam;
7+
import org.pjsip.pjsua2.OnMwiInfoParam;
78
import org.pjsip.pjsua2.OnRegStateParam;
9+
import org.pjsip.pjsua2.SipRxData;
810
import org.pjsip.pjsua2.pjsip_status_code;
911

1012
import java.util.HashMap;
@@ -145,6 +147,21 @@ public void onRegState(OnRegStateParam prm) {
145147
service.getBroadcastEmitter().registrationState(data.getIdUri(), prm.getCode());
146148
}
147149

150+
@Override
151+
public void onMwiInfo(OnMwiInfoParam prm) {
152+
// Unsolicited MWI NOTIFY (RFC 3842). Parse the voice-message counts from the raw
153+
// message body and broadcast them so the app can drive the voicemail badge.
154+
try {
155+
SipRxData rdata = prm.getRdata();
156+
String wholeMsg = rdata != null ? rdata.getWholeMsg() : null;
157+
VoicemailStatus status = VoicemailStatus.parse(wholeMsg);
158+
Logger.info(LOG_TAG, "Received MWI info - " + status);
159+
service.getBroadcastEmitter().voicemailWaiting(status);
160+
} catch (Exception ex) {
161+
Logger.error(LOG_TAG, "Error while handling MWI info", ex);
162+
}
163+
}
164+
148165
@Override
149166
public void onIncomingCall(OnIncomingCallParam prm) {
150167

sipservice/src/main/java/net/gotev/sipservice/SipServiceConstants.java

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -96,6 +96,9 @@ public interface SipServiceConstants {
9696
String PARAM_CALL_STATS_RX_STREAM = "callStatsRxStream";
9797
String PARAM_CALL_STATS_TX_STREAM = "callStatsTxStream";
9898

99+
// Voicemail waiting counts (VoicemailStatus), parsed from the MWI NOTIFY (RFC 3842).
100+
String PARAM_VOICEMAIL_STATUS = "voicemailStatus";
101+
99102
/**
100103
* Video Configuration Params
101104
*/
Lines changed: 160 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,160 @@
1+
package net.gotev.sipservice;
2+
3+
import android.os.Parcel;
4+
import android.os.Parcelable;
5+
6+
import androidx.annotation.NonNull;
7+
8+
import java.util.regex.Matcher;
9+
import java.util.regex.Pattern;
10+
11+
/**
12+
* Voice-message state carried by an MWI NOTIFY (RFC 3842). Parcelable so it can ride a
13+
* single broadcast extra, mirroring {@link RtpStreamStats}.
14+
* <p>
15+
* Parsing follows RFC 3842's {@code application/simple-message-summary} body:
16+
* <ol>
17+
* <li>the body must be that content type (other event bodies are ignored);</li>
18+
* <li>{@code Messages-Waiting: yes|no} is the authoritative "are there new messages"
19+
* flag — it can be {@code yes} with no per-class line at all;</li>
20+
* <li>the optional {@code Voice-Message: new/old (urgentNew/urgentOld)} line refines it
21+
* with counts when present.</li>
22+
* </ol>
23+
*/
24+
public class VoicemailStatus implements Parcelable {
25+
26+
private final boolean messagesWaiting;
27+
private final int newMessages;
28+
private final int oldMessages;
29+
private final int urgentNew;
30+
private final int urgentOld;
31+
32+
VoicemailStatus(boolean messagesWaiting, int newMessages, int oldMessages, int urgentNew,
33+
int urgentOld) {
34+
this.messagesWaiting = messagesWaiting;
35+
this.newMessages = newMessages;
36+
this.oldMessages = oldMessages;
37+
this.urgentNew = urgentNew;
38+
this.urgentOld = urgentOld;
39+
}
40+
41+
// The only body content type the message-summary event package defines.
42+
private static final Pattern CONTENT_TYPE = Pattern.compile("Content-Type:\\s*application" +
43+
"/simple-message-summary", Pattern.CASE_INSENSITIVE);
44+
// Authoritative waiting flag.
45+
private static final Pattern MESSAGES_WAITING = Pattern.compile("Messages-Waiting:\\s*" +
46+
"(yes|no)", Pattern.CASE_INSENSITIVE);
47+
// Optional per-class counts: <new>/<old>[ (<urgentNew>/<urgentOld>)].
48+
private static final Pattern VOICE_MESSAGE = Pattern.compile("Voice-Message:\\s*(\\d+)" +
49+
"\\s*/\\s*(\\d+)(?:\\s*\\(\\s*(\\d+)\\s*/\\s*(\\d+)\\s*\\))?",
50+
Pattern.CASE_INSENSITIVE);
51+
52+
/**
53+
* Parse the whole NOTIFY message. Returns an all-clear status when the body isn't a
54+
* simple-message-summary or is empty/unparseable. Never throws.
55+
*/
56+
public static VoicemailStatus parse(String wholeMessage) {
57+
if (wholeMessage == null || wholeMessage.isEmpty()) {
58+
return none();
59+
}
60+
// 1) Only interpret simple-message-summary bodies.
61+
if (!CONTENT_TYPE.matcher(wholeMessage).find()) {
62+
return none();
63+
}
64+
// 2) Messages-Waiting is the source of truth for "something is waiting".
65+
final Matcher waitingMatcher = MESSAGES_WAITING.matcher(wholeMessage);
66+
final boolean waiting =
67+
waitingMatcher.find() && "yes".equalsIgnoreCase(waitingMatcher.group(1));
68+
// 3) Refine with per-class counts when the optional line is present.
69+
final Matcher voiceMatcher = VOICE_MESSAGE.matcher(wholeMessage);
70+
if (voiceMatcher.find()) {
71+
return new VoicemailStatus(waiting, safeParse(voiceMatcher.group(1)),
72+
safeParse(voiceMatcher.group(2)), safeParse(voiceMatcher.group(3)),
73+
safeParse(voiceMatcher.group(4)));
74+
}
75+
return new VoicemailStatus(waiting, 0, 0, 0, 0);
76+
}
77+
78+
private static VoicemailStatus none() {
79+
return new VoicemailStatus(false, 0, 0, 0, 0);
80+
}
81+
82+
private static int safeParse(String value) {
83+
if (value == null) return 0;
84+
try {
85+
return Integer.parseInt(value.trim());
86+
} catch (NumberFormatException e) {
87+
return 0;
88+
}
89+
}
90+
91+
public static final Parcelable.Creator<VoicemailStatus> CREATOR =
92+
new Parcelable.Creator<VoicemailStatus>() {
93+
@Override
94+
public VoicemailStatus createFromParcel(final Parcel in) {
95+
return new VoicemailStatus(in);
96+
}
97+
98+
@Override
99+
public VoicemailStatus[] newArray(final int size) {
100+
return new VoicemailStatus[size];
101+
}
102+
};
103+
104+
private VoicemailStatus(Parcel in) {
105+
this.messagesWaiting = in.readByte() != 0;
106+
this.newMessages = in.readInt();
107+
this.oldMessages = in.readInt();
108+
this.urgentNew = in.readInt();
109+
this.urgentOld = in.readInt();
110+
}
111+
112+
@Override
113+
public void writeToParcel(Parcel parcel, int flags) {
114+
parcel.writeByte((byte) (messagesWaiting ? 1 : 0));
115+
parcel.writeInt(newMessages);
116+
parcel.writeInt(oldMessages);
117+
parcel.writeInt(urgentNew);
118+
parcel.writeInt(urgentOld);
119+
}
120+
121+
@Override
122+
public int describeContents() {
123+
return 0;
124+
}
125+
126+
public boolean getMessagesWaiting() {
127+
return messagesWaiting;
128+
}
129+
130+
public int getNewMessages() {
131+
return newMessages;
132+
}
133+
134+
public int getOldMessages() {
135+
return oldMessages;
136+
}
137+
138+
public int getUrgentNew() {
139+
return urgentNew;
140+
}
141+
142+
public int getUrgentOld() {
143+
return urgentOld;
144+
}
145+
146+
/**
147+
* True when the mailbox has messages waiting. Per RFC 3842 the {@code Messages-Waiting}
148+
* flag is authoritative and is always {@code yes} when new messages exist, so it alone
149+
* decides this.
150+
*/
151+
public boolean hasNewMessages() {
152+
return messagesWaiting;
153+
}
154+
155+
@NonNull
156+
@Override
157+
public String toString() {
158+
return "VoicemailStatus{waiting=" + messagesWaiting + ", new=" + newMessages + ", old=" + oldMessages + ", urgentNew=" + urgentNew + ", urgentOld=" + urgentOld + "}";
159+
}
160+
}
Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
package net.gotev.sipservice;
2+
3+
import static org.junit.Assert.assertEquals;
4+
import static org.junit.Assert.assertFalse;
5+
import static org.junit.Assert.assertTrue;
6+
7+
import org.junit.Test;
8+
9+
/**
10+
* Tests for {@link VoicemailStatus#parse(String)}, the RFC 3842
11+
* {@code application/simple-message-summary} MWI body parser.
12+
*/
13+
public class VoicemailStatusParseTest {
14+
15+
private static String notify(String messagesWaiting, String voiceMessageLine) {
16+
StringBuilder sb =
17+
new StringBuilder().append("NOTIFY sip:208@example SIP/2.0\r\n").append("Event: " +
18+
"message-summary\r\n").append("Content-Type: application/simple-message" +
19+
"-summary\r\n").append("Content-Length: 85\r\n").append("Messages-Waiting" +
20+
": ").append(messagesWaiting).append("\r\n").append("Message-Account: " +
21+
"sip:jaime@example.com\r\n");
22+
if (voiceMessageLine != null) {
23+
sb.append(voiceMessageLine).append("\r\n");
24+
}
25+
return sb.append("\r\n").toString();
26+
}
27+
28+
@Test
29+
public void newOldOnlyReadsCountsAndFlagsWaiting() {
30+
VoicemailStatus status = VoicemailStatus.parse(notify("yes", "Voice-Message: 2/0"));
31+
assertTrue(status.getMessagesWaiting());
32+
assertEquals(2, status.getNewMessages());
33+
assertEquals(0, status.getOldMessages());
34+
assertEquals(0, status.getUrgentNew());
35+
assertEquals(0, status.getUrgentOld());
36+
assertTrue(status.hasNewMessages());
37+
}
38+
39+
@Test
40+
public void urgentPairReadsAllFourCounts() {
41+
VoicemailStatus status = VoicemailStatus.parse(notify("yes", "Voice-Message: 2/8 (1/2)"));
42+
assertEquals(2, status.getNewMessages());
43+
assertEquals(8, status.getOldMessages());
44+
assertEquals(1, status.getUrgentNew());
45+
assertEquals(2, status.getUrgentOld());
46+
}
47+
48+
// The key RFC case: waiting=yes with no per-class line must still surface as "waiting".
49+
@Test
50+
public void waitingYesWithoutVoiceMessageStillReportsWaiting() {
51+
VoicemailStatus status = VoicemailStatus.parse(notify("yes", null));
52+
assertTrue(status.getMessagesWaiting());
53+
assertEquals(0, status.getNewMessages());
54+
assertTrue(status.hasNewMessages());
55+
}
56+
57+
@Test
58+
public void waitingNoIsNotWaiting() {
59+
VoicemailStatus status = VoicemailStatus.parse(notify("no", null));
60+
assertFalse(status.getMessagesWaiting());
61+
assertFalse(status.hasNewMessages());
62+
}
63+
64+
// A body of a different content type must be ignored entirely.
65+
@Test
66+
public void nonSimpleMessageSummaryBodyIsAllClear() {
67+
String other = "NOTIFY sip:208@example SIP/2.0\r\n" + "Event: dialog\r\n" + "Content-Type" +
68+
": application/dialog-info+xml\r\n" + "Messages-Waiting: yes\r\n" + "Voice" +
69+
"-Message: 3/1\r\n\r\n";
70+
VoicemailStatus status = VoicemailStatus.parse(other);
71+
assertFalse(status.getMessagesWaiting());
72+
assertEquals(0, status.getNewMessages());
73+
assertFalse(status.hasNewMessages());
74+
}
75+
76+
@Test
77+
public void nullOrEmptyIsAllClear() {
78+
assertFalse(VoicemailStatus.parse(null).hasNewMessages());
79+
assertFalse(VoicemailStatus.parse("").hasNewMessages());
80+
}
81+
}

0 commit comments

Comments
 (0)