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