/* * Copyright (C) 2012 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.cellbroadcastservice; import static android.telephony.SmsCbEtwsInfo.ETWS_WARNING_TYPE_EARTHQUAKE; import static android.telephony.SmsCbEtwsInfo.ETWS_WARNING_TYPE_EARTHQUAKE_AND_TSUNAMI; import static android.telephony.SmsCbEtwsInfo.ETWS_WARNING_TYPE_OTHER_EMERGENCY; import static android.telephony.SmsCbEtwsInfo.ETWS_WARNING_TYPE_TEST_MESSAGE; import static android.telephony.SmsCbEtwsInfo.ETWS_WARNING_TYPE_TSUNAMI; import static com.android.cellbroadcastservice.CellBroadcastStatsLog.CELL_BROADCAST_MESSAGE_ERROR__TYPE__GSM_INVALID_GEO_FENCING_DATA; import static com.android.cellbroadcastservice.CellBroadcastStatsLog.CELL_BROADCAST_MESSAGE_ERROR__TYPE__GSM_UMTS_INVALID_WAC; import android.annotation.NonNull; import android.content.Context; import android.content.res.Resources; import android.telephony.CbGeoUtils.Circle; import android.telephony.CbGeoUtils.Geometry; import android.telephony.CbGeoUtils.LatLng; import android.telephony.CbGeoUtils.Polygon; import android.telephony.SmsCbLocation; import android.telephony.SmsCbMessage; import android.telephony.SmsMessage; import android.telephony.SubscriptionManager; import android.util.Log; import android.util.Pair; import com.android.cellbroadcastservice.GsmSmsCbMessage.GeoFencingTriggerMessage.CellBroadcastIdentity; import com.android.cellbroadcastservice.SmsCbHeader.DataCodingScheme; import com.android.internal.annotations.VisibleForTesting; import java.io.UnsupportedEncodingException; import java.util.ArrayList; import java.util.List; import java.util.stream.Collectors; /** * Parses a GSM or UMTS format SMS-CB message into an {@link SmsCbMessage} object. The class is * public because {@link #createSmsCbMessage(SmsCbLocation, byte[][])} is used by some test cases. */ public class GsmSmsCbMessage { private static final String TAG = GsmSmsCbMessage.class.getSimpleName(); private static final char CARRIAGE_RETURN = 0x0d; private static final int PDU_BODY_PAGE_LENGTH = 82; /** Utility class with only static methods. */ private GsmSmsCbMessage() { } /** * Get built-in ETWS primary messages by category. ETWS primary message does not contain text, * so we have to show the pre-built messages to the user. * * @param context Device context * @param category ETWS message category defined in SmsCbConstants * @return ETWS text message in string. Return an empty string if no match. */ @VisibleForTesting public static String getEtwsPrimaryMessage(Context context, int category) { final Resources r = context.getResources(); switch (category) { case ETWS_WARNING_TYPE_EARTHQUAKE: return r.getString(R.string.etws_primary_default_message_earthquake); case ETWS_WARNING_TYPE_TSUNAMI: return r.getString(R.string.etws_primary_default_message_tsunami); case ETWS_WARNING_TYPE_EARTHQUAKE_AND_TSUNAMI: return r.getString(R.string.etws_primary_default_message_earthquake_and_tsunami); case ETWS_WARNING_TYPE_TEST_MESSAGE: return r.getString(R.string.etws_primary_default_message_test); case ETWS_WARNING_TYPE_OTHER_EMERGENCY: return r.getString(R.string.etws_primary_default_message_others); default: return ""; } } /** * Create a new SmsCbMessage object from a header object plus one or more received PDUs. * * @param pdus PDU bytes */ public static SmsCbMessage createSmsCbMessage(Context context, SmsCbHeader header, SmsCbLocation location, byte[][] pdus, int slotIndex) throws IllegalArgumentException { SubscriptionManager sm = (SubscriptionManager) context.getSystemService( Context.TELEPHONY_SUBSCRIPTION_SERVICE); int subId = SubscriptionManager.DEFAULT_SUBSCRIPTION_ID; int[] subIds = sm.getSubscriptionIds(slotIndex); if (subIds != null && subIds.length > 0) { subId = subIds[0]; } long receivedTimeMillis = System.currentTimeMillis(); if (header.isEtwsPrimaryNotification()) { // ETSI TS 23.041 ETWS Primary Notification message // ETWS primary message only contains 4 fields including serial number, // message identifier, warning type, and warning security information. // There is no field for the content/text so we get the text from the resources. return new SmsCbMessage(SmsCbMessage.MESSAGE_FORMAT_3GPP, header.getGeographicalScope(), header.getSerialNumber(), location, header.getServiceCategory(), null, header.getDataCodingScheme(), getEtwsPrimaryMessage(context, header.getEtwsInfo().getWarningType()), SmsCbMessage.MESSAGE_PRIORITY_EMERGENCY, header.getEtwsInfo(), header.getCmasInfo(), 0, null, receivedTimeMillis, slotIndex, subId); } else if (header.isUmtsFormat()) { // UMTS format has only 1 PDU byte[] pdu = pdus[0]; Pair cbData = parseUmtsBody(header, pdu); String language = cbData.first; String body = cbData.second; int priority = header.isEmergencyMessage() ? SmsCbMessage.MESSAGE_PRIORITY_EMERGENCY : SmsCbMessage.MESSAGE_PRIORITY_NORMAL; int nrPages = pdu[SmsCbHeader.PDU_HEADER_LENGTH]; int wacDataOffset = SmsCbHeader.PDU_HEADER_LENGTH + 1 // number of pages + (PDU_BODY_PAGE_LENGTH + 1) * nrPages; // cb data // Has Warning Area Coordinates information List geometries = null; int maximumWaitingTimeSec = 255; if (pdu.length > wacDataOffset) { try { Pair> wac = parseWarningAreaCoordinates(pdu, wacDataOffset); maximumWaitingTimeSec = wac.first; geometries = wac.second; } catch (Exception ex) { // Catch the exception here, the message will be considered as having no WAC // information which means the message will be broadcasted directly. Log.e(TAG, "Can't parse warning area coordinates, ex = " + ex.toString()); } } return new SmsCbMessage(SmsCbMessage.MESSAGE_FORMAT_3GPP, header.getGeographicalScope(), header.getSerialNumber(), location, header.getServiceCategory(), language, header.getDataCodingScheme(), body, priority, header.getEtwsInfo(), header.getCmasInfo(), maximumWaitingTimeSec, geometries, receivedTimeMillis, slotIndex, subId); } else { String language = null; StringBuilder sb = new StringBuilder(); for (byte[] pdu : pdus) { Pair p = parseGsmBody(header, pdu); language = p.first; sb.append(p.second); } int priority = header.isEmergencyMessage() ? SmsCbMessage.MESSAGE_PRIORITY_EMERGENCY : SmsCbMessage.MESSAGE_PRIORITY_NORMAL; return new SmsCbMessage(SmsCbMessage.MESSAGE_FORMAT_3GPP, header.getGeographicalScope(), header.getSerialNumber(), location, header.getServiceCategory(), language, header.getDataCodingScheme(), sb.toString(), priority, header.getEtwsInfo(), header.getCmasInfo(), 0, null, receivedTimeMillis, slotIndex, subId); } } /** * Parse WEA Handset Action Message(WHAM) a.k.a geo-fencing trigger message. * * WEA Handset Action Message(WHAM) is a cell broadcast service message broadcast by the network * to direct devices to perform a geo-fencing check on selected alerts. * * WEA Handset Action Message(WHAM) requirements from ATIS-0700041 section 4 * 1. The Warning Message contents of a WHAM shall be in Cell Broadcast(CB) data format as * defined in TS 23.041. * 2. The Warning Message Contents of WHAM shall be limited to one CB page(max 20 referenced * WEA messages). * 3. The broadcast area for a WHAM shall be the union of the broadcast areas of the referenced * WEA message. * @param pdu cell broadcast pdu, including the header * @return {@link GeoFencingTriggerMessage} instance */ public static GeoFencingTriggerMessage createGeoFencingTriggerMessage(byte[] pdu) { try { // Header length + 1(number of page). ATIS-0700041 define the number of page of // geo-fencing trigger message is 1. int whamOffset = SmsCbHeader.PDU_HEADER_LENGTH + 1; BitStreamReader bitReader = new BitStreamReader(pdu, whamOffset); int type = bitReader.read(4); int length = bitReader.read(7); // Skip the remained 5 bits bitReader.skip(); int messageIdentifierCount = (length - 2) * 8 / 32; List cbIdentifiers = new ArrayList<>(); for (int i = 0; i < messageIdentifierCount; i++) { // Both messageIdentifier and serialNumber are 16 bits integers. // ATIS-0700041 Section 5.1.6 int messageIdentifier = bitReader.read(16); int serialNumber = bitReader.read(16); cbIdentifiers.add(new CellBroadcastIdentity(messageIdentifier, serialNumber)); } return new GeoFencingTriggerMessage(type, cbIdentifiers); } catch (Exception ex) { final String errorMessage = "create geo-fencing trigger failed, ex = " + ex.toString(); Log.e(TAG, errorMessage); CellBroadcastStatsLog.write(CellBroadcastStatsLog.CB_MESSAGE_ERROR, CELL_BROADCAST_MESSAGE_ERROR__TYPE__GSM_INVALID_GEO_FENCING_DATA, errorMessage); return null; } } /** * Parse the broadcast area and maximum wait time from the Warning Area Coordinates TLV. * * @param pdu Warning Area Coordinates TLV. * @param wacOffset the offset of Warning Area Coordinates TLV. * @return a pair with the first element is maximum wait time and the second is the broadcast * area. The default value of the maximum wait time is 255 which means use the device default * value. */ private static Pair> parseWarningAreaCoordinates( byte[] pdu, int wacOffset) { // little-endian int wacDataLength = ((pdu[wacOffset + 1] & 0xff) << 8) | (pdu[wacOffset] & 0xff); int offset = wacOffset + 2; if (offset + wacDataLength > pdu.length) { IllegalArgumentException ex = new IllegalArgumentException( "Invalid wac data, expected the length of pdu at least " + (offset + wacDataLength) + ", actual is " + pdu.length); CellBroadcastStatsLog.write(CellBroadcastStatsLog.CB_MESSAGE_ERROR, CellBroadcastStatsLog.CELL_BROADCAST_MESSAGE_ERROR__TYPE__GSM_UMTS_INVALID_WAC, ex.toString()); throw ex; } BitStreamReader bitReader = new BitStreamReader(pdu, offset); int maximumWaitTimeSec = SmsCbMessage.MAXIMUM_WAIT_TIME_NOT_SET; List geo = new ArrayList<>(); int remainedBytes = wacDataLength; while (remainedBytes > 0) { int type = bitReader.read(4); int length = bitReader.read(10); remainedBytes -= length; // Skip the 2 remained bits bitReader.skip(); switch (type) { case CbGeoUtils.GEO_FENCING_MAXIMUM_WAIT_TIME: maximumWaitTimeSec = bitReader.read(8); break; case CbGeoUtils.GEOMETRY_TYPE_POLYGON: List latLngs = new ArrayList<>(); // Each coordinate is represented by 44 bits integer. // ATIS-0700041 5.2.4 Coordinate coding int n = (length - 2) * 8 / 44; for (int i = 0; i < n; i++) { latLngs.add(getLatLng(bitReader)); } // Skip the padding bits bitReader.skip(); geo.add(new Polygon(latLngs)); break; case CbGeoUtils.GEOMETRY_TYPE_CIRCLE: LatLng center = getLatLng(bitReader); // radius = (wacRadius / 2^6). The unit of wacRadius is km, we use meter as the // distance unit during geo-fencing. // ATIS-0700041 5.2.5 radius coding double radius = (bitReader.read(20) * 1.0 / (1 << 6)) * 1000.0; geo.add(new Circle(center, radius)); break; default: IllegalArgumentException ex = new IllegalArgumentException( "Unsupported geoType = " + type); CellBroadcastStatsLog.write(CellBroadcastStatsLog.CB_MESSAGE_ERROR, CELL_BROADCAST_MESSAGE_ERROR__TYPE__GSM_UMTS_INVALID_WAC, ex.toString()); throw ex; } } return new Pair(maximumWaitTimeSec, geo); } /** * The coordinate is (latitude, longitude), represented by a 44 bits integer. * The coding is defined in ATIS-0700041 5.2.4 * @param bitReader * @return coordinate (latitude, longitude) */ private static LatLng getLatLng(BitStreamReader bitReader) { // wacLatitude = floor(((latitude + 90) / 180) * 2^22) // wacLongitude = floor(((longitude + 180) / 360) * 2^22) int wacLat = bitReader.read(22); int wacLng = bitReader.read(22); // latitude = wacLatitude * 180 / 2^22 - 90 // longitude = wacLongitude * 360 / 2^22 -180 return new LatLng((wacLat * 180.0 / (1 << 22)) - 90, (wacLng * 360.0 / (1 << 22) - 180)); } /** * Parse and unpack the UMTS body text according to the encoding in the data coding scheme. * * @param header the message header to use * @param pdu the PDU to decode * @return a pair of string containing the language and body of the message in order */ private static Pair parseUmtsBody(SmsCbHeader header, byte[] pdu) { // Payload may contain multiple pages int nrPages = pdu[SmsCbHeader.PDU_HEADER_LENGTH]; String language = header.getDataCodingSchemeStructedData().language; if (pdu.length < SmsCbHeader.PDU_HEADER_LENGTH + 1 + (PDU_BODY_PAGE_LENGTH + 1) * nrPages) { throw new IllegalArgumentException("Pdu length " + pdu.length + " does not match " + nrPages + " pages"); } StringBuilder sb = new StringBuilder(); for (int i = 0; i < nrPages; i++) { // Each page is 82 bytes followed by a length octet indicating // the number of useful octets within those 82 int offset = SmsCbHeader.PDU_HEADER_LENGTH + 1 + (PDU_BODY_PAGE_LENGTH + 1) * i; int length = pdu[offset + PDU_BODY_PAGE_LENGTH]; if (length > PDU_BODY_PAGE_LENGTH) { throw new IllegalArgumentException("Page length " + length + " exceeds maximum value " + PDU_BODY_PAGE_LENGTH); } Pair p = unpackBody(pdu, offset, length, header.getDataCodingSchemeStructedData()); language = p.first; sb.append(p.second); } return new Pair(language, sb.toString()); } /** * Parse and unpack the GSM body text according to the encoding in the data coding scheme. * @param header the message header to use * @param pdu the PDU to decode * @return a pair of string containing the language and body of the message in order */ private static Pair parseGsmBody(SmsCbHeader header, byte[] pdu) { // Payload is one single page int offset = SmsCbHeader.PDU_HEADER_LENGTH; int length = pdu.length - offset; return unpackBody(pdu, offset, length, header.getDataCodingSchemeStructedData()); } /** * Unpack body text from the pdu using the given encoding, position and length within the pdu. * * @param pdu The pdu * @param offset Position of the first byte to unpack * @param length Number of bytes to unpack * @param dcs data coding scheme * @return a Pair of Strings containing the language and body of the message */ private static Pair unpackBody(byte[] pdu, int offset, int length, DataCodingScheme dcs) { String body = null; String language = dcs.language; switch (dcs.encoding) { case SmsMessage.ENCODING_7BIT: body = GsmAlphabet.gsm7BitPackedToString(pdu, offset, length * 8 / 7); if (dcs.hasLanguageIndicator && body != null && body.length() > 2) { // Language is two GSM characters followed by a CR. // The actual body text is offset by 3 characters. language = body.substring(0, 2); body = body.substring(3); } break; case SmsMessage.ENCODING_8BIT: // Support decoding the pdu as pack GSM 8-bit (a GSM alphabet string that's stored // in 8-bit unpacked format) characters. body = GsmAlphabet.gsm8BitUnpackedToString(pdu, offset, length); break; case SmsMessage.ENCODING_16BIT: if (dcs.hasLanguageIndicator && pdu.length >= offset + 2) { // Language is two GSM characters. // The actual body text is offset by 2 bytes. language = GsmAlphabet.gsm7BitPackedToString(pdu, offset, 2); offset += 2; length -= 2; } try { body = new String(pdu, offset, (length & 0xfffe), "utf-16"); } catch (UnsupportedEncodingException e) { // Apparently it wasn't valid UTF-16. throw new IllegalArgumentException("Error decoding UTF-16 message", e); } break; default: break; } if (body != null) { // Remove trailing carriage return for (int i = body.length() - 1; i >= 0; i--) { if (body.charAt(i) != CARRIAGE_RETURN) { body = body.substring(0, i + 1); break; } } } else { body = ""; } return new Pair(language, body); } /** A class use to facilitate the processing of bits stream data. */ private static final class BitStreamReader { /** The bits stream represent by a bytes array. */ private final byte[] mData; /** The offset of the current byte. */ private int mCurrentOffset; /** * The remained bits of the current byte which have not been read. The most significant * will be read first, so the remained bits are always the least significant bits. */ private int mRemainedBit; /** * Constructor * @param data bit stream data represent by byte array. * @param offset the offset of the first byte. */ BitStreamReader(byte[] data, int offset) { mData = data; mCurrentOffset = offset; mRemainedBit = 8; } /** * Read the first {@code count} bits. * @param count the number of bits need to read * @return {@code bits} represent by an 32-bits integer, therefore {@code count} must be no * greater than 32. */ public int read(int count) throws IndexOutOfBoundsException { int val = 0; while (count > 0) { if (count >= mRemainedBit) { val <<= mRemainedBit; val |= mData[mCurrentOffset] & ((1 << mRemainedBit) - 1); count -= mRemainedBit; mRemainedBit = 8; ++mCurrentOffset; } else { val <<= count; val |= (mData[mCurrentOffset] & ((1 << mRemainedBit) - 1)) >> (mRemainedBit - count); mRemainedBit -= count; count = 0; } } return val; } /** * Skip the current bytes if the remained bits is less than 8. This is useful when * processing the padding or reserved bits. */ public void skip() { if (mRemainedBit < 8) { mRemainedBit = 8; ++mCurrentOffset; } } } /** * Part of a GSM SMS cell broadcast message which may trigger geo-fencing logic. * @hide */ public static final class GeoFencingTriggerMessage { /** * Indicate the list of active alerts share their warning area coordinates which means the * broadcast area is the union of the broadcast areas of the active alerts in this list. */ public static final int TYPE_ACTIVE_ALERT_SHARE_WAC = 2; public final int type; public final List cbIdentifiers; GeoFencingTriggerMessage(int type, @NonNull List cbIdentifiers) { this.type = type; this.cbIdentifiers = cbIdentifiers; } /** * Whether the trigger message indicates that the broadcast areas are shared between all * active alerts. * @return true if broadcast areas are to be shared */ boolean shouldShareBroadcastArea() { return type == TYPE_ACTIVE_ALERT_SHARE_WAC; } /** * The GSM cell broadcast identity */ @VisibleForTesting public static final class CellBroadcastIdentity { public final int messageIdentifier; public final int serialNumber; CellBroadcastIdentity(int messageIdentifier, int serialNumber) { this.messageIdentifier = messageIdentifier; this.serialNumber = serialNumber; } } @Override public String toString() { String identifiers = cbIdentifiers.stream() .map(cbIdentifier ->String.format("(msgId = %d, serial = %d)", cbIdentifier.messageIdentifier, cbIdentifier.serialNumber)) .collect(Collectors.joining(",")); return "triggerType=" + type + " identifiers=" + identifiers; } } }