• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
1 /*
2  * Copyright (C) 2012 The Android Open Source Project
3  *
4  * Licensed under the Apache License, Version 2.0 (the "License");
5  * you may not use this file except in compliance with the License.
6  * You may obtain a copy of the License at
7  *
8  *      http://www.apache.org/licenses/LICENSE-2.0
9  *
10  * Unless required by applicable law or agreed to in writing, software
11  * distributed under the License is distributed on an "AS IS" BASIS,
12  * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13  * See the License for the specific language governing permissions and
14  * limitations under the License.
15  */
16 
17 package com.android.cellbroadcastservice;
18 
19 import static android.telephony.SmsCbEtwsInfo.ETWS_WARNING_TYPE_EARTHQUAKE;
20 import static android.telephony.SmsCbEtwsInfo.ETWS_WARNING_TYPE_EARTHQUAKE_AND_TSUNAMI;
21 import static android.telephony.SmsCbEtwsInfo.ETWS_WARNING_TYPE_OTHER_EMERGENCY;
22 import static android.telephony.SmsCbEtwsInfo.ETWS_WARNING_TYPE_TEST_MESSAGE;
23 import static android.telephony.SmsCbEtwsInfo.ETWS_WARNING_TYPE_TSUNAMI;
24 
25 import static com.android.cellbroadcastservice.CellBroadcastStatsLog.CELL_BROADCAST_MESSAGE_ERROR__TYPE__GSM_INVALID_GEO_FENCING_DATA;
26 import static com.android.cellbroadcastservice.CellBroadcastStatsLog.CELL_BROADCAST_MESSAGE_ERROR__TYPE__GSM_UMTS_INVALID_WAC;
27 
28 import android.annotation.NonNull;
29 import android.content.Context;
30 import android.content.res.Resources;
31 import android.telephony.CbGeoUtils.Circle;
32 import android.telephony.CbGeoUtils.Geometry;
33 import android.telephony.CbGeoUtils.LatLng;
34 import android.telephony.CbGeoUtils.Polygon;
35 import android.telephony.SmsCbLocation;
36 import android.telephony.SmsCbMessage;
37 import android.telephony.SmsMessage;
38 import android.telephony.SubscriptionManager;
39 import android.util.Log;
40 import android.util.Pair;
41 
42 import com.android.cellbroadcastservice.GsmSmsCbMessage.GeoFencingTriggerMessage.CellBroadcastIdentity;
43 import com.android.cellbroadcastservice.SmsCbHeader.DataCodingScheme;
44 import com.android.internal.annotations.VisibleForTesting;
45 
46 import java.io.UnsupportedEncodingException;
47 import java.util.ArrayList;
48 import java.util.List;
49 import java.util.stream.Collectors;
50 
51 /**
52  * Parses a GSM or UMTS format SMS-CB message into an {@link SmsCbMessage} object. The class is
53  * public because {@link #createSmsCbMessage(SmsCbLocation, byte[][])} is used by some test cases.
54  */
55 public class GsmSmsCbMessage {
56     private static final String TAG = GsmSmsCbMessage.class.getSimpleName();
57 
58     private static final char CARRIAGE_RETURN = 0x0d;
59 
60     private static final int PDU_BODY_PAGE_LENGTH = 82;
61 
62     /** Utility class with only static methods. */
GsmSmsCbMessage()63     private GsmSmsCbMessage() { }
64 
65     /**
66      * Get built-in ETWS primary messages by category. ETWS primary message does not contain text,
67      * so we have to show the pre-built messages to the user.
68      *
69      * @param context Device context
70      * @param category ETWS message category defined in SmsCbConstants
71      * @return ETWS text message in string. Return an empty string if no match.
72      */
73     @VisibleForTesting
getEtwsPrimaryMessage(Context context, int category)74     public static String getEtwsPrimaryMessage(Context context, int category) {
75         final Resources r = context.getResources();
76         switch (category) {
77             case ETWS_WARNING_TYPE_EARTHQUAKE:
78                 return r.getString(R.string.etws_primary_default_message_earthquake);
79             case ETWS_WARNING_TYPE_TSUNAMI:
80                 return r.getString(R.string.etws_primary_default_message_tsunami);
81             case ETWS_WARNING_TYPE_EARTHQUAKE_AND_TSUNAMI:
82                 return r.getString(R.string.etws_primary_default_message_earthquake_and_tsunami);
83             case ETWS_WARNING_TYPE_TEST_MESSAGE:
84                 return r.getString(R.string.etws_primary_default_message_test);
85             case ETWS_WARNING_TYPE_OTHER_EMERGENCY:
86                 return r.getString(R.string.etws_primary_default_message_others);
87             default:
88                 return "";
89         }
90     }
91 
92     /**
93      * Create a new SmsCbMessage object from a header object plus one or more received PDUs.
94      *
95      * @param pdus PDU bytes
96      */
createSmsCbMessage(Context context, SmsCbHeader header, SmsCbLocation location, byte[][] pdus, int slotIndex)97     public static SmsCbMessage createSmsCbMessage(Context context, SmsCbHeader header,
98             SmsCbLocation location, byte[][] pdus, int slotIndex)
99             throws IllegalArgumentException {
100         SubscriptionManager sm = (SubscriptionManager) context.getSystemService(
101                 Context.TELEPHONY_SUBSCRIPTION_SERVICE);
102         int subId = SubscriptionManager.DEFAULT_SUBSCRIPTION_ID;
103         int[] subIds = sm.getSubscriptionIds(slotIndex);
104         if (subIds != null && subIds.length > 0) {
105             subId = subIds[0];
106         }
107 
108         long receivedTimeMillis = System.currentTimeMillis();
109         if (header.isEtwsPrimaryNotification()) {
110             // ETSI TS 23.041 ETWS Primary Notification message
111             // ETWS primary message only contains 4 fields including serial number,
112             // message identifier, warning type, and warning security information.
113             // There is no field for the content/text so we get the text from the resources.
114             return new SmsCbMessage(SmsCbMessage.MESSAGE_FORMAT_3GPP, header.getGeographicalScope(),
115                     header.getSerialNumber(), location, header.getServiceCategory(), null,
116                     header.getDataCodingScheme(), getEtwsPrimaryMessage(context,
117                     header.getEtwsInfo().getWarningType()), SmsCbMessage.MESSAGE_PRIORITY_EMERGENCY,
118                     header.getEtwsInfo(), header.getCmasInfo(), 0, null, receivedTimeMillis,
119                     slotIndex, subId);
120         } else if (header.isUmtsFormat()) {
121             // UMTS format has only 1 PDU
122             byte[] pdu = pdus[0];
123             Pair<String, String> cbData = parseUmtsBody(header, pdu);
124             String language = cbData.first;
125             String body = cbData.second;
126             int priority = header.isEmergencyMessage() ? SmsCbMessage.MESSAGE_PRIORITY_EMERGENCY
127                     : SmsCbMessage.MESSAGE_PRIORITY_NORMAL;
128             int nrPages = pdu[SmsCbHeader.PDU_HEADER_LENGTH];
129             int wacDataOffset = SmsCbHeader.PDU_HEADER_LENGTH
130                     + 1 // number of pages
131                     + (PDU_BODY_PAGE_LENGTH + 1) * nrPages; // cb data
132 
133             // Has Warning Area Coordinates information
134             List<Geometry> geometries = null;
135             int maximumWaitingTimeSec = 255;
136             if (pdu.length > wacDataOffset) {
137                 try {
138                     Pair<Integer, List<Geometry>> wac = parseWarningAreaCoordinates(pdu,
139                             wacDataOffset);
140                     maximumWaitingTimeSec = wac.first;
141                     geometries = wac.second;
142                 } catch (Exception ex) {
143                     // Catch the exception here, the message will be considered as having no WAC
144                     // information which means the message will be broadcasted directly.
145                     Log.e(TAG, "Can't parse warning area coordinates, ex = " + ex.toString());
146                 }
147             }
148 
149             return new SmsCbMessage(SmsCbMessage.MESSAGE_FORMAT_3GPP,
150                     header.getGeographicalScope(), header.getSerialNumber(), location,
151                     header.getServiceCategory(), language, header.getDataCodingScheme(), body,
152                     priority, header.getEtwsInfo(), header.getCmasInfo(), maximumWaitingTimeSec,
153                     geometries, receivedTimeMillis, slotIndex, subId);
154         } else {
155             String language = null;
156             StringBuilder sb = new StringBuilder();
157             for (byte[] pdu : pdus) {
158                 Pair<String, String> p = parseGsmBody(header, pdu);
159                 language = p.first;
160                 sb.append(p.second);
161             }
162             int priority = header.isEmergencyMessage() ? SmsCbMessage.MESSAGE_PRIORITY_EMERGENCY
163                     : SmsCbMessage.MESSAGE_PRIORITY_NORMAL;
164 
165             return new SmsCbMessage(SmsCbMessage.MESSAGE_FORMAT_3GPP,
166                     header.getGeographicalScope(), header.getSerialNumber(), location,
167                     header.getServiceCategory(), language, header.getDataCodingScheme(),
168                     sb.toString(), priority, header.getEtwsInfo(), header.getCmasInfo(), 0, null,
169                     receivedTimeMillis, slotIndex, subId);
170         }
171     }
172 
173     /**
174      * Parse WEA Handset Action Message(WHAM) a.k.a geo-fencing trigger message.
175      *
176      * WEA Handset Action Message(WHAM) is a cell broadcast service message broadcast by the network
177      * to direct devices to perform a geo-fencing check on selected alerts.
178      *
179      * WEA Handset Action Message(WHAM) requirements from ATIS-0700041 section 4
180      * 1. The Warning Message contents of a WHAM shall be in Cell Broadcast(CB) data format as
181      * defined in TS 23.041.
182      * 2. The Warning Message Contents of WHAM shall be limited to one CB page(max 20 referenced
183      * WEA messages).
184      * 3. The broadcast area for a WHAM shall be the union of the broadcast areas of the referenced
185      * WEA message.
186      * @param pdu cell broadcast pdu, including the header
187      * @return {@link GeoFencingTriggerMessage} instance
188      */
createGeoFencingTriggerMessage(byte[] pdu)189     public static GeoFencingTriggerMessage createGeoFencingTriggerMessage(byte[] pdu) {
190         try {
191             // Header length + 1(number of page). ATIS-0700041 define the number of page of
192             // geo-fencing trigger message is 1.
193             int whamOffset = SmsCbHeader.PDU_HEADER_LENGTH + 1;
194 
195             BitStreamReader bitReader = new BitStreamReader(pdu, whamOffset);
196             int type = bitReader.read(4);
197             int length = bitReader.read(7);
198             // Skip the remained 5 bits
199             bitReader.skip();
200 
201             int messageIdentifierCount = (length - 2) * 8 / 32;
202             List<CellBroadcastIdentity> cbIdentifiers = new ArrayList<>();
203             for (int i = 0; i < messageIdentifierCount; i++) {
204                 // Both messageIdentifier and serialNumber are 16 bits integers.
205                 // ATIS-0700041 Section 5.1.6
206                 int messageIdentifier = bitReader.read(16);
207                 int serialNumber = bitReader.read(16);
208                 cbIdentifiers.add(new CellBroadcastIdentity(messageIdentifier, serialNumber));
209             }
210             return new GeoFencingTriggerMessage(type, cbIdentifiers);
211         } catch (Exception ex) {
212             final String errorMessage = "create geo-fencing trigger failed, ex = " + ex.toString();
213             Log.e(TAG, errorMessage);
214             CellBroadcastStatsLog.write(CellBroadcastStatsLog.CB_MESSAGE_ERROR,
215                     CELL_BROADCAST_MESSAGE_ERROR__TYPE__GSM_INVALID_GEO_FENCING_DATA,
216                     errorMessage);
217             return null;
218         }
219     }
220 
221     /**
222      * Parse the broadcast area and maximum wait time from the Warning Area Coordinates TLV.
223      *
224      * @param pdu Warning Area Coordinates TLV.
225      * @param wacOffset the offset of Warning Area Coordinates TLV.
226      * @return a pair with the first element is maximum wait time and the second is the broadcast
227      * area. The default value of the maximum wait time is 255 which means use the device default
228      * value.
229      */
parseWarningAreaCoordinates( byte[] pdu, int wacOffset)230     private static Pair<Integer, List<Geometry>> parseWarningAreaCoordinates(
231             byte[] pdu, int wacOffset) {
232         // little-endian
233         int wacDataLength = ((pdu[wacOffset + 1] & 0xff) << 8) | (pdu[wacOffset] & 0xff);
234         int offset = wacOffset + 2;
235 
236         if (offset + wacDataLength > pdu.length) {
237             IllegalArgumentException ex = new IllegalArgumentException(
238                     "Invalid wac data, expected the length of pdu at least "
239                             + (offset + wacDataLength) + ", actual is " + pdu.length);
240             CellBroadcastStatsLog.write(CellBroadcastStatsLog.CB_MESSAGE_ERROR,
241                     CellBroadcastStatsLog.CELL_BROADCAST_MESSAGE_ERROR__TYPE__GSM_UMTS_INVALID_WAC,
242                     ex.toString());
243             throw ex;
244         }
245 
246         BitStreamReader bitReader = new BitStreamReader(pdu, offset);
247 
248         int maximumWaitTimeSec = SmsCbMessage.MAXIMUM_WAIT_TIME_NOT_SET;
249 
250         List<Geometry> geo = new ArrayList<>();
251         int remainedBytes = wacDataLength;
252         while (remainedBytes > 0) {
253             int type = bitReader.read(4);
254             int length = bitReader.read(10);
255             remainedBytes -= length;
256             // Skip the 2 remained bits
257             bitReader.skip();
258 
259             switch (type) {
260                 case CbGeoUtils.GEO_FENCING_MAXIMUM_WAIT_TIME:
261                     maximumWaitTimeSec = bitReader.read(8);
262                     break;
263                 case CbGeoUtils.GEOMETRY_TYPE_POLYGON:
264                     List<LatLng> latLngs = new ArrayList<>();
265                     // Each coordinate is represented by 44 bits integer.
266                     // ATIS-0700041 5.2.4 Coordinate coding
267                     int n = (length - 2) * 8 / 44;
268                     for (int i = 0; i < n; i++) {
269                         latLngs.add(getLatLng(bitReader));
270                     }
271                     // Skip the padding bits
272                     bitReader.skip();
273                     geo.add(new Polygon(latLngs));
274                     break;
275                 case CbGeoUtils.GEOMETRY_TYPE_CIRCLE:
276                     LatLng center = getLatLng(bitReader);
277                     // radius = (wacRadius / 2^6). The unit of wacRadius is km, we use meter as the
278                     // distance unit during geo-fencing.
279                     // ATIS-0700041 5.2.5 radius coding
280                     double radius = (bitReader.read(20) * 1.0 / (1 << 6)) * 1000.0;
281                     geo.add(new Circle(center, radius));
282                     break;
283                 default:
284                     IllegalArgumentException ex = new IllegalArgumentException(
285                             "Unsupported geoType = " + type);
286                     CellBroadcastStatsLog.write(CellBroadcastStatsLog.CB_MESSAGE_ERROR,
287                             CELL_BROADCAST_MESSAGE_ERROR__TYPE__GSM_UMTS_INVALID_WAC,
288                             ex.toString());
289                     throw ex;
290             }
291         }
292         return new Pair(maximumWaitTimeSec, geo);
293     }
294 
295     /**
296      * The coordinate is (latitude, longitude), represented by a 44 bits integer.
297      * The coding is defined in ATIS-0700041 5.2.4
298      * @param bitReader
299      * @return coordinate (latitude, longitude)
300      */
getLatLng(BitStreamReader bitReader)301     private static LatLng getLatLng(BitStreamReader bitReader) {
302         // wacLatitude = floor(((latitude + 90) / 180) * 2^22)
303         // wacLongitude = floor(((longitude + 180) / 360) * 2^22)
304         int wacLat = bitReader.read(22);
305         int wacLng = bitReader.read(22);
306 
307         // latitude = wacLatitude * 180 / 2^22 - 90
308         // longitude = wacLongitude * 360 / 2^22 -180
309         return new LatLng((wacLat * 180.0 / (1 << 22)) - 90, (wacLng * 360.0 / (1 << 22) - 180));
310     }
311 
312     /**
313      * Parse and unpack the UMTS body text according to the encoding in the data coding scheme.
314      *
315      * @param header the message header to use
316      * @param pdu the PDU to decode
317      * @return a pair of string containing the language and body of the message in order
318      */
parseUmtsBody(SmsCbHeader header, byte[] pdu)319     private static Pair<String, String> parseUmtsBody(SmsCbHeader header,
320             byte[] pdu) {
321         // Payload may contain multiple pages
322         int nrPages = pdu[SmsCbHeader.PDU_HEADER_LENGTH];
323         String language = header.getDataCodingSchemeStructedData().language;
324 
325         if (pdu.length < SmsCbHeader.PDU_HEADER_LENGTH + 1 + (PDU_BODY_PAGE_LENGTH + 1)
326                 * nrPages) {
327             throw new IllegalArgumentException("Pdu length " + pdu.length + " does not match "
328                     + nrPages + " pages");
329         }
330 
331         StringBuilder sb = new StringBuilder();
332 
333         for (int i = 0; i < nrPages; i++) {
334             // Each page is 82 bytes followed by a length octet indicating
335             // the number of useful octets within those 82
336             int offset = SmsCbHeader.PDU_HEADER_LENGTH + 1 + (PDU_BODY_PAGE_LENGTH + 1) * i;
337             int length = pdu[offset + PDU_BODY_PAGE_LENGTH];
338 
339             if (length > PDU_BODY_PAGE_LENGTH) {
340                 throw new IllegalArgumentException("Page length " + length
341                         + " exceeds maximum value " + PDU_BODY_PAGE_LENGTH);
342             }
343 
344             Pair<String, String> p = unpackBody(pdu, offset, length,
345                     header.getDataCodingSchemeStructedData());
346             language = p.first;
347             sb.append(p.second);
348         }
349         return new Pair(language, sb.toString());
350 
351     }
352 
353     /**
354      * Parse and unpack the GSM body text according to the encoding in the data coding scheme.
355      * @param header the message header to use
356      * @param pdu the PDU to decode
357      * @return a pair of string containing the language and body of the message in order
358      */
parseGsmBody(SmsCbHeader header, byte[] pdu)359     private static Pair<String, String> parseGsmBody(SmsCbHeader header,
360             byte[] pdu) {
361         // Payload is one single page
362         int offset = SmsCbHeader.PDU_HEADER_LENGTH;
363         int length = pdu.length - offset;
364         return unpackBody(pdu, offset, length, header.getDataCodingSchemeStructedData());
365     }
366 
367     /**
368      * Unpack body text from the pdu using the given encoding, position and length within the pdu.
369      *
370      * @param pdu The pdu
371      * @param offset Position of the first byte to unpack
372      * @param length Number of bytes to unpack
373      * @param dcs data coding scheme
374      * @return a Pair of Strings containing the language and body of the message
375      */
unpackBody(byte[] pdu, int offset, int length, DataCodingScheme dcs)376     private static Pair<String, String> unpackBody(byte[] pdu, int offset,
377             int length, DataCodingScheme dcs) {
378         String body = null;
379 
380         String language = dcs.language;
381         switch (dcs.encoding) {
382             case SmsMessage.ENCODING_7BIT:
383                 body = GsmAlphabet.gsm7BitPackedToString(pdu, offset, length * 8 / 7);
384 
385                 if (dcs.hasLanguageIndicator && body != null && body.length() > 2) {
386                     // Language is two GSM characters followed by a CR.
387                     // The actual body text is offset by 3 characters.
388                     language = body.substring(0, 2);
389                     body = body.substring(3);
390                 }
391                 break;
392 
393             case SmsMessage.ENCODING_8BIT:
394                 // Support decoding the pdu as pack GSM 8-bit (a GSM alphabet string that's stored
395                 // in 8-bit unpacked format) characters.
396                 body = GsmAlphabet.gsm8BitUnpackedToString(pdu, offset, length);
397                 break;
398 
399             case SmsMessage.ENCODING_16BIT:
400                 if (dcs.hasLanguageIndicator && pdu.length >= offset + 2) {
401                     // Language is two GSM characters.
402                     // The actual body text is offset by 2 bytes.
403                     language = GsmAlphabet.gsm7BitPackedToString(pdu, offset, 2);
404                     offset += 2;
405                     length -= 2;
406                 }
407 
408                 try {
409                     body = new String(pdu, offset, (length & 0xfffe), "utf-16");
410                 } catch (UnsupportedEncodingException e) {
411                     // Apparently it wasn't valid UTF-16.
412                     throw new IllegalArgumentException("Error decoding UTF-16 message", e);
413                 }
414                 break;
415 
416             default:
417                 break;
418         }
419 
420         if (body != null) {
421             // Remove trailing carriage return
422             for (int i = body.length() - 1; i >= 0; i--) {
423                 if (body.charAt(i) != CARRIAGE_RETURN) {
424                     body = body.substring(0, i + 1);
425                     break;
426                 }
427             }
428         } else {
429             body = "";
430         }
431 
432         return new Pair<String, String>(language, body);
433     }
434 
435     /** A class use to facilitate the processing of bits stream data. */
436     private static final class BitStreamReader {
437         /** The bits stream represent by a bytes array. */
438         private final byte[] mData;
439 
440         /** The offset of the current byte. */
441         private int mCurrentOffset;
442 
443         /**
444          * The remained bits of the current byte which have not been read. The most significant
445          * will be read first, so the remained bits are always the least significant bits.
446          */
447         private int mRemainedBit;
448 
449         /**
450          * Constructor
451          * @param data bit stream data represent by byte array.
452          * @param offset the offset of the first byte.
453          */
BitStreamReader(byte[] data, int offset)454         BitStreamReader(byte[] data, int offset) {
455             mData = data;
456             mCurrentOffset = offset;
457             mRemainedBit = 8;
458         }
459 
460         /**
461          * Read the first {@code count} bits.
462          * @param count the number of bits need to read
463          * @return {@code bits} represent by an 32-bits integer, therefore {@code count} must be no
464          * greater than 32.
465          */
read(int count)466         public int read(int count) throws IndexOutOfBoundsException {
467             int val = 0;
468             while (count > 0) {
469                 if (count >= mRemainedBit) {
470                     val <<= mRemainedBit;
471                     val |= mData[mCurrentOffset] & ((1 << mRemainedBit) - 1);
472                     count -= mRemainedBit;
473                     mRemainedBit = 8;
474                     ++mCurrentOffset;
475                 } else {
476                     val <<= count;
477                     val |= (mData[mCurrentOffset] & ((1 << mRemainedBit) - 1))
478                             >> (mRemainedBit - count);
479                     mRemainedBit -= count;
480                     count = 0;
481                 }
482             }
483             return val;
484         }
485 
486         /**
487          * Skip the current bytes if the remained bits is less than 8. This is useful when
488          * processing the padding or reserved bits.
489          */
skip()490         public void skip() {
491             if (mRemainedBit < 8) {
492                 mRemainedBit = 8;
493                 ++mCurrentOffset;
494             }
495         }
496     }
497 
498     /**
499      * Part of a GSM SMS cell broadcast message which may trigger geo-fencing logic.
500      * @hide
501      */
502     public static final class GeoFencingTriggerMessage {
503         /**
504          * Indicate the list of active alerts share their warning area coordinates which means the
505          * broadcast area is the union of the broadcast areas of the active alerts in this list.
506          */
507         public static final int TYPE_ACTIVE_ALERT_SHARE_WAC = 2;
508 
509         public final int type;
510         public final List<CellBroadcastIdentity> cbIdentifiers;
511 
GeoFencingTriggerMessage(int type, @NonNull List<CellBroadcastIdentity> cbIdentifiers)512         GeoFencingTriggerMessage(int type, @NonNull List<CellBroadcastIdentity> cbIdentifiers) {
513             this.type = type;
514             this.cbIdentifiers = cbIdentifiers;
515         }
516 
517         /**
518          * Whether the trigger message indicates that the broadcast areas are shared between all
519          * active alerts.
520          * @return true if broadcast areas are to be shared
521          */
shouldShareBroadcastArea()522         boolean shouldShareBroadcastArea() {
523             return type == TYPE_ACTIVE_ALERT_SHARE_WAC;
524         }
525 
526         /**
527          * The GSM cell broadcast identity
528          */
529         @VisibleForTesting
530         public static final class CellBroadcastIdentity {
531             public final int messageIdentifier;
532             public final int serialNumber;
CellBroadcastIdentity(int messageIdentifier, int serialNumber)533             CellBroadcastIdentity(int messageIdentifier, int serialNumber) {
534                 this.messageIdentifier = messageIdentifier;
535                 this.serialNumber = serialNumber;
536             }
537         }
538 
539         @Override
toString()540         public String toString() {
541             String identifiers = cbIdentifiers.stream()
542                     .map(cbIdentifier ->String.format("(msgId = %d, serial = %d)",
543                             cbIdentifier.messageIdentifier, cbIdentifier.serialNumber))
544                     .collect(Collectors.joining(","));
545             return "triggerType=" + type + " identifiers=" + identifiers;
546         }
547     }
548 }
549