1 /* 2 * Copyright (C) 2013 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 com.android.cellbroadcastservice.CellBroadcastStatsLog.CELL_BROADCAST_MESSAGE_ERROR__TYPE__GSM_INVALID_PDU; 20 import static com.android.cellbroadcastservice.CellBroadcastStatsLog.CELL_BROADCAST_MESSAGE_ERROR__TYPE__UNEXPECTED_GSM_MESSAGE_TYPE_FROM_FWK; 21 22 import android.annotation.NonNull; 23 import android.annotation.Nullable; 24 import android.content.ContentResolver; 25 import android.content.ContentUris; 26 import android.content.Context; 27 import android.content.Intent; 28 import android.content.res.Resources; 29 import android.database.Cursor; 30 import android.net.Uri; 31 import android.os.Looper; 32 import android.os.Message; 33 import android.os.SystemClock; 34 import android.os.UserHandle; 35 import android.provider.Telephony.CellBroadcasts; 36 import android.telephony.AccessNetworkConstants; 37 import android.telephony.CbGeoUtils.Geometry; 38 import android.telephony.CellBroadcastIntents; 39 import android.telephony.CellIdentity; 40 import android.telephony.CellIdentityGsm; 41 import android.telephony.CellIdentityLte; 42 import android.telephony.CellIdentityNr; 43 import android.telephony.CellIdentityTdscdma; 44 import android.telephony.CellIdentityWcdma; 45 import android.telephony.CellInfo; 46 import android.telephony.NetworkRegistrationInfo; 47 import android.telephony.ServiceState; 48 import android.telephony.SmsCbLocation; 49 import android.telephony.SmsCbMessage; 50 import android.telephony.SubscriptionManager; 51 import android.telephony.TelephonyManager; 52 import android.text.TextUtils; 53 import android.text.format.DateUtils; 54 import android.util.Pair; 55 import android.util.SparseArray; 56 57 import com.android.cellbroadcastservice.GsmSmsCbMessage.GeoFencingTriggerMessage; 58 import com.android.cellbroadcastservice.GsmSmsCbMessage.GeoFencingTriggerMessage.CellBroadcastIdentity; 59 import com.android.internal.annotations.VisibleForTesting; 60 61 import java.text.DateFormat; 62 import java.util.ArrayList; 63 import java.util.HashMap; 64 import java.util.Iterator; 65 import java.util.List; 66 import java.util.Objects; 67 import java.util.stream.Collectors; 68 import java.util.stream.IntStream; 69 70 /** 71 * Handler for 3GPP format Cell Broadcasts. Parent class can also handle CDMA Cell Broadcasts. 72 */ 73 public class GsmCellBroadcastHandler extends CellBroadcastHandler { 74 private static final boolean VDBG = false; // log CB PDU data 75 76 /** Indicates that a message is not displayed. */ 77 private static final String MESSAGE_NOT_DISPLAYED = "0"; 78 79 private final SparseArray<String> mAreaInfos = new SparseArray<>(); 80 81 /** This map holds incomplete concatenated messages waiting for assembly. */ 82 private final HashMap<SmsCbConcatInfo, byte[][]> mSmsCbPageMap = 83 new HashMap<>(4); 84 85 @VisibleForTesting GsmCellBroadcastHandler(Context context, Looper looper)86 public GsmCellBroadcastHandler(Context context, Looper looper) { 87 super("GsmCellBroadcastHandler", context, looper); 88 } 89 90 @Override onQuitting()91 protected void onQuitting() { 92 super.onQuitting(); // release wakelock 93 } 94 95 /** 96 * Handle a GSM cell broadcast message passed from the telephony framework. 97 * @param message 98 */ onGsmCellBroadcastSms(int slotIndex, byte[] message)99 public void onGsmCellBroadcastSms(int slotIndex, byte[] message) { 100 sendMessage(EVENT_NEW_SMS_MESSAGE, slotIndex, -1, message); 101 } 102 103 /** 104 * Get the area information 105 * 106 * @param slotIndex SIM slot index 107 * @return The area information 108 */ 109 @NonNull getCellBroadcastAreaInfo(int slotIndex)110 public String getCellBroadcastAreaInfo(int slotIndex) { 111 String info; 112 synchronized (mAreaInfos) { 113 info = mAreaInfos.get(slotIndex); 114 } 115 return info == null ? "" : info; 116 } 117 118 /** 119 * Create a new CellBroadcastHandler. 120 * @param context the context to use for dispatching Intents 121 * @return the new handler 122 */ makeGsmCellBroadcastHandler(Context context)123 public static GsmCellBroadcastHandler makeGsmCellBroadcastHandler(Context context) { 124 GsmCellBroadcastHandler handler = new GsmCellBroadcastHandler(context, Looper.myLooper()); 125 handler.start(); 126 return handler; 127 } 128 129 /** 130 * Find the cell broadcast messages specify by the geo-fencing trigger message and perform a 131 * geo-fencing check for these messages. 132 * @param geoFencingTriggerMessage the trigger message 133 * 134 * @return {@code True} if geo-fencing is need for some cell broadcast message. 135 */ handleGeoFencingTriggerMessage( GeoFencingTriggerMessage geoFencingTriggerMessage, int slotIndex)136 private boolean handleGeoFencingTriggerMessage( 137 GeoFencingTriggerMessage geoFencingTriggerMessage, int slotIndex) { 138 final List<SmsCbMessage> cbMessages = new ArrayList<>(); 139 final List<Uri> cbMessageUris = new ArrayList<>(); 140 141 SubscriptionManager subMgr = (SubscriptionManager) mContext.getSystemService( 142 Context.TELEPHONY_SUBSCRIPTION_SERVICE); 143 int[] subIds = subMgr.getSubscriptionIds(slotIndex); 144 Resources res; 145 if (subIds != null) { 146 res = getResources(subIds[0]); 147 } else { 148 res = getResources(SubscriptionManager.DEFAULT_SUBSCRIPTION_ID); 149 } 150 151 // Only consider the cell broadcast received within 24 hours. 152 long lastReceivedTime = System.currentTimeMillis() - DateUtils.DAY_IN_MILLIS; 153 154 // Some carriers require reset duplication detection after airplane mode or reboot. 155 if (res.getBoolean(R.bool.reset_on_power_cycle_or_airplane_mode)) { 156 lastReceivedTime = Long.max(lastReceivedTime, mLastAirplaneModeTime); 157 lastReceivedTime = Long.max(lastReceivedTime, 158 System.currentTimeMillis() - SystemClock.elapsedRealtime()); 159 } 160 161 // Find the cell broadcast message identify by the message identifier and serial number 162 // and was not displayed. 163 String where = CellBroadcasts.SERVICE_CATEGORY + "=? AND " 164 + CellBroadcasts.SERIAL_NUMBER + "=? AND " 165 + CellBroadcasts.MESSAGE_DISPLAYED + "=? AND " 166 + CellBroadcasts.RECEIVED_TIME + ">?"; 167 168 ContentResolver resolver = mContext.getContentResolver(); 169 for (CellBroadcastIdentity identity : geoFencingTriggerMessage.cbIdentifiers) { 170 try (Cursor cursor = resolver.query(CellBroadcasts.CONTENT_URI, 171 CellBroadcastProvider.QUERY_COLUMNS, 172 where, 173 new String[] { Integer.toString(identity.messageIdentifier), 174 Integer.toString(identity.serialNumber), MESSAGE_NOT_DISPLAYED, 175 Long.toString(lastReceivedTime) }, 176 null /* sortOrder */)) { 177 if (cursor != null) { 178 while (cursor.moveToNext()) { 179 cbMessages.add(SmsCbMessage.createFromCursor(cursor)); 180 cbMessageUris.add(ContentUris.withAppendedId(CellBroadcasts.CONTENT_URI, 181 cursor.getInt(cursor.getColumnIndex(CellBroadcasts._ID)))); 182 } 183 } 184 } 185 } 186 187 log("Found " + cbMessages.size() + " not broadcasted messages since " 188 + DateFormat.getDateTimeInstance().format(lastReceivedTime)); 189 190 List<Geometry> commonBroadcastArea = new ArrayList<>(); 191 if (geoFencingTriggerMessage.shouldShareBroadcastArea()) { 192 for (SmsCbMessage msg : cbMessages) { 193 if (msg.getGeometries() != null) { 194 commonBroadcastArea.addAll(msg.getGeometries()); 195 } 196 } 197 } 198 199 // ATIS doesn't specify the geo fencing maximum wait time for the cell broadcasts specified 200 // in geo fencing trigger message. We will pick the largest maximum wait time among these 201 // cell broadcasts. 202 int maxWaitingTimeSec = 0; 203 for (SmsCbMessage msg : cbMessages) { 204 maxWaitingTimeSec = Math.max(maxWaitingTimeSec, getMaxLocationWaitingTime(msg)); 205 } 206 207 if (DBG) { 208 logd("Geo-fencing trigger message = " + geoFencingTriggerMessage); 209 for (SmsCbMessage msg : cbMessages) { 210 logd(msg.toString()); 211 } 212 } 213 214 if (cbMessages.isEmpty()) { 215 if (DBG) logd("No CellBroadcast message need to be broadcasted"); 216 return false; 217 } 218 219 requestLocationUpdate(location -> { 220 if (location == null) { 221 // If the location is not available, broadcast the messages directly. 222 for (int i = 0; i < cbMessages.size(); i++) { 223 broadcastMessage(cbMessages.get(i), cbMessageUris.get(i), slotIndex); 224 } 225 } else { 226 for (int i = 0; i < cbMessages.size(); i++) { 227 List<Geometry> broadcastArea = !commonBroadcastArea.isEmpty() 228 ? commonBroadcastArea : cbMessages.get(i).getGeometries(); 229 if (broadcastArea == null || broadcastArea.isEmpty()) { 230 broadcastMessage(cbMessages.get(i), cbMessageUris.get(i), slotIndex); 231 } else { 232 performGeoFencing(cbMessages.get(i), cbMessageUris.get(i), broadcastArea, 233 location, slotIndex); 234 } 235 } 236 } 237 }, maxWaitingTimeSec); 238 return true; 239 } 240 241 /** 242 * Process area info message. 243 * 244 * @param slotIndex SIM slot index 245 * @param message Cell broadcast message 246 * @return {@code true} if the mssage is an area info message and got processed correctly, 247 * otherwise {@code false}. 248 */ handleAreaInfoMessage(int slotIndex, SmsCbMessage message)249 private boolean handleAreaInfoMessage(int slotIndex, SmsCbMessage message) { 250 Resources res = getResources(message.getSubscriptionId()); 251 int[] areaInfoChannels = res.getIntArray(R.array.area_info_channels); 252 253 // Check area info message 254 if (IntStream.of(areaInfoChannels).anyMatch( 255 x -> x == message.getServiceCategory())) { 256 synchronized (mAreaInfos) { 257 String info = mAreaInfos.get(slotIndex); 258 if (TextUtils.equals(info, message.getMessageBody())) { 259 // Message is a duplicate 260 return true; 261 } 262 mAreaInfos.put(slotIndex, message.getMessageBody()); 263 } 264 265 String[] pkgs = mContext.getResources().getStringArray( 266 R.array.config_area_info_receiver_packages); 267 for (String pkg : pkgs) { 268 Intent intent = new Intent(CellBroadcastIntents.ACTION_AREA_INFO_UPDATED); 269 intent.putExtra(SubscriptionManager.EXTRA_SLOT_INDEX, slotIndex); 270 intent.setPackage(pkg); 271 mContext.sendBroadcastAsUser(intent, UserHandle.ALL, 272 android.Manifest.permission.READ_PRIVILEGED_PHONE_STATE); 273 } 274 return true; 275 } 276 277 // This is not an area info message. 278 return false; 279 } 280 281 /** 282 * Handle 3GPP-format Cell Broadcast messages sent from radio. 283 * 284 * @param message the message to process 285 * @return true if need to wait for geo-fencing or an ordered broadcast was sent. 286 */ 287 @Override handleSmsMessage(Message message)288 protected boolean handleSmsMessage(Message message) { 289 // For GSM, message.obj should be a byte[] 290 int slotIndex = message.arg1; 291 if (message.obj instanceof byte[]) { 292 byte[] pdu = (byte[]) message.obj; 293 SmsCbHeader header = createSmsCbHeader(pdu); 294 if (header == null) return false; 295 296 log("header=" + header); 297 if (header.getServiceCategory() == SmsCbConstants.MESSAGE_ID_CMAS_GEO_FENCING_TRIGGER) { 298 GeoFencingTriggerMessage triggerMessage = 299 GsmSmsCbMessage.createGeoFencingTriggerMessage(pdu); 300 if (triggerMessage != null) { 301 return handleGeoFencingTriggerMessage(triggerMessage, slotIndex); 302 } 303 } else { 304 SmsCbMessage cbMessage = handleGsmBroadcastSms(header, pdu, slotIndex); 305 if (cbMessage != null) { 306 if (isDuplicate(cbMessage)) { 307 CellBroadcastStatsLog.write(CellBroadcastStatsLog.CB_MESSAGE_FILTERED, 308 CellBroadcastStatsLog.CELL_BROADCAST_MESSAGE_FILTERED__TYPE__GSM, 309 CellBroadcastStatsLog.CELL_BROADCAST_MESSAGE_FILTERED__FILTER__DUPLICATE_MESSAGE); 310 return false; 311 } 312 313 if (handleAreaInfoMessage(slotIndex, cbMessage)) { 314 log("Channel " + cbMessage.getServiceCategory() + " message processed"); 315 CellBroadcastStatsLog.write(CellBroadcastStatsLog.CB_MESSAGE_FILTERED, 316 CellBroadcastStatsLog.CELL_BROADCAST_MESSAGE_FILTERED__TYPE__GSM, 317 CellBroadcastStatsLog.CELL_BROADCAST_MESSAGE_FILTERED__FILTER__AREA_INFO_MESSAGE); 318 return false; 319 } 320 321 handleBroadcastSms(cbMessage); 322 return true; 323 } 324 if (VDBG) log("Not handled GSM broadcasts."); 325 } 326 } else { 327 final String errorMessage = "handleSmsMessage for GSM got object of type: " 328 + message.obj.getClass().getName(); 329 loge(errorMessage); 330 CellBroadcastStatsLog.write(CellBroadcastStatsLog.CB_MESSAGE_ERROR, 331 CELL_BROADCAST_MESSAGE_ERROR__TYPE__UNEXPECTED_GSM_MESSAGE_TYPE_FROM_FWK, 332 errorMessage); 333 } 334 if (message.obj instanceof SmsCbMessage) { 335 return super.handleSmsMessage(message); 336 } else { 337 return false; 338 } 339 } 340 341 /** 342 * Get LAC (location area code for GSM/UMTS) / TAC (tracking area code for LTE/NR) and CID 343 * (Cell id) from the cell identity 344 * 345 * @param ci Cell identity 346 * @return Pair of LAC and CID. {@code null} if not available. 347 */ getLacAndCid(CellIdentity ci)348 private @Nullable Pair<Integer, Integer> getLacAndCid(CellIdentity ci) { 349 if (ci == null) return null; 350 int lac = CellInfo.UNAVAILABLE; 351 int cid = CellInfo.UNAVAILABLE; 352 if (ci instanceof CellIdentityGsm) { 353 lac = ((CellIdentityGsm) ci).getLac(); 354 cid = ((CellIdentityGsm) ci).getCid(); 355 } else if (ci instanceof CellIdentityWcdma) { 356 lac = ((CellIdentityWcdma) ci).getLac(); 357 cid = ((CellIdentityWcdma) ci).getCid(); 358 } else if ((ci instanceof CellIdentityTdscdma)) { 359 lac = ((CellIdentityTdscdma) ci).getLac(); 360 cid = ((CellIdentityTdscdma) ci).getCid(); 361 } else if (ci instanceof CellIdentityLte) { 362 lac = ((CellIdentityLte) ci).getTac(); 363 cid = ((CellIdentityLte) ci).getCi(); 364 } else if (ci instanceof CellIdentityNr) { 365 lac = ((CellIdentityNr) ci).getTac(); 366 cid = ((CellIdentityNr) ci).getPci(); 367 } 368 369 if (lac != CellInfo.UNAVAILABLE || cid != CellInfo.UNAVAILABLE) { 370 return Pair.create(lac, cid); 371 } 372 373 // When both LAC and CID are not available. 374 return null; 375 } 376 377 /** 378 * Get LAC (location area code for GSM/UMTS) / TAC (tracking area code for LTE/NR) and CID 379 * (Cell id) of the registered network. 380 * 381 * @param slotIndex SIM slot index 382 * 383 * @return lac and cid. {@code null} if cell identity is not available from the registered 384 * network. 385 */ getLacAndCid(int slotIndex)386 private @Nullable Pair<Integer, Integer> getLacAndCid(int slotIndex) { 387 TelephonyManager tm = mContext.getSystemService(TelephonyManager.class); 388 tm.createForSubscriptionId(getSubIdForPhone(mContext, slotIndex)); 389 390 ServiceState serviceState = tm.getServiceState(); 391 392 if (serviceState == null) return null; 393 394 // The list of cell identity to extract LAC and CID. The higher priority one will be added 395 // into the top of list. 396 List<CellIdentity> cellIdentityList = new ArrayList<>(); 397 398 // CS network 399 NetworkRegistrationInfo nri = serviceState.getNetworkRegistrationInfo( 400 NetworkRegistrationInfo.DOMAIN_CS, AccessNetworkConstants.TRANSPORT_TYPE_WWAN); 401 if (nri != null) { 402 cellIdentityList.add(nri.getCellIdentity()); 403 } 404 405 // PS network 406 nri = serviceState.getNetworkRegistrationInfo( 407 NetworkRegistrationInfo.DOMAIN_PS, AccessNetworkConstants.TRANSPORT_TYPE_WWAN); 408 if (nri != null) { 409 cellIdentityList.add(nri.getCellIdentity()); 410 } 411 412 // When SIM is not inserted, we use the cell identity from the nearby cell. This is 413 // best effort. 414 List<CellInfo> infos = tm.getAllCellInfo(); 415 if (infos != null) { 416 cellIdentityList.addAll( 417 infos.stream().map(CellInfo::getCellIdentity).collect(Collectors.toList())); 418 } 419 420 // Return the first valid LAC and CID from the list. 421 return cellIdentityList.stream() 422 .map(this::getLacAndCid) 423 .filter(Objects::nonNull) 424 .findFirst() 425 .orElse(null); 426 } 427 428 429 /** 430 * Handle 3GPP format SMS-CB message. 431 * @param header the cellbroadcast header. 432 * @param receivedPdu the received PDUs as a byte[] 433 */ handleGsmBroadcastSms(SmsCbHeader header, byte[] receivedPdu, int slotIndex)434 private SmsCbMessage handleGsmBroadcastSms(SmsCbHeader header, byte[] receivedPdu, 435 int slotIndex) { 436 try { 437 if (VDBG) { 438 int pduLength = receivedPdu.length; 439 for (int i = 0; i < pduLength; i += 8) { 440 StringBuilder sb = new StringBuilder("SMS CB pdu data: "); 441 for (int j = i; j < i + 8 && j < pduLength; j++) { 442 int b = receivedPdu[j] & 0xff; 443 if (b < 0x10) { 444 sb.append('0'); 445 } 446 sb.append(Integer.toHexString(b)).append(' '); 447 } 448 log(sb.toString()); 449 } 450 } 451 452 if (VDBG) log("header=" + header); 453 TelephonyManager tm = 454 (TelephonyManager) mContext.getSystemService(Context.TELEPHONY_SERVICE); 455 tm.createForSubscriptionId(getSubIdForPhone(mContext, slotIndex)); 456 String plmn = tm.getNetworkOperator(); 457 int lac = -1; 458 int cid = -1; 459 // Get LAC and CID of the current camped cell. 460 Pair<Integer, Integer> lacAndCid = getLacAndCid(slotIndex); 461 if (lacAndCid != null) { 462 lac = lacAndCid.first; 463 cid = lacAndCid.second; 464 } 465 466 SmsCbLocation location = new SmsCbLocation(plmn, lac, cid); 467 468 byte[][] pdus; 469 int pageCount = header.getNumberOfPages(); 470 if (pageCount > 1) { 471 // Multi-page message 472 SmsCbConcatInfo concatInfo = new SmsCbConcatInfo(header, location); 473 474 // Try to find other pages of the same message 475 pdus = mSmsCbPageMap.get(concatInfo); 476 477 if (pdus == null) { 478 // This is the first page of this message, make room for all 479 // pages and keep until complete 480 pdus = new byte[pageCount][]; 481 482 mSmsCbPageMap.put(concatInfo, pdus); 483 } 484 485 if (VDBG) log("pdus size=" + pdus.length); 486 // Page parameter is one-based 487 pdus[header.getPageIndex() - 1] = receivedPdu; 488 489 for (byte[] pdu : pdus) { 490 if (pdu == null) { 491 // Still missing pages, exit 492 log("still missing pdu"); 493 return null; 494 } 495 } 496 497 // Message complete, remove and dispatch 498 mSmsCbPageMap.remove(concatInfo); 499 } else { 500 // Single page message 501 pdus = new byte[1][]; 502 pdus[0] = receivedPdu; 503 } 504 505 // Remove messages that are out of scope to prevent the map from 506 // growing indefinitely, containing incomplete messages that were 507 // never assembled 508 Iterator<SmsCbConcatInfo> iter = mSmsCbPageMap.keySet().iterator(); 509 510 while (iter.hasNext()) { 511 SmsCbConcatInfo info = iter.next(); 512 513 if (!info.matchesLocation(plmn, lac, cid)) { 514 iter.remove(); 515 } 516 } 517 518 return GsmSmsCbMessage.createSmsCbMessage(mContext, header, location, pdus, slotIndex); 519 520 } catch (RuntimeException e) { 521 final String errorMessage = "Error in decoding SMS CB pdu: " + e.toString(); 522 e.printStackTrace(); 523 loge(errorMessage); 524 CellBroadcastStatsLog.write(CellBroadcastStatsLog.CB_MESSAGE_ERROR, 525 CELL_BROADCAST_MESSAGE_ERROR__TYPE__GSM_INVALID_PDU, errorMessage); 526 return null; 527 } 528 } 529 createSmsCbHeader(byte[] bytes)530 private SmsCbHeader createSmsCbHeader(byte[] bytes) { 531 try { 532 return new SmsCbHeader(bytes); 533 } catch (Exception ex) { 534 loge("Can't create SmsCbHeader, ex = " + ex.toString()); 535 return null; 536 } 537 } 538 539 /** 540 * Holds all info about a message page needed to assemble a complete concatenated message. 541 */ 542 private static final class SmsCbConcatInfo { 543 544 private final SmsCbHeader mHeader; 545 private final SmsCbLocation mLocation; 546 SmsCbConcatInfo(SmsCbHeader header, SmsCbLocation location)547 SmsCbConcatInfo(SmsCbHeader header, SmsCbLocation location) { 548 mHeader = header; 549 mLocation = location; 550 } 551 552 @Override hashCode()553 public int hashCode() { 554 return (mHeader.getSerialNumber() * 31) + mLocation.hashCode(); 555 } 556 557 @Override equals(Object obj)558 public boolean equals(Object obj) { 559 if (obj instanceof SmsCbConcatInfo) { 560 SmsCbConcatInfo other = (SmsCbConcatInfo) obj; 561 562 // Two pages match if they have the same serial number (which includes the 563 // geographical scope and update number), and both pages belong to the same 564 // location (PLMN, plus LAC and CID if these are part of the geographical scope). 565 return mHeader.getSerialNumber() == other.mHeader.getSerialNumber() 566 && mLocation.equals(other.mLocation); 567 } 568 569 return false; 570 } 571 572 /** 573 * Compare the location code for this message to the current location code. The match is 574 * relative to the geographical scope of the message, which determines whether the LAC 575 * and Cell ID are saved in mLocation or set to -1 to match all values. 576 * 577 * @param plmn the current PLMN 578 * @param lac the current Location Area (GSM) or Service Area (UMTS) 579 * @param cid the current Cell ID 580 * @return true if this message is valid for the current location; false otherwise 581 */ matchesLocation(String plmn, int lac, int cid)582 public boolean matchesLocation(String plmn, int lac, int cid) { 583 return mLocation.isInLocationArea(plmn, lac, cid); 584 } 585 } 586 } 587