1 // Copyright 2019 The Chromium Authors. All rights reserved. 2 // Use of this source code is governed by a BSD-style license that can be 3 // found in the LICENSE file. 4 5 package org.chromium.android_webview.devui; 6 7 import android.app.Activity; 8 import android.app.AlertDialog; 9 import android.content.ClipData; 10 import android.content.ClipboardManager; 11 import android.content.Context; 12 import android.content.Intent; 13 import android.content.pm.PackageManager; 14 import android.content.pm.ResolveInfo; 15 import android.database.DataSetObserver; 16 import android.graphics.drawable.Drawable; 17 import android.os.Bundle; 18 import android.view.LayoutInflater; 19 import android.view.Menu; 20 import android.view.MenuInflater; 21 import android.view.MenuItem; 22 import android.view.View; 23 import android.view.ViewGroup; 24 import android.widget.BaseExpandableListAdapter; 25 import android.widget.Button; 26 import android.widget.ExpandableListView; 27 import android.widget.ImageButton; 28 import android.widget.ImageView; 29 import android.widget.TextView; 30 31 import androidx.annotation.IntDef; 32 import androidx.annotation.MainThread; 33 import androidx.annotation.NonNull; 34 import androidx.annotation.Nullable; 35 import androidx.annotation.VisibleForTesting; 36 import androidx.annotation.WorkerThread; 37 38 import org.chromium.android_webview.common.DeveloperModeUtils; 39 import org.chromium.android_webview.common.PlatformServiceBridge; 40 import org.chromium.android_webview.common.crash.CrashInfo; 41 import org.chromium.android_webview.common.crash.CrashInfo.UploadState; 42 import org.chromium.android_webview.common.crash.CrashUploadUtil; 43 import org.chromium.android_webview.devui.util.CrashBugUrlFactory; 44 import org.chromium.android_webview.devui.util.WebViewCrashInfoCollector; 45 import org.chromium.base.BaseSwitches; 46 import org.chromium.base.CommandLine; 47 import org.chromium.base.Log; 48 import org.chromium.base.metrics.RecordHistogram; 49 import org.chromium.base.task.AsyncTask; 50 import org.chromium.components.version_info.Channel; 51 import org.chromium.components.version_info.VersionConstants; 52 import org.chromium.ui.widget.Toast; 53 54 import java.util.ArrayList; 55 import java.util.Date; 56 import java.util.List; 57 import java.util.Locale; 58 59 /** 60 * A fragment to show a list of recent WebView crashes. 61 */ 62 public class CrashesListFragment extends DevUiBaseFragment { 63 private static final String TAG = "WebViewDevTools"; 64 65 public static final String CRASH_BUG_DIALOG_MESSAGE = 66 "This crash has already been reported to our crash system. " 67 + "Do you want to share more information, such as steps to reproduce the crash?"; 68 public static final String NO_WIFI_DIALOG_MESSAGE = 69 "You are connected to a metered network or cellular data." 70 + " Do you want to proceed?"; 71 public static final String CRASH_COLLECTION_DISABLED_ERROR_MESSAGE = 72 "Crash collection is disabled. Please turn on 'Usage & diagnostics' " 73 + "from the three-dotted menu in Google settings."; 74 public static final String NO_GMS_ERROR_MESSAGE = 75 "Crash collection is not supported at the moment."; 76 77 public static final String USAGE_AND_DIAGONSTICS_ACTIVITY_INTENT_ACTION = 78 "com.android.settings.action.EXTRA_SETTINGS"; 79 80 // Max number of crashes to show in the crashes list. 81 public static final int MAX_CRASHES_NUMBER = 20; 82 83 private CrashListExpandableAdapter mCrashListViewAdapter; 84 private Context mContext; 85 86 private static @Nullable Runnable sCrashInfoLoadedListener; 87 88 // These values are persisted to logs. Entries should not be renumbered and 89 // numeric values should never be reused. 90 @IntDef({CollectionState.ENABLED_BY_COMMANDLINE, CollectionState.ENABLED_BY_FLAG_UI, 91 CollectionState.ENABLED_BY_USER_CONSENT, CollectionState.DISABLED_BY_USER_CONSENT, 92 CollectionState.DISABLED_BY_USER_CONSENT_CANNOT_FIND_SETTINGS, 93 CollectionState.DISABLED_CANNOT_USE_GMS}) 94 private @interface CollectionState { 95 int ENABLED_BY_COMMANDLINE = 0; 96 int ENABLED_BY_FLAG_UI = 1; 97 int ENABLED_BY_USER_CONSENT = 2; 98 int DISABLED_BY_USER_CONSENT = 3; 99 int DISABLED_BY_USER_CONSENT_CANNOT_FIND_SETTINGS = 4; 100 int DISABLED_CANNOT_USE_GMS = 5; 101 int COUNT = 6; 102 } 103 logCrashCollectionState(@ollectionState int state)104 private static void logCrashCollectionState(@CollectionState int state) { 105 RecordHistogram.recordEnumeratedHistogram( 106 "Android.WebView.DevUi.CrashList.CollectionState", state, CollectionState.COUNT); 107 } 108 109 // These values are persisted to logs. Entries should not be renumbered and 110 // numeric values should never be reused. 111 @IntDef({CrashInteraction.FORCE_UPLOAD_BUTTON, CrashInteraction.FORCE_UPLOAD_NO_DIALOG, 112 CrashInteraction.FORCE_UPLOAD_DIALOG_METERED_NETWORK, 113 CrashInteraction.FORCE_UPLOAD_DIALOG_CANCEL, CrashInteraction.FILE_BUG_REPORT_BUTTON, 114 CrashInteraction.FILE_BUG_REPORT_DIALOG_PROCEED, 115 CrashInteraction.FILE_BUG_REPORT_DIALOG_DISMISS, CrashInteraction.HIDE_CRASH_BUTTON}) 116 private @interface CrashInteraction { 117 int FORCE_UPLOAD_BUTTON = 0; 118 int FORCE_UPLOAD_NO_DIALOG = 1; 119 int FORCE_UPLOAD_DIALOG_METERED_NETWORK = 2; 120 int FORCE_UPLOAD_DIALOG_CANCEL = 3; 121 int FILE_BUG_REPORT_BUTTON = 4; 122 int FILE_BUG_REPORT_DIALOG_PROCEED = 5; 123 int FILE_BUG_REPORT_DIALOG_DISMISS = 6; 124 int HIDE_CRASH_BUTTON = 7; 125 int COUNT = 8; 126 } 127 logCrashInteraction(@rashInteraction int action)128 private static void logCrashInteraction(@CrashInteraction int action) { 129 RecordHistogram.recordEnumeratedHistogram( 130 "Android.WebView.DevUi.CrashList.CrashInteraction", action, CrashInteraction.COUNT); 131 } 132 133 @Override onAttach(Context context)134 public void onAttach(Context context) { 135 super.onAttach(context); 136 mContext = context; 137 } 138 139 @Override onCreate(Bundle savedInstanceState)140 public void onCreate(Bundle savedInstanceState) { 141 super.onCreate(savedInstanceState); 142 setHasOptionsMenu(true); 143 } 144 145 @Override onCreateView( LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState)146 public View onCreateView( 147 LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState) { 148 return inflater.inflate(R.layout.fragment_crashes_list, null); 149 } 150 151 @Override onViewCreated(View view, Bundle savedInstanceState)152 public void onViewCreated(View view, Bundle savedInstanceState) { 153 Activity activity = (Activity) mContext; 154 activity.setTitle("WebView Crashes"); 155 156 TextView crashesSummaryView = view.findViewById(R.id.crashes_summary_textview); 157 mCrashListViewAdapter = new CrashListExpandableAdapter(crashesSummaryView); 158 ExpandableListView crashListView = view.findViewById(R.id.crashes_list); 159 crashListView.setAdapter(mCrashListViewAdapter); 160 } 161 162 @Override onResume()163 public void onResume() { 164 super.onResume(); 165 mCrashListViewAdapter.updateCrashes(); 166 } 167 isCrashUploadsEnabledFromCommandLine()168 private boolean isCrashUploadsEnabledFromCommandLine() { 169 return CommandLine.getInstance().hasSwitch(BaseSwitches.ENABLE_CRASH_REPORTER_FOR_TESTING); 170 } 171 isCrashUploadsEnabledFromFlagsUi()172 private boolean isCrashUploadsEnabledFromFlagsUi() { 173 if (DeveloperModeUtils.isDeveloperModeEnabled(mContext.getPackageName())) { 174 Boolean flagValue = DeveloperModeUtils.getFlagOverrides(mContext.getPackageName()) 175 .get(BaseSwitches.ENABLE_CRASH_REPORTER_FOR_TESTING); 176 return Boolean.TRUE.equals(flagValue); 177 } 178 return false; 179 } 180 181 /** 182 * Adapter to create crashes list items from a list of CrashInfo. 183 */ 184 private class CrashListExpandableAdapter extends BaseExpandableListAdapter { 185 private List<CrashInfo> mCrashInfoList; 186 CrashListExpandableAdapter(TextView crashesSummaryView)187 CrashListExpandableAdapter(TextView crashesSummaryView) { 188 mCrashInfoList = new ArrayList<>(); 189 190 // Update crash summary when the data changes. 191 registerDataSetObserver(new DataSetObserver() { 192 @Override 193 public void onChanged() { 194 crashesSummaryView.setText( 195 String.format(Locale.US, "Crashes (%d)", mCrashInfoList.size())); 196 RecordHistogram.recordCount100Histogram( 197 "Android.WebView.DevUi.CrashList.NumberShown", mCrashInfoList.size()); 198 } 199 }); 200 } 201 202 // Group View which is used as header for a crash in crashes list. 203 // We show: 204 // - Icon of the app where the crash happened. 205 // - Package name of the app where the crash happened. 206 // - Time when the crash happened. 207 @Override getGroupView( int groupPosition, boolean isExpanded, View view, ViewGroup parent)208 public View getGroupView( 209 int groupPosition, boolean isExpanded, View view, ViewGroup parent) { 210 // If the the old view is already created then reuse it, else create a new one by layout 211 // inflation. 212 if (view == null) { 213 view = getLayoutInflater().inflate(R.layout.crashes_list_item_header, null); 214 } 215 216 CrashInfo crashInfo = (CrashInfo) getGroup(groupPosition); 217 218 ImageView packageIcon = view.findViewById(R.id.crash_package_icon); 219 String packageName = crashInfo.getCrashKey(CrashInfo.APP_PACKAGE_NAME_KEY); 220 if (packageName == null) { 221 // This can happen if crash log file where we keep crash info is cleared but other 222 // log files like upload logs still exist. 223 packageName = "unknown app"; 224 packageIcon.setImageResource(android.R.drawable.sym_def_app_icon); 225 } else { 226 try { 227 Drawable icon = mContext.getPackageManager().getApplicationIcon(packageName); 228 packageIcon.setImageDrawable(icon); 229 } catch (PackageManager.NameNotFoundException e) { 230 // This can happen if the app was uninstalled after the crash was recorded. 231 packageIcon.setImageResource(android.R.drawable.sym_def_app_icon); 232 } 233 } 234 setTwoLineListItemText(view.findViewById(R.id.crash_header), packageName, 235 new Date(crashInfo.captureTime).toString()); 236 return view; 237 } 238 239 // Child View where more info about the crash is shown: 240 // - Crash report upload status. 241 @Override getChildView(int groupPosition, final int childPosition, boolean isLastChild, View view, ViewGroup parent)242 public View getChildView(int groupPosition, final int childPosition, boolean isLastChild, 243 View view, ViewGroup parent) { 244 // If the the old view is already created then reuse it, else create a new one by layout 245 // inflation. 246 if (view == null) { 247 view = getLayoutInflater().inflate(R.layout.crashes_list_item_body, null); 248 } 249 250 CrashInfo crashInfo = (CrashInfo) getChild(groupPosition, childPosition); 251 252 // Upload info 253 String uploadState = uploadStateString(crashInfo.uploadState); 254 View uploadInfoView = view.findViewById(R.id.upload_status); 255 if (crashInfo.uploadState == UploadState.UPLOADED) { 256 final String uploadInfo = 257 new Date(crashInfo.uploadTime).toString() + "\nID: " + crashInfo.uploadId; 258 uploadInfoView.setOnLongClickListener(v -> { 259 ClipboardManager clipboard = 260 (ClipboardManager) mContext.getSystemService(Context.CLIPBOARD_SERVICE); 261 ClipData clip = ClipData.newPlainText("upload info", uploadInfo); 262 clipboard.setPrimaryClip(clip); 263 // Show a toast that the text has been copied. 264 Toast.makeText(mContext, "Copied upload info", Toast.LENGTH_SHORT).show(); 265 return true; 266 }); 267 setTwoLineListItemText(uploadInfoView, uploadState, uploadInfo); 268 } else { 269 setTwoLineListItemText(uploadInfoView, uploadState, null); 270 } 271 272 Button bugButton = view.findViewById(R.id.crash_report_button); 273 // Report button is only clickable if the crash report is uploaded. 274 if (crashInfo.uploadState == UploadState.UPLOADED) { 275 bugButton.setEnabled(true); 276 bugButton.setOnClickListener(v -> { 277 logCrashInteraction(CrashInteraction.FILE_BUG_REPORT_BUTTON); 278 buildCrashBugDialog(crashInfo).show(); 279 }); 280 } else { 281 bugButton.setEnabled(false); 282 } 283 284 Button uploadButton = view.findViewById(R.id.crash_upload_button); 285 if (crashInfo.uploadState == UploadState.SKIPPED 286 || crashInfo.uploadState == UploadState.PENDING) { 287 uploadButton.setVisibility(View.VISIBLE); 288 uploadButton.setOnClickListener(v -> { 289 if (!CrashUploadUtil.isNetworkUnmetered(mContext)) { 290 new AlertDialog.Builder(mContext) 291 .setTitle("Network Warning") 292 .setMessage(NO_WIFI_DIALOG_MESSAGE) 293 .setPositiveButton("Upload", 294 (dialog, id) -> { 295 logCrashInteraction( 296 CrashInteraction 297 .FORCE_UPLOAD_DIALOG_METERED_NETWORK); 298 attemptUploadCrash(crashInfo.localId); 299 }) 300 .setNegativeButton("Cancel", 301 (dialog, id) -> { 302 logCrashInteraction( 303 CrashInteraction.FORCE_UPLOAD_DIALOG_CANCEL); 304 dialog.dismiss(); 305 }) 306 .create() 307 .show(); 308 } else { 309 logCrashInteraction(CrashInteraction.FORCE_UPLOAD_NO_DIALOG); 310 attemptUploadCrash(crashInfo.localId); 311 } 312 }); 313 } else { 314 uploadButton.setVisibility(View.GONE); 315 } 316 317 ImageButton hideButton = view.findViewById(R.id.crash_hide_button); 318 hideButton.setOnClickListener(v -> { 319 logCrashInteraction(CrashInteraction.HIDE_CRASH_BUTTON); 320 crashInfo.isHidden = true; 321 WebViewCrashInfoCollector.updateCrashLogFileWithNewCrashInfo(crashInfo); 322 updateCrashes(); 323 }); 324 325 return view; 326 } 327 attemptUploadCrash(String crashLocalId)328 private void attemptUploadCrash(String crashLocalId) { 329 // Attempt uploading the file asynchronously, upload is not guaranteed. 330 CrashUploadUtil.tryUploadCrashDumpWithLocalId(mContext, crashLocalId); 331 // Update the uploadState to be PENDING_USER_REQUESTED or UPLOADED. 332 updateCrashes(); 333 } 334 335 @Override isChildSelectable(int groupPosition, int childPosition)336 public boolean isChildSelectable(int groupPosition, int childPosition) { 337 return true; 338 } 339 340 @Override getGroup(int groupPosition)341 public Object getGroup(int groupPosition) { 342 return mCrashInfoList.get(groupPosition); 343 } 344 345 @Override getChild(int groupPosition, int childPosition)346 public Object getChild(int groupPosition, int childPosition) { 347 return mCrashInfoList.get(groupPosition); 348 } 349 350 @Override getGroupId(int groupPosition)351 public long getGroupId(int groupPosition) { 352 // Hash code of local id is unique per crash info object. 353 return ((CrashInfo) getGroup(groupPosition)).localId.hashCode(); 354 } 355 356 @Override getChildId(int groupPosition, int childPosition)357 public long getChildId(int groupPosition, int childPosition) { 358 // Child ID refers to a piece of info in a particular crash report. It is stable 359 // since we don't change the order we show this info in runtime and currently we show 360 // all information in only one child item. 361 return childPosition; 362 } 363 364 @Override hasStableIds()365 public boolean hasStableIds() { 366 // Stable IDs mean both getGroupId and getChildId return stable IDs for each group and 367 // child i.e: an ID always refers to the same object. See getGroupId and getChildId for 368 // why the IDs are stable. 369 return true; 370 } 371 372 @Override getChildrenCount(int groupPosition)373 public int getChildrenCount(int groupPosition) { 374 // Crash info is shown in one child item. 375 return 1; 376 } 377 378 @Override getGroupCount()379 public int getGroupCount() { 380 return mCrashInfoList.size(); 381 } 382 383 /** 384 * Asynchronously load crash info on a background thread and then update the UI when the 385 * data is loaded. 386 */ updateCrashes()387 public void updateCrashes() { 388 AsyncTask<List<CrashInfo>> asyncTask = new AsyncTask<List<CrashInfo>>() { 389 @Override 390 @WorkerThread 391 protected List<CrashInfo> doInBackground() { 392 WebViewCrashInfoCollector crashCollector = new WebViewCrashInfoCollector(); 393 // Only show crashes from the same WebView channel, which usually means the 394 // same package. 395 List<CrashInfo> crashes = crashCollector.loadCrashesInfo(crashInfo -> { 396 @Channel 397 int channel = getCrashInfoChannel(crashInfo); 398 // Always show the crash if the channel is unknown (to handle missing 399 // channel info for example for crashes from older versions). 400 return channel == Channel.DEFAULT || channel == VersionConstants.CHANNEL; 401 }); 402 if (crashes.size() > MAX_CRASHES_NUMBER) { 403 return crashes.subList(0, MAX_CRASHES_NUMBER); 404 } 405 return crashes; 406 } 407 408 @Override 409 protected void onPostExecute(List<CrashInfo> result) { 410 mCrashInfoList = result; 411 notifyDataSetChanged(); 412 if (sCrashInfoLoadedListener != null) sCrashInfoLoadedListener.run(); 413 } 414 }; 415 asyncTask.executeOnExecutor(AsyncTask.THREAD_POOL_EXECUTOR); 416 } 417 } 418 419 @VisibleForTesting uploadStateString(UploadState uploadState)420 public static String uploadStateString(UploadState uploadState) { 421 switch (uploadState) { 422 case UPLOADED: 423 return "Uploaded"; 424 case PENDING: 425 case PENDING_USER_REQUESTED: 426 return "Pending upload"; 427 case SKIPPED: 428 return "Skipped upload"; 429 } 430 return null; 431 } 432 433 @Channel getCrashInfoChannel(@onNull CrashInfo c)434 private static int getCrashInfoChannel(@NonNull CrashInfo c) { 435 switch (c.getCrashKeyOrDefault(CrashInfo.WEBVIEW_CHANNEL_KEY, "default")) { 436 case "canary": 437 return Channel.CANARY; 438 case "dev": 439 return Channel.DEV; 440 case "beta": 441 return Channel.BETA; 442 case "stable": 443 return Channel.STABLE; 444 default: 445 return Channel.DEFAULT; 446 } 447 } 448 449 // Helper method to find and set text for two line list item. If a null String is passed, the 450 // relevant TextView will be hidden. setTwoLineListItemText( @onNull View view, @Nullable String title, @Nullable String subtitle)451 private static void setTwoLineListItemText( 452 @NonNull View view, @Nullable String title, @Nullable String subtitle) { 453 TextView titleView = view.findViewById(android.R.id.text1); 454 TextView subtitleView = view.findViewById(android.R.id.text2); 455 if (titleView != null) { 456 titleView.setVisibility(View.VISIBLE); 457 titleView.setText(title); 458 } else { 459 titleView.setVisibility(View.GONE); 460 } 461 if (subtitle != null) { 462 subtitleView.setVisibility(View.VISIBLE); 463 subtitleView.setText(subtitle); 464 } else { 465 subtitleView.setVisibility(View.GONE); 466 } 467 } 468 469 @Override maybeShowErrorView(PersistentErrorView errorView)470 void maybeShowErrorView(PersistentErrorView errorView) { 471 // Check if crash collection is enabled and show or hide the error message. 472 // Firstly, check for the flag value in commandline, since it doesn't require any IPCs. 473 // Then check for flags value in the DeveloperUi ContentProvider (it involves an IPC but 474 // it's guarded by quick developer mode check). Finally check the GMS service since it 475 // is the slowest check. 476 if (isCrashUploadsEnabledFromCommandLine()) { 477 logCrashCollectionState(CollectionState.ENABLED_BY_COMMANDLINE); 478 errorView.hide(); 479 } else if (isCrashUploadsEnabledFromFlagsUi()) { 480 logCrashCollectionState(CollectionState.ENABLED_BY_FLAG_UI); 481 errorView.hide(); 482 } else { 483 PlatformServiceBridge.getInstance().queryMetricsSetting(enabled -> { 484 if (Boolean.TRUE.equals(enabled)) { 485 logCrashCollectionState(CollectionState.ENABLED_BY_USER_CONSENT); 486 errorView.hide(); 487 } else { 488 buildCrashConsentError(errorView); 489 errorView.show(); 490 } 491 }); 492 } 493 } 494 buildCrashConsentError(PersistentErrorView errorView)495 private void buildCrashConsentError(PersistentErrorView errorView) { 496 if (PlatformServiceBridge.getInstance().canUseGms()) { 497 errorView.setText(CRASH_COLLECTION_DISABLED_ERROR_MESSAGE); 498 // Open Google Settings activity, "Usage & diagnostics" activity is not exported and 499 // cannot be opened directly. 500 Intent settingsIntent = new Intent(USAGE_AND_DIAGONSTICS_ACTIVITY_INTENT_ACTION); 501 List<ResolveInfo> intentResolveInfo = 502 mContext.getPackageManager().queryIntentActivities(settingsIntent, 0); 503 // Show a button to open GMS settings activity only if it exists. 504 if (intentResolveInfo.size() > 0) { 505 logCrashCollectionState(CollectionState.DISABLED_BY_USER_CONSENT); 506 errorView.setActionButton( 507 "Open Settings", v -> mContext.startActivity(settingsIntent)); 508 } else { 509 logCrashCollectionState( 510 CollectionState.DISABLED_BY_USER_CONSENT_CANNOT_FIND_SETTINGS); 511 Log.e(TAG, "Cannot find GMS settings activity"); 512 } 513 } else { 514 logCrashCollectionState(CollectionState.DISABLED_CANNOT_USE_GMS); 515 errorView.setText(NO_GMS_ERROR_MESSAGE); 516 } 517 } 518 buildCrashBugDialog(CrashInfo crashInfo)519 private AlertDialog buildCrashBugDialog(CrashInfo crashInfo) { 520 AlertDialog.Builder dialogBuilder = new AlertDialog.Builder(mContext); 521 dialogBuilder.setMessage(CRASH_BUG_DIALOG_MESSAGE); 522 dialogBuilder.setPositiveButton("Provide more info", (dialog, id) -> { 523 logCrashInteraction(CrashInteraction.FILE_BUG_REPORT_DIALOG_PROCEED); 524 mContext.startActivity(new CrashBugUrlFactory(crashInfo).getReportIntent()); 525 }); 526 dialogBuilder.setNegativeButton("Dismiss", (dialog, id) -> { 527 logCrashInteraction(CrashInteraction.FILE_BUG_REPORT_DIALOG_DISMISS); 528 dialog.dismiss(); 529 }); 530 return dialogBuilder.create(); 531 } 532 533 /** 534 * Notifies the caller when all CrashInfo is reloaded in the ListView. 535 */ 536 @MainThread 537 @VisibleForTesting setCrashInfoLoadedListenerForTesting(@ullable Runnable listener)538 public static void setCrashInfoLoadedListenerForTesting(@Nullable Runnable listener) { 539 sCrashInfoLoadedListener = listener; 540 } 541 542 @Override onCreateOptionsMenu(Menu menu, MenuInflater inflater)543 public void onCreateOptionsMenu(Menu menu, MenuInflater inflater) { 544 inflater.inflate(R.menu.crashes_options_menu, menu); 545 } 546 547 @Override onOptionsItemSelected(MenuItem item)548 public boolean onOptionsItemSelected(MenuItem item) { 549 if (item.getItemId() == R.id.options_menu_refresh) { 550 MainActivity.logMenuSelection(MainActivity.MenuChoice.CRASHES_REFRESH); 551 mCrashListViewAdapter.updateCrashes(); 552 return true; 553 } 554 return super.onOptionsItemSelected(item); 555 } 556 } 557