1 /* 2 * Copyright (C) 2015 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 package com.android.settingslib.drawer; 17 18 import android.app.ActivityManager; 19 import android.content.Context; 20 import android.content.IContentProvider; 21 import android.content.Intent; 22 import android.content.pm.ActivityInfo; 23 import android.content.pm.ApplicationInfo; 24 import android.content.pm.PackageManager; 25 import android.content.pm.ResolveInfo; 26 import android.content.res.Resources; 27 import android.graphics.drawable.Icon; 28 import android.net.Uri; 29 import android.os.Bundle; 30 import android.os.RemoteException; 31 import android.os.UserHandle; 32 import android.os.UserManager; 33 import android.provider.Settings.Global; 34 import android.text.TextUtils; 35 import android.util.Log; 36 import android.util.Pair; 37 import android.widget.RemoteViews; 38 39 import java.util.ArrayList; 40 import java.util.Collections; 41 import java.util.Comparator; 42 import java.util.HashMap; 43 import java.util.List; 44 import java.util.Map; 45 46 public class TileUtils { 47 48 private static final boolean DEBUG = false; 49 private static final boolean DEBUG_TIMING = false; 50 51 private static final String LOG_TAG = "TileUtils"; 52 53 /** 54 * Settings will search for system activities of this action and add them as a top level 55 * settings tile using the following parameters. 56 * 57 * <p>A category must be specified in the meta-data for the activity named 58 * {@link #EXTRA_CATEGORY_KEY} 59 * 60 * <p>The title may be defined by meta-data named {@link #META_DATA_PREFERENCE_TITLE} 61 * otherwise the label for the activity will be used. 62 * 63 * <p>The icon may be defined by meta-data named {@link #META_DATA_PREFERENCE_ICON} 64 * otherwise the icon for the activity will be used. 65 * 66 * <p>A summary my be defined by meta-data named {@link #META_DATA_PREFERENCE_SUMMARY} 67 */ 68 public static final String EXTRA_SETTINGS_ACTION = 69 "com.android.settings.action.EXTRA_SETTINGS"; 70 71 /** 72 * @See {@link #EXTRA_SETTINGS_ACTION}. 73 */ 74 private static final String IA_SETTINGS_ACTION = 75 "com.android.settings.action.IA_SETTINGS"; 76 77 78 /** 79 * Same as #EXTRA_SETTINGS_ACTION but used for the platform Settings activities. 80 */ 81 private static final String SETTINGS_ACTION = 82 "com.android.settings.action.SETTINGS"; 83 84 private static final String OPERATOR_SETTINGS = 85 "com.android.settings.OPERATOR_APPLICATION_SETTING"; 86 87 private static final String OPERATOR_DEFAULT_CATEGORY = 88 "com.android.settings.category.wireless"; 89 90 private static final String MANUFACTURER_SETTINGS = 91 "com.android.settings.MANUFACTURER_APPLICATION_SETTING"; 92 93 private static final String MANUFACTURER_DEFAULT_CATEGORY = 94 "com.android.settings.category.device"; 95 96 /** 97 * The key used to get the category from metadata of activities of action 98 * {@link #EXTRA_SETTINGS_ACTION} 99 * The value must be one of: 100 * <li>com.android.settings.category.wireless</li> 101 * <li>com.android.settings.category.device</li> 102 * <li>com.android.settings.category.personal</li> 103 * <li>com.android.settings.category.system</li> 104 */ 105 private static final String EXTRA_CATEGORY_KEY = "com.android.settings.category"; 106 107 /** 108 * The key used to get the package name of the icon resource for the preference. 109 */ 110 private static final String EXTRA_PREFERENCE_ICON_PACKAGE = 111 "com.android.settings.icon_package"; 112 113 /** 114 * Name of the meta-data item that should be set in the AndroidManifest.xml 115 * to specify the key that should be used for the preference. 116 */ 117 public static final String META_DATA_PREFERENCE_KEYHINT = "com.android.settings.keyhint"; 118 119 /** 120 * Name of the meta-data item that should be set in the AndroidManifest.xml 121 * to specify the icon that should be displayed for the preference. 122 */ 123 public static final String META_DATA_PREFERENCE_ICON = "com.android.settings.icon"; 124 125 /** 126 * Name of the meta-data item that should be set in the AndroidManifest.xml 127 * to specify the icon background color. The value may or may not be used by Settings app. 128 */ 129 public static final String META_DATA_PREFERENCE_ICON_BACKGROUND_HINT = 130 "com.android.settings.bg.hint"; 131 132 /** 133 * Name of the meta-data item that should be set in the AndroidManifest.xml 134 * to specify the content provider providing the icon that should be displayed for 135 * the preference. 136 * 137 * Icon provided by the content provider overrides any static icon. 138 */ 139 public static final String META_DATA_PREFERENCE_ICON_URI = "com.android.settings.icon_uri"; 140 141 /** 142 * Name of the meta-data item that should be set in the AndroidManifest.xml 143 * to specify whether the icon is tintable. This should be a boolean value {@code true} or 144 * {@code false}, set using {@code android:value} 145 */ 146 public static final String META_DATA_PREFERENCE_ICON_TINTABLE = 147 "com.android.settings.icon_tintable"; 148 149 /** 150 * Name of the meta-data item that should be set in the AndroidManifest.xml 151 * to specify the title that should be displayed for the preference. 152 * 153 * <p>Note: It is preferred to provide this value using {@code android:resource} with a string 154 * resource for localization. 155 */ 156 public static final String META_DATA_PREFERENCE_TITLE = "com.android.settings.title"; 157 158 /** 159 * Name of the meta-data item that should be set in the AndroidManifest.xml 160 * to specify the summary text that should be displayed for the preference. 161 */ 162 public static final String META_DATA_PREFERENCE_SUMMARY = "com.android.settings.summary"; 163 164 /** 165 * Name of the meta-data item that should be set in the AndroidManifest.xml 166 * to specify the content provider providing the summary text that should be displayed for the 167 * preference. 168 * 169 * Summary provided by the content provider overrides any static summary. 170 */ 171 public static final String META_DATA_PREFERENCE_SUMMARY_URI = 172 "com.android.settings.summary_uri"; 173 174 /** 175 * Name of the meta-data item that should be set in the AndroidManifest.xml to specify the 176 * custom view which should be displayed for the preference. The custom view will be inflated 177 * as a remote view. 178 * 179 * This also can be used with {@link #META_DATA_PREFERENCE_SUMMARY_URI}, by setting the id 180 * of the summary TextView to '@android:id/summary'. 181 */ 182 public static final String META_DATA_PREFERENCE_CUSTOM_VIEW = 183 "com.android.settings.custom_view"; 184 185 public static final String SETTING_PKG = "com.android.settings"; 186 187 /** 188 * Build a list of DashboardCategory. Each category must be defined in manifest. 189 * eg: .Settings$DeviceSettings 190 * @deprecated 191 */ 192 @Deprecated 193 public static List<DashboardCategory> getCategories(Context context, 194 Map<Pair<String, String>, Tile> cache) { 195 return getCategories(context, cache, true /*categoryDefinedInManifest*/); 196 } 197 198 /** 199 * Build a list of DashboardCategory. 200 * @param categoryDefinedInManifest If true, an dummy activity must exists in manifest to 201 * represent this category (eg: .Settings$DeviceSettings) 202 */ 203 public static List<DashboardCategory> getCategories(Context context, 204 Map<Pair<String, String>, Tile> cache, boolean categoryDefinedInManifest) { 205 return getCategories(context, cache, categoryDefinedInManifest, null, SETTING_PKG); 206 } 207 208 /** 209 * Build a list of DashboardCategory. 210 * @param categoryDefinedInManifest If true, an dummy activity must exists in manifest to 211 * represent this category (eg: .Settings$DeviceSettings) 212 * @param extraAction additional intent filter action to be usetileutild to build the dashboard 213 * categories 214 */ 215 public static List<DashboardCategory> getCategories(Context context, 216 Map<Pair<String, String>, Tile> cache, boolean categoryDefinedInManifest, 217 String extraAction, String settingPkg) { 218 final long startTime = System.currentTimeMillis(); 219 boolean setup = Global.getInt(context.getContentResolver(), Global.DEVICE_PROVISIONED, 0) 220 != 0; 221 ArrayList<Tile> tiles = new ArrayList<>(); 222 UserManager userManager = (UserManager) context.getSystemService(Context.USER_SERVICE); 223 for (UserHandle user : userManager.getUserProfiles()) { 224 // TODO: Needs much optimization, too many PM queries going on here. 225 if (user.getIdentifier() == ActivityManager.getCurrentUser()) { 226 // Only add Settings for this user. 227 getTilesForAction(context, user, SETTINGS_ACTION, cache, null, tiles, true, 228 settingPkg); 229 getTilesForAction(context, user, OPERATOR_SETTINGS, cache, 230 OPERATOR_DEFAULT_CATEGORY, tiles, false, true, settingPkg); 231 getTilesForAction(context, user, MANUFACTURER_SETTINGS, cache, 232 MANUFACTURER_DEFAULT_CATEGORY, tiles, false, true, settingPkg); 233 } 234 if (setup) { 235 getTilesForAction(context, user, EXTRA_SETTINGS_ACTION, cache, null, tiles, false, 236 settingPkg); 237 if (!categoryDefinedInManifest) { 238 getTilesForAction(context, user, IA_SETTINGS_ACTION, cache, null, tiles, false, 239 settingPkg); 240 if (extraAction != null) { 241 getTilesForAction(context, user, extraAction, cache, null, tiles, false, 242 settingPkg); 243 } 244 } 245 } 246 } 247 248 HashMap<String, DashboardCategory> categoryMap = new HashMap<>(); 249 for (Tile tile : tiles) { 250 DashboardCategory category = categoryMap.get(tile.category); 251 if (category == null) { 252 category = createCategory(context, tile.category, categoryDefinedInManifest); 253 if (category == null) { 254 Log.w(LOG_TAG, "Couldn't find category " + tile.category); 255 continue; 256 } 257 categoryMap.put(category.key, category); 258 } 259 category.addTile(tile); 260 } 261 ArrayList<DashboardCategory> categories = new ArrayList<>(categoryMap.values()); 262 for (DashboardCategory category : categories) { 263 category.sortTiles(); 264 } 265 Collections.sort(categories, CATEGORY_COMPARATOR); 266 if (DEBUG_TIMING) Log.d(LOG_TAG, "getCategories took " 267 + (System.currentTimeMillis() - startTime) + " ms"); 268 return categories; 269 } 270 271 /** 272 * Create a new DashboardCategory from key. 273 * 274 * @param context Context to query intent 275 * @param categoryKey The category key 276 * @param categoryDefinedInManifest If true, an dummy activity must exists in manifest to 277 * represent this category (eg: .Settings$DeviceSettings) 278 */ 279 private static DashboardCategory createCategory(Context context, String categoryKey, 280 boolean categoryDefinedInManifest) { 281 DashboardCategory category = new DashboardCategory(); 282 category.key = categoryKey; 283 if (!categoryDefinedInManifest) { 284 return category; 285 } 286 PackageManager pm = context.getPackageManager(); 287 List<ResolveInfo> results = pm.queryIntentActivities(new Intent(categoryKey), 0); 288 if (results.size() == 0) { 289 return null; 290 } 291 for (ResolveInfo resolved : results) { 292 if (!resolved.system) { 293 // Do not allow any app to add to settings, only system ones. 294 continue; 295 } 296 category.title = resolved.activityInfo.loadLabel(pm); 297 category.priority = SETTING_PKG.equals( 298 resolved.activityInfo.applicationInfo.packageName) ? resolved.priority : 0; 299 if (DEBUG) Log.d(LOG_TAG, "Adding category " + category.title); 300 } 301 302 return category; 303 } 304 305 private static void getTilesForAction(Context context, 306 UserHandle user, String action, Map<Pair<String, String>, Tile> addedCache, 307 String defaultCategory, ArrayList<Tile> outTiles, boolean requireSettings, 308 String settingPkg) { 309 getTilesForAction(context, user, action, addedCache, defaultCategory, outTiles, 310 requireSettings, requireSettings, settingPkg); 311 } 312 313 private static void getTilesForAction(Context context, 314 UserHandle user, String action, Map<Pair<String, String>, Tile> addedCache, 315 String defaultCategory, ArrayList<Tile> outTiles, boolean requireSettings, 316 boolean usePriority, String settingPkg) { 317 Intent intent = new Intent(action); 318 if (requireSettings) { 319 intent.setPackage(settingPkg); 320 } 321 getTilesForIntent(context, user, intent, addedCache, defaultCategory, outTiles, 322 usePriority, true, true); 323 } 324 325 public static void getTilesForIntent( 326 Context context, UserHandle user, Intent intent, 327 Map<Pair<String, String>, Tile> addedCache, String defaultCategory, List<Tile> outTiles, 328 boolean usePriority, boolean checkCategory, boolean forceTintExternalIcon) { 329 getTilesForIntent(context, user, intent, addedCache, defaultCategory, outTiles, 330 usePriority, checkCategory, forceTintExternalIcon, false /* shouldUpdateTiles */); 331 } 332 333 public static void getTilesForIntent( 334 Context context, UserHandle user, Intent intent, 335 Map<Pair<String, String>, Tile> addedCache, String defaultCategory, List<Tile> outTiles, 336 boolean usePriority, boolean checkCategory, boolean forceTintExternalIcon, 337 boolean shouldUpdateTiles) { 338 PackageManager pm = context.getPackageManager(); 339 List<ResolveInfo> results = pm.queryIntentActivitiesAsUser(intent, 340 PackageManager.GET_META_DATA, user.getIdentifier()); 341 Map<String, IContentProvider> providerMap = new HashMap<>(); 342 for (ResolveInfo resolved : results) { 343 if (!resolved.system) { 344 // Do not allow any app to add to settings, only system ones. 345 continue; 346 } 347 ActivityInfo activityInfo = resolved.activityInfo; 348 Bundle metaData = activityInfo.metaData; 349 String categoryKey = defaultCategory; 350 351 // Load category 352 if (checkCategory && ((metaData == null) || !metaData.containsKey(EXTRA_CATEGORY_KEY)) 353 && categoryKey == null) { 354 Log.w(LOG_TAG, "Found " + resolved.activityInfo.name + " for intent " 355 + intent + " missing metadata " 356 + (metaData == null ? "" : EXTRA_CATEGORY_KEY)); 357 continue; 358 } else { 359 categoryKey = metaData.getString(EXTRA_CATEGORY_KEY); 360 } 361 362 Pair<String, String> key = new Pair<String, String>(activityInfo.packageName, 363 activityInfo.name); 364 Tile tile = addedCache.get(key); 365 if (tile == null) { 366 tile = new Tile(); 367 tile.intent = new Intent().setClassName( 368 activityInfo.packageName, activityInfo.name); 369 tile.category = categoryKey; 370 tile.priority = usePriority ? resolved.priority : 0; 371 tile.metaData = activityInfo.metaData; 372 updateTileData(context, tile, activityInfo, activityInfo.applicationInfo, 373 pm, providerMap, forceTintExternalIcon); 374 if (DEBUG) Log.d(LOG_TAG, "Adding tile " + tile.title); 375 addedCache.put(key, tile); 376 } else if (shouldUpdateTiles) { 377 updateSummaryAndTitle(context, providerMap, tile); 378 } 379 380 if (!tile.userHandle.contains(user)) { 381 tile.userHandle.add(user); 382 } 383 if (!outTiles.contains(tile)) { 384 outTiles.add(tile); 385 } 386 } 387 } 388 389 private static boolean updateTileData(Context context, Tile tile, 390 ActivityInfo activityInfo, ApplicationInfo applicationInfo, PackageManager pm, 391 Map<String, IContentProvider> providerMap, boolean forceTintExternalIcon) { 392 if (applicationInfo.isSystemApp()) { 393 boolean forceTintIcon = false; 394 int icon = 0; 395 Pair<String, Integer> iconFromUri = null; 396 CharSequence title = null; 397 String summary = null; 398 String keyHint = null; 399 boolean isIconTintable = false; 400 401 // Get the activity's meta-data 402 try { 403 Resources res = pm.getResourcesForApplication(applicationInfo.packageName); 404 Bundle metaData = activityInfo.metaData; 405 406 if (forceTintExternalIcon 407 && !context.getPackageName().equals(applicationInfo.packageName)) { 408 isIconTintable = true; 409 forceTintIcon = true; 410 } 411 412 if (res != null && metaData != null) { 413 if (metaData.containsKey(META_DATA_PREFERENCE_ICON)) { 414 icon = metaData.getInt(META_DATA_PREFERENCE_ICON); 415 } 416 if (metaData.containsKey(META_DATA_PREFERENCE_ICON_TINTABLE)) { 417 if (forceTintIcon) { 418 Log.w(LOG_TAG, "Ignoring icon tintable for " + activityInfo); 419 } else { 420 isIconTintable = 421 metaData.getBoolean(META_DATA_PREFERENCE_ICON_TINTABLE); 422 } 423 } 424 if (metaData.containsKey(META_DATA_PREFERENCE_TITLE)) { 425 if (metaData.get(META_DATA_PREFERENCE_TITLE) instanceof Integer) { 426 title = res.getString(metaData.getInt(META_DATA_PREFERENCE_TITLE)); 427 } else { 428 title = metaData.getString(META_DATA_PREFERENCE_TITLE); 429 } 430 } 431 if (metaData.containsKey(META_DATA_PREFERENCE_SUMMARY)) { 432 if (metaData.get(META_DATA_PREFERENCE_SUMMARY) instanceof Integer) { 433 summary = res.getString(metaData.getInt(META_DATA_PREFERENCE_SUMMARY)); 434 } else { 435 summary = metaData.getString(META_DATA_PREFERENCE_SUMMARY); 436 } 437 } 438 if (metaData.containsKey(META_DATA_PREFERENCE_KEYHINT)) { 439 if (metaData.get(META_DATA_PREFERENCE_KEYHINT) instanceof Integer) { 440 keyHint = res.getString(metaData.getInt(META_DATA_PREFERENCE_KEYHINT)); 441 } else { 442 keyHint = metaData.getString(META_DATA_PREFERENCE_KEYHINT); 443 } 444 } 445 if (metaData.containsKey(META_DATA_PREFERENCE_CUSTOM_VIEW)) { 446 int layoutId = metaData.getInt(META_DATA_PREFERENCE_CUSTOM_VIEW); 447 tile.remoteViews = new RemoteViews(applicationInfo.packageName, layoutId); 448 updateSummaryAndTitle(context, providerMap, tile); 449 } 450 } 451 } catch (PackageManager.NameNotFoundException | Resources.NotFoundException e) { 452 if (DEBUG) Log.d(LOG_TAG, "Couldn't find info", e); 453 } 454 455 // Set the preference title to the activity's label if no 456 // meta-data is found 457 if (TextUtils.isEmpty(title)) { 458 title = activityInfo.loadLabel(pm).toString(); 459 } 460 461 // Set the icon 462 if (icon == 0) { 463 // Only fallback to activityinfo.icon if metadata does not contain ICON_URI. 464 // ICON_URI should be loaded in app UI when need the icon object. 465 if (!tile.metaData.containsKey(META_DATA_PREFERENCE_ICON_URI)) { 466 icon = activityInfo.icon; 467 } 468 } 469 if (icon != 0) { 470 tile.icon = Icon.createWithResource(activityInfo.packageName, icon); 471 } 472 473 // Set title and summary for the preference 474 tile.title = title; 475 tile.summary = summary; 476 // Replace the intent with this specific activity 477 tile.intent = new Intent().setClassName(activityInfo.packageName, 478 activityInfo.name); 479 // Suggest a key for this tile 480 tile.key = keyHint; 481 tile.isIconTintable = isIconTintable; 482 483 return true; 484 } 485 486 return false; 487 } 488 489 private static void updateSummaryAndTitle( 490 Context context, Map<String, IContentProvider> providerMap, Tile tile) { 491 if (tile == null || tile.metaData == null 492 || !tile.metaData.containsKey(META_DATA_PREFERENCE_SUMMARY_URI)) { 493 return; 494 } 495 496 String uriString = tile.metaData.getString(META_DATA_PREFERENCE_SUMMARY_URI); 497 Bundle bundle = getBundleFromUri(context, uriString, providerMap); 498 String overrideSummary = getString(bundle, META_DATA_PREFERENCE_SUMMARY); 499 String overrideTitle = getString(bundle, META_DATA_PREFERENCE_TITLE); 500 if (overrideSummary != null) { 501 tile.remoteViews.setTextViewText(android.R.id.summary, overrideSummary); 502 } 503 504 if (overrideTitle != null) { 505 tile.remoteViews.setTextViewText(android.R.id.title, overrideTitle); 506 } 507 } 508 509 /** 510 * Gets the icon package name and resource id from content provider. 511 * @param context context 512 * @param packageName package name of the target activity 513 * @param uriString URI for the content provider 514 * @param providerMap Maps URI authorities to providers 515 * @return package name and resource id of the icon specified 516 */ 517 public static Pair<String, Integer> getIconFromUri(Context context, String packageName, 518 String uriString, Map<String, IContentProvider> providerMap) { 519 Bundle bundle = getBundleFromUri(context, uriString, providerMap); 520 if (bundle == null) { 521 return null; 522 } 523 String iconPackageName = bundle.getString(EXTRA_PREFERENCE_ICON_PACKAGE); 524 if (TextUtils.isEmpty(iconPackageName)) { 525 return null; 526 } 527 int resId = bundle.getInt(META_DATA_PREFERENCE_ICON, 0); 528 if (resId == 0) { 529 return null; 530 } 531 // Icon can either come from the target package or from the Settings app. 532 if (iconPackageName.equals(packageName) 533 || iconPackageName.equals(context.getPackageName())) { 534 return Pair.create(iconPackageName, bundle.getInt(META_DATA_PREFERENCE_ICON, 0)); 535 } 536 return null; 537 } 538 539 /** 540 * Gets text associated with the input key from the content provider. 541 * @param context context 542 * @param uriString URI for the content provider 543 * @param providerMap Maps URI authorities to providers 544 * @param key Key mapping to the text in bundle returned by the content provider 545 * @return Text associated with the key, if returned by the content provider 546 */ 547 public static String getTextFromUri(Context context, String uriString, 548 Map<String, IContentProvider> providerMap, String key) { 549 Bundle bundle = getBundleFromUri(context, uriString, providerMap); 550 return (bundle != null) ? bundle.getString(key) : null; 551 } 552 553 private static Bundle getBundleFromUri(Context context, String uriString, 554 Map<String, IContentProvider> providerMap) { 555 if (TextUtils.isEmpty(uriString)) { 556 return null; 557 } 558 Uri uri = Uri.parse(uriString); 559 String method = getMethodFromUri(uri); 560 if (TextUtils.isEmpty(method)) { 561 return null; 562 } 563 IContentProvider provider = getProviderFromUri(context, uri, providerMap); 564 if (provider == null) { 565 return null; 566 } 567 try { 568 return provider.call(context.getPackageName(), method, uriString, null); 569 } catch (RemoteException e) { 570 return null; 571 } 572 } 573 574 private static String getString(Bundle bundle, String key) { 575 return bundle == null ? null : bundle.getString(key); 576 } 577 578 private static IContentProvider getProviderFromUri(Context context, Uri uri, 579 Map<String, IContentProvider> providerMap) { 580 if (uri == null) { 581 return null; 582 } 583 String authority = uri.getAuthority(); 584 if (TextUtils.isEmpty(authority)) { 585 return null; 586 } 587 if (!providerMap.containsKey(authority)) { 588 providerMap.put(authority, context.getContentResolver().acquireUnstableProvider(uri)); 589 } 590 return providerMap.get(authority); 591 } 592 593 /** Returns the first path segment of the uri if it exists as the method, otherwise null. */ 594 static String getMethodFromUri(Uri uri) { 595 if (uri == null) { 596 return null; 597 } 598 List<String> pathSegments = uri.getPathSegments(); 599 if ((pathSegments == null) || pathSegments.isEmpty()) { 600 return null; 601 } 602 return pathSegments.get(0); 603 } 604 605 private static final Comparator<DashboardCategory> CATEGORY_COMPARATOR = 606 new Comparator<DashboardCategory>() { 607 @Override 608 public int compare(DashboardCategory lhs, DashboardCategory rhs) { 609 return rhs.priority - lhs.priority; 610 } 611 }; 612 } 613