BugreportProgressService.java revision 1e33af88edf3109bc8e6484c5f66e259a247cd01
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
17package com.android.shell;
18
19import static android.os.Process.THREAD_PRIORITY_BACKGROUND;
20import static com.android.shell.BugreportPrefs.STATE_SHOW;
21import static com.android.shell.BugreportPrefs.getWarningState;
22
23import java.io.BufferedOutputStream;
24import java.io.ByteArrayInputStream;
25import java.io.File;
26import java.io.FileDescriptor;
27import java.io.FileInputStream;
28import java.io.FileOutputStream;
29import java.io.IOException;
30import java.io.InputStream;
31import java.io.PrintWriter;
32import java.nio.charset.StandardCharsets;
33import java.text.NumberFormat;
34import java.util.ArrayList;
35import java.util.Enumeration;
36import java.util.List;
37import java.util.zip.ZipEntry;
38import java.util.zip.ZipFile;
39import java.util.zip.ZipOutputStream;
40
41import libcore.io.Streams;
42
43import com.android.internal.annotations.VisibleForTesting;
44import com.android.internal.logging.MetricsLogger;
45import com.android.internal.logging.MetricsProto.MetricsEvent;
46import com.google.android.collect.Lists;
47
48import android.accounts.Account;
49import android.accounts.AccountManager;
50import android.annotation.SuppressLint;
51import android.app.AlertDialog;
52import android.app.Notification;
53import android.app.Notification.Action;
54import android.app.NotificationManager;
55import android.app.PendingIntent;
56import android.app.Service;
57import android.content.ClipData;
58import android.content.Context;
59import android.content.ContextWrapper;
60import android.content.DialogInterface;
61import android.content.Intent;
62import android.content.res.Configuration;
63import android.net.Uri;
64import android.os.AsyncTask;
65import android.os.Handler;
66import android.os.HandlerThread;
67import android.os.IBinder;
68import android.os.Looper;
69import android.os.Message;
70import android.os.Parcel;
71import android.os.Parcelable;
72import android.os.SystemProperties;
73import android.os.Vibrator;
74import android.support.v4.content.FileProvider;
75import android.text.TextUtils;
76import android.text.format.DateUtils;
77import android.util.Log;
78import android.util.Patterns;
79import android.util.SparseArray;
80import android.view.View;
81import android.view.WindowManager;
82import android.view.View.OnFocusChangeListener;
83import android.view.inputmethod.EditorInfo;
84import android.widget.Button;
85import android.widget.EditText;
86import android.widget.Toast;
87
88/**
89 * Service used to keep progress of bugreport processes ({@code dumpstate}).
90 * <p>
91 * The workflow is:
92 * <ol>
93 * <li>When {@code dumpstate} starts, it sends a {@code BUGREPORT_STARTED} with a sequential id,
94 * its pid, and the estimated total effort.
95 * <li>{@link BugreportReceiver} receives the intent and delegates it to this service.
96 * <li>Upon start, this service:
97 * <ol>
98 * <li>Issues a system notification so user can watch the progresss (which is 0% initially).
99 * <li>Polls the {@link SystemProperties} for updates on the {@code dumpstate} progress.
100 * <li>If the progress changed, it updates the system notification.
101 * </ol>
102 * <li>As {@code dumpstate} progresses, it updates the system property.
103 * <li>When {@code dumpstate} finishes, it sends a {@code BUGREPORT_FINISHED} intent.
104 * <li>{@link BugreportReceiver} receives the intent and delegates it to this service, which in
105 * turn:
106 * <ol>
107 * <li>Updates the system notification so user can share the bugreport.
108 * <li>Stops monitoring that {@code dumpstate} process.
109 * <li>Stops itself if it doesn't have any process left to monitor.
110 * </ol>
111 * </ol>
112 */
113public class BugreportProgressService extends Service {
114    private static final String TAG = "BugreportProgressService";
115    private static final boolean DEBUG = false;
116
117    private static final String AUTHORITY = "com.android.shell";
118
119    // External intents sent by dumpstate.
120    static final String INTENT_BUGREPORT_STARTED = "android.intent.action.BUGREPORT_STARTED";
121    static final String INTENT_BUGREPORT_FINISHED = "android.intent.action.BUGREPORT_FINISHED";
122    static final String INTENT_REMOTE_BUGREPORT_FINISHED =
123            "android.intent.action.REMOTE_BUGREPORT_FINISHED";
124    static final String INTENT_REMOTE_BUGREPORT_DISPATCH =
125            "android.intent.action.REMOTE_BUGREPORT_DISPATCH";
126
127    // Internal intents used on notification actions.
128    static final String INTENT_BUGREPORT_CANCEL = "android.intent.action.BUGREPORT_CANCEL";
129    static final String INTENT_BUGREPORT_SHARE = "android.intent.action.BUGREPORT_SHARE";
130    static final String INTENT_BUGREPORT_INFO_LAUNCH =
131            "android.intent.action.BUGREPORT_INFO_LAUNCH";
132    static final String INTENT_BUGREPORT_SCREENSHOT =
133            "android.intent.action.BUGREPORT_SCREENSHOT";
134
135    static final String EXTRA_BUGREPORT = "android.intent.extra.BUGREPORT";
136    static final String EXTRA_SCREENSHOT = "android.intent.extra.SCREENSHOT";
137    static final String EXTRA_ID = "android.intent.extra.ID";
138    static final String EXTRA_PID = "android.intent.extra.PID";
139    static final String EXTRA_MAX = "android.intent.extra.MAX";
140    static final String EXTRA_NAME = "android.intent.extra.NAME";
141    static final String EXTRA_TITLE = "android.intent.extra.TITLE";
142    static final String EXTRA_DESCRIPTION = "android.intent.extra.DESCRIPTION";
143    static final String EXTRA_ORIGINAL_INTENT = "android.intent.extra.ORIGINAL_INTENT";
144    static final String EXTRA_INFO = "android.intent.extra.INFO";
145
146    private static final int MSG_SERVICE_COMMAND = 1;
147    private static final int MSG_POLL = 2;
148    private static final int MSG_DELAYED_SCREENSHOT = 3;
149    private static final int MSG_SCREENSHOT_REQUEST = 4;
150    private static final int MSG_SCREENSHOT_RESPONSE = 5;
151
152    // Passed to Message.obtain() when msg.arg2 is not used.
153    private static final int UNUSED_ARG2 = -2;
154
155    /**
156     * Delay before a screenshot is taken.
157     * <p>
158     * Should be at least 3 seconds, otherwise its toast might show up in the screenshot.
159     */
160    static final int SCREENSHOT_DELAY_SECONDS = 3;
161
162    /** Polling frequency, in milliseconds. */
163    static final long POLLING_FREQUENCY = 2 * DateUtils.SECOND_IN_MILLIS;
164
165    /** How long (in ms) a dumpstate process will be monitored if it didn't show progress. */
166    private static final long INACTIVITY_TIMEOUT = 10 * DateUtils.MINUTE_IN_MILLIS;
167
168    /** System properties used for monitoring progress. */
169    private static final String DUMPSTATE_PREFIX = "dumpstate.";
170    private static final String PROGRESS_SUFFIX = ".progress";
171    private static final String MAX_SUFFIX = ".max";
172    private static final String NAME_SUFFIX = ".name";
173
174    /** System property (and value) used to stop dumpstate. */
175    // TODO: should call ActiveManager API instead
176    private static final String CTL_STOP = "ctl.stop";
177    private static final String BUGREPORT_SERVICE = "bugreportplus";
178
179    /**
180     * Directory on Shell's data storage where screenshots will be stored.
181     * <p>
182     * Must be a path supported by its FileProvider.
183     */
184    private static final String SCREENSHOT_DIR = "bugreports";
185
186    /** Managed dumpstate processes (keyed by id) */
187    private final SparseArray<BugreportInfo> mProcesses = new SparseArray<>();
188
189    private Context mContext;
190    private ServiceHandler mMainHandler;
191    private ScreenshotHandler mScreenshotHandler;
192
193    private final BugreportInfoDialog mInfoDialog = new BugreportInfoDialog();
194
195    private File mScreenshotsDir;
196
197    /**
198     * Flag indicating whether a screenshot is being taken.
199     * <p>
200     * This is the only state that is shared between the 2 handlers and hence must have synchronized
201     * access.
202     */
203    private boolean mTakingScreenshot;
204
205    @Override
206    public void onCreate() {
207        mContext = getApplicationContext();
208        mMainHandler = new ServiceHandler("BugreportProgressServiceMainThread");
209        mScreenshotHandler = new ScreenshotHandler("BugreportProgressServiceScreenshotThread");
210
211        mScreenshotsDir = new File(new ContextWrapper(mContext).getFilesDir(), SCREENSHOT_DIR);
212        if (!mScreenshotsDir.exists()) {
213            Log.i(TAG, "Creating directory " + mScreenshotsDir + " to store temporary screenshots");
214            if (!mScreenshotsDir.mkdir()) {
215                Log.w(TAG, "Could not create directory " + mScreenshotsDir);
216            }
217        }
218    }
219
220    @Override
221    public int onStartCommand(Intent intent, int flags, int startId) {
222        if (intent != null) {
223            // Handle it in a separate thread.
224            final Message msg = mMainHandler.obtainMessage();
225            msg.what = MSG_SERVICE_COMMAND;
226            msg.obj = intent;
227            mMainHandler.sendMessage(msg);
228        }
229
230        // If service is killed it cannot be recreated because it would not know which
231        // dumpstate IDs it would have to watch.
232        return START_NOT_STICKY;
233    }
234
235    @Override
236    public IBinder onBind(Intent intent) {
237        return null;
238    }
239
240    @Override
241    public void onDestroy() {
242        mMainHandler.getLooper().quit();
243        mScreenshotHandler.getLooper().quit();
244        super.onDestroy();
245    }
246
247    @Override
248    protected void dump(FileDescriptor fd, PrintWriter writer, String[] args) {
249        final int size = mProcesses.size();
250        if (size == 0) {
251            writer.printf("No monitored processes");
252            return;
253        }
254        writer.printf("Monitored dumpstate processes\n");
255        writer.printf("-----------------------------\n");
256        for (int i = 0; i < size; i++) {
257            writer.printf("%s\n", mProcesses.valueAt(i));
258        }
259    }
260
261    /**
262     * Main thread used to handle all requests but taking screenshots.
263     */
264    private final class ServiceHandler extends Handler {
265        public ServiceHandler(String name) {
266            super(newLooper(name));
267        }
268
269        @Override
270        public void handleMessage(Message msg) {
271            if (msg.what == MSG_POLL) {
272                poll();
273                return;
274            }
275
276            if (msg.what == MSG_DELAYED_SCREENSHOT) {
277                takeScreenshot(msg.arg1, msg.arg2);
278                return;
279            }
280
281            if (msg.what == MSG_SCREENSHOT_RESPONSE) {
282                handleScreenshotResponse(msg);
283                return;
284            }
285
286            if (msg.what != MSG_SERVICE_COMMAND) {
287                // Sanity check.
288                Log.e(TAG, "Invalid message type: " + msg.what);
289                return;
290            }
291
292            // At this point it's handling onStartCommand(), with the intent passed as an Extra.
293            if (!(msg.obj instanceof Intent)) {
294                // Sanity check.
295                Log.wtf(TAG, "handleMessage(): invalid msg.obj type: " + msg.obj);
296                return;
297            }
298            final Parcelable parcel = ((Intent) msg.obj).getParcelableExtra(EXTRA_ORIGINAL_INTENT);
299            final Intent intent;
300            if (parcel instanceof Intent) {
301                // The real intent was passed to BugreportReceiver, which delegated to the service.
302                intent = (Intent) parcel;
303            } else {
304                intent = (Intent) msg.obj;
305            }
306            final String action = intent.getAction();
307            final int pid = intent.getIntExtra(EXTRA_PID, 0);
308            final int id = intent.getIntExtra(EXTRA_ID, 0);
309            final int max = intent.getIntExtra(EXTRA_MAX, -1);
310            final String name = intent.getStringExtra(EXTRA_NAME);
311
312            if (DEBUG)
313                Log.v(TAG, "action: " + action + ", name: " + name + ", id: " + id + ", pid: "
314                        + pid + ", max: " + max);
315            switch (action) {
316                case INTENT_BUGREPORT_STARTED:
317                    if (!startProgress(name, id, pid, max)) {
318                        stopSelfWhenDone();
319                        return;
320                    }
321                    poll();
322                    break;
323                case INTENT_BUGREPORT_FINISHED:
324                    if (id == 0) {
325                        // Shouldn't happen, unless BUGREPORT_FINISHED is received from a legacy,
326                        // out-of-sync dumpstate process.
327                        Log.w(TAG, "Missing " + EXTRA_ID + " on intent " + intent);
328                    }
329                    onBugreportFinished(id, intent);
330                    break;
331                case INTENT_BUGREPORT_INFO_LAUNCH:
332                    launchBugreportInfoDialog(id);
333                    break;
334                case INTENT_BUGREPORT_SCREENSHOT:
335                    takeScreenshot(id, true);
336                    break;
337                case INTENT_BUGREPORT_SHARE:
338                    shareBugreport(id, (BugreportInfo) intent.getParcelableExtra(EXTRA_INFO));
339                    break;
340                case INTENT_BUGREPORT_CANCEL:
341                    cancel(id);
342                    break;
343                default:
344                    Log.w(TAG, "Unsupported intent: " + action);
345            }
346            return;
347
348        }
349
350        private void poll() {
351            if (pollProgress()) {
352                // Keep polling...
353                sendEmptyMessageDelayed(MSG_POLL, POLLING_FREQUENCY);
354            } else {
355                Log.i(TAG, "Stopped polling");
356            }
357        }
358    }
359
360    /**
361     * Separate thread used only to take screenshots so it doesn't block the main thread.
362     */
363    private final class ScreenshotHandler extends Handler {
364        public ScreenshotHandler(String name) {
365            super(newLooper(name));
366        }
367
368        @Override
369        public void handleMessage(Message msg) {
370            if (msg.what != MSG_SCREENSHOT_REQUEST) {
371                Log.e(TAG, "Invalid message type: " + msg.what);
372                return;
373            }
374            handleScreenshotRequest(msg);
375        }
376    }
377
378    private BugreportInfo getInfo(int id) {
379        final BugreportInfo info = mProcesses.get(id);
380        if (info == null) {
381            Log.w(TAG, "Not monitoring process with ID " + id);
382        }
383        return info;
384    }
385
386    /**
387     * Creates the {@link BugreportInfo} for a process and issue a system notification to
388     * indicate its progress.
389     *
390     * @return whether it succeeded or not.
391     */
392    private boolean startProgress(String name, int id, int pid, int max) {
393        if (name == null) {
394            Log.w(TAG, "Missing " + EXTRA_NAME + " on start intent");
395        }
396        if (id == -1) {
397            Log.e(TAG, "Missing " + EXTRA_ID + " on start intent");
398            return false;
399        }
400        if (pid == -1) {
401            Log.e(TAG, "Missing " + EXTRA_PID + " on start intent");
402            return false;
403        }
404        if (max <= 0) {
405            Log.e(TAG, "Invalid value for extra " + EXTRA_MAX + ": " + max);
406            return false;
407        }
408
409        final BugreportInfo info = new BugreportInfo(mContext, id, pid, name, max);
410        if (mProcesses.indexOfKey(id) >= 0) {
411            Log.w(TAG, "ID " + id + " already watched");
412        } else {
413            mProcesses.put(info.id, info);
414        }
415        // Take initial screenshot.
416        takeScreenshot(id, false);
417        updateProgress(info);
418        return true;
419    }
420
421    /**
422     * Updates the system notification for a given bugreport.
423     */
424    private void updateProgress(BugreportInfo info) {
425        if (info.max <= 0 || info.progress < 0) {
426            Log.e(TAG, "Invalid progress values for " + info);
427            return;
428        }
429
430        final NumberFormat nf = NumberFormat.getPercentInstance();
431        nf.setMinimumFractionDigits(2);
432        nf.setMaximumFractionDigits(2);
433        final String percentText = nf.format((double) info.progress / info.max);
434        final Action cancelAction = new Action.Builder(null, mContext.getString(
435                com.android.internal.R.string.cancel), newCancelIntent(mContext, info)).build();
436        final Intent infoIntent = new Intent(mContext, BugreportProgressService.class);
437        infoIntent.setAction(INTENT_BUGREPORT_INFO_LAUNCH);
438        infoIntent.putExtra(EXTRA_ID, info.id);
439        final PendingIntent infoPendingIntent =
440                PendingIntent.getService(mContext, info.id, infoIntent,
441                PendingIntent.FLAG_UPDATE_CURRENT);
442        final Action infoAction = new Action.Builder(null,
443                mContext.getString(R.string.bugreport_info_action),
444                infoPendingIntent).build();
445        final Intent screenshotIntent = new Intent(mContext, BugreportProgressService.class);
446        screenshotIntent.setAction(INTENT_BUGREPORT_SCREENSHOT);
447        screenshotIntent.putExtra(EXTRA_ID, info.id);
448        PendingIntent screenshotPendingIntent = mTakingScreenshot ? null : PendingIntent
449                .getService(mContext, info.id, screenshotIntent,
450                        PendingIntent.FLAG_UPDATE_CURRENT);
451        final Action screenshotAction = new Action.Builder(null,
452                mContext.getString(R.string.bugreport_screenshot_action),
453                screenshotPendingIntent).build();
454
455        final String title = mContext.getString(R.string.bugreport_in_progress_title, info.id);
456
457        final String name =
458                info.name != null ? info.name : mContext.getString(R.string.bugreport_unnamed);
459
460        final Notification notification = new Notification.Builder(mContext)
461                .setSmallIcon(com.android.internal.R.drawable.stat_sys_adb)
462                .setContentTitle(title)
463                .setTicker(title)
464                .setContentText(name)
465                .setContentInfo(percentText)
466                .setProgress(info.max, info.progress, false)
467                .setOngoing(true)
468                .setLocalOnly(true)
469                .setColor(mContext.getColor(
470                        com.android.internal.R.color.system_notification_accent_color))
471                .setContentIntent(infoPendingIntent)
472                .addAction(infoAction)
473                .addAction(screenshotAction)
474                .addAction(cancelAction)
475                .build();
476
477        if (info.finished) {
478            Log.w(TAG, "Not sending progress notification because bugreport has finished already ("
479                    + info + ")");
480            return;
481        }
482        if (DEBUG) {
483            Log.d(TAG, "Sending 'Progress' notification for id " + info.id + "(pid " + info.pid
484                    + "): " + percentText);
485        }
486        NotificationManager.from(mContext).notify(TAG, info.id, notification);
487    }
488
489    /**
490     * Creates a {@link PendingIntent} for a notification action used to cancel a bugreport.
491     */
492    private static PendingIntent newCancelIntent(Context context, BugreportInfo info) {
493        final Intent intent = new Intent(INTENT_BUGREPORT_CANCEL);
494        intent.setClass(context, BugreportProgressService.class);
495        intent.putExtra(EXTRA_ID, info.id);
496        return PendingIntent.getService(context, info.id, intent,
497                PendingIntent.FLAG_UPDATE_CURRENT);
498    }
499
500    /**
501     * Finalizes the progress on a given bugreport and cancel its notification.
502     */
503    private void stopProgress(int id) {
504        if (mProcesses.indexOfKey(id) < 0) {
505            Log.w(TAG, "ID not watched: " + id);
506        } else {
507            Log.d(TAG, "Removing ID " + id);
508            mProcesses.remove(id);
509        }
510        stopSelfWhenDone();
511        Log.v(TAG, "stopProgress(" + id + "): cancel notification");
512        NotificationManager.from(mContext).cancel(TAG, id);
513    }
514
515    /**
516     * Cancels a bugreport upon user's request.
517     */
518    private void cancel(int id) {
519        MetricsLogger.action(this, MetricsEvent.ACTION_BUGREPORT_NOTIFICATION_ACTION_CANCEL);
520        Log.v(TAG, "cancel: ID=" + id);
521        final BugreportInfo info = getInfo(id);
522        if (info != null && !info.finished) {
523            Log.i(TAG, "Cancelling bugreport service (ID=" + id + ") on user's request");
524            setSystemProperty(CTL_STOP, BUGREPORT_SERVICE);
525            deleteScreenshots(info);
526        }
527        stopProgress(id);
528    }
529
530    /**
531     * Poll {@link SystemProperties} to get the progress on each monitored process.
532     *
533     * @return whether it should keep polling.
534     */
535    private boolean pollProgress() {
536        final int total = mProcesses.size();
537        if (total == 0) {
538            Log.d(TAG, "No process to poll progress.");
539        }
540        int activeProcesses = 0;
541        for (int i = 0; i < total; i++) {
542            final BugreportInfo info = mProcesses.valueAt(i);
543            if (info == null) {
544                Log.wtf(TAG, "pollProgress(): null info at index " + i + "(ID = "
545                        + mProcesses.keyAt(i) + ")");
546                continue;
547            }
548
549            final int pid = info.pid;
550            final int id = info.id;
551            if (info.finished) {
552                if (DEBUG) Log.v(TAG, "Skipping finished process " + pid + " (id: " + id + ")");
553                continue;
554            }
555            activeProcesses++;
556            final String progressKey = DUMPSTATE_PREFIX + pid + PROGRESS_SUFFIX;
557            final int progress = SystemProperties.getInt(progressKey, 0);
558            if (progress == 0) {
559                Log.v(TAG, "System property " + progressKey + " is not set yet");
560            }
561            final int max = SystemProperties.getInt(DUMPSTATE_PREFIX + pid + MAX_SUFFIX, 0);
562            final boolean maxChanged = max > 0 && max != info.max;
563            final boolean progressChanged = progress > 0 && progress != info.progress;
564
565            if (progressChanged || maxChanged) {
566                if (progressChanged) {
567                    if (DEBUG) Log.v(TAG, "Updating progress for PID " + pid + "(id: " + id
568                            + ") from " + info.progress + " to " + progress);
569                    info.progress = progress;
570                }
571                if (maxChanged) {
572                    Log.i(TAG, "Updating max progress for PID " + pid + "(id: " + id
573                            + ") from " + info.max + " to " + max);
574                    info.max = max;
575                }
576                info.lastUpdate = System.currentTimeMillis();
577                updateProgress(info);
578            } else {
579                long inactiveTime = System.currentTimeMillis() - info.lastUpdate;
580                if (inactiveTime >= INACTIVITY_TIMEOUT) {
581                    Log.w(TAG, "No progress update for PID " + pid + " since "
582                            + info.getFormattedLastUpdate());
583                    stopProgress(info.id);
584                }
585            }
586        }
587        if (DEBUG) Log.v(TAG, "pollProgress() total=" + total + ", actives=" + activeProcesses);
588        return activeProcesses > 0;
589    }
590
591    /**
592     * Fetches a {@link BugreportInfo} for a given process and launches a dialog where the user can
593     * change its values.
594     */
595    private void launchBugreportInfoDialog(int id) {
596        MetricsLogger.action(this, MetricsEvent.ACTION_BUGREPORT_NOTIFICATION_ACTION_DETAILS);
597        // Copy values so it doesn't lock mProcesses while UI is being updated
598        final String name, title, description;
599        final BugreportInfo info = getInfo(id);
600        if (info == null) {
601            // Most likely am killed Shell before user tapped the notification. Since system might
602            // be too busy anwyays, it's better to ignore the notification and switch back to the
603            // non-interactive mode (where the bugerport will be shared upon completion).
604            Log.d(TAG, "launchBugreportInfoDialog(" + id + "): cancel notification");
605            // TODO: add test case to make sure notification is canceled.
606            NotificationManager.from(mContext).cancel(TAG, id);
607            return;
608        }
609
610        collapseNotificationBar();
611        mInfoDialog.initialize(mContext, info);
612    }
613
614    /**
615     * Starting point for taking a screenshot.
616     * <p>
617     * If {@code delayed} is set, it first display a toast message and waits
618     * {@link #SCREENSHOT_DELAY_SECONDS} seconds before taking it, otherwise it takes the screenshot
619     * right away.
620     * <p>
621     * Typical usage is delaying when taken from the notification action, and taking it right away
622     * upon receiving a {@link #INTENT_BUGREPORT_STARTED}.
623     */
624    private void takeScreenshot(int id, boolean delayed) {
625        MetricsLogger.action(this, MetricsEvent.ACTION_BUGREPORT_NOTIFICATION_ACTION_SCREENSHOT);
626        if (getInfo(id) == null) {
627            // Most likely am killed Shell before user tapped the notification. Since system might
628            // be too busy anwyays, it's better to ignore the notification and switch back to the
629            // non-interactive mode (where the bugerport will be shared upon completion).
630            Log.d(TAG, "takeScreenshot(" + id + ", " + delayed + "): cancel notification");
631            // TODO: add test case to make sure notification is canceled.
632            NotificationManager.from(mContext).cancel(TAG, id);
633            return;
634        }
635        setTakingScreenshot(true);
636        if (delayed) {
637            collapseNotificationBar();
638            final String msg = mContext.getResources()
639                    .getQuantityString(com.android.internal.R.plurals.bugreport_countdown,
640                            SCREENSHOT_DELAY_SECONDS, SCREENSHOT_DELAY_SECONDS);
641            Log.i(TAG, msg);
642            // Show a toast just once, otherwise it might be captured in the screenshot.
643            Toast.makeText(mContext, msg, Toast.LENGTH_SHORT).show();
644
645            takeScreenshot(id, SCREENSHOT_DELAY_SECONDS);
646        } else {
647            takeScreenshot(id, 0);
648        }
649    }
650
651    /**
652     * Takes a screenshot after {@code delay} seconds.
653     */
654    private void takeScreenshot(int id, int delay) {
655        if (delay > 0) {
656            Log.d(TAG, "Taking screenshot for " + id + " in " + delay + " seconds");
657            final Message msg = mMainHandler.obtainMessage();
658            msg.what = MSG_DELAYED_SCREENSHOT;
659            msg.arg1 = id;
660            msg.arg2 = delay - 1;
661            mMainHandler.sendMessageDelayed(msg, DateUtils.SECOND_IN_MILLIS);
662            return;
663        }
664
665        // It's time to take the screenshot: let the proper thread handle it
666        final BugreportInfo info = getInfo(id);
667        if (info == null) {
668            return;
669        }
670        final String screenshotPath =
671                new File(mScreenshotsDir, info.getPathNextScreenshot()).getAbsolutePath();
672
673        Message.obtain(mScreenshotHandler, MSG_SCREENSHOT_REQUEST, id, UNUSED_ARG2, screenshotPath)
674                .sendToTarget();
675    }
676
677    /**
678     * Sets the internal {@code mTakingScreenshot} state and updates all notifications so their
679     * SCREENSHOT button is enabled or disabled accordingly.
680     */
681    private void setTakingScreenshot(boolean flag) {
682        synchronized (BugreportProgressService.this) {
683            mTakingScreenshot = flag;
684            for (int i = 0; i < mProcesses.size(); i++) {
685                final BugreportInfo info = mProcesses.valueAt(i);
686                if (info.finished) {
687                    Log.d(TAG, "Not updating progress because share notification was already sent");
688                    continue;
689                }
690                updateProgress(info);
691            }
692        }
693    }
694
695    private void handleScreenshotRequest(Message requestMsg) {
696        String screenshotFile = (String) requestMsg.obj;
697        boolean taken = takeScreenshot(mContext, screenshotFile);
698        setTakingScreenshot(false);
699
700        Message.obtain(mMainHandler, MSG_SCREENSHOT_RESPONSE, requestMsg.arg1, taken ? 1 : 0,
701                screenshotFile).sendToTarget();
702    }
703
704    private void handleScreenshotResponse(Message resultMsg) {
705        final boolean taken = resultMsg.arg2 != 0;
706        final BugreportInfo info = getInfo(resultMsg.arg1);
707        if (info == null) {
708            return;
709        }
710        final File screenshotFile = new File((String) resultMsg.obj);
711
712        final String msg;
713        if (taken) {
714            info.addScreenshot(screenshotFile);
715            if (info.finished) {
716                Log.d(TAG, "Screenshot finished after bugreport; updating share notification");
717                info.renameScreenshots(mScreenshotsDir);
718                sendBugreportNotification(mContext, info);
719            }
720            msg = mContext.getString(R.string.bugreport_screenshot_taken);
721        } else {
722            // TODO: try again using Framework APIs instead of relying on screencap.
723            msg = mContext.getString(R.string.bugreport_screenshot_failed);
724            Toast.makeText(mContext, msg, Toast.LENGTH_SHORT).show();
725        }
726        Log.d(TAG, msg);
727    }
728
729    /**
730     * Deletes all screenshots taken for a given bugreport.
731     */
732    private void deleteScreenshots(BugreportInfo info) {
733        for (File file : info.screenshotFiles) {
734            Log.i(TAG, "Deleting screenshot file " + file);
735            file.delete();
736        }
737    }
738
739    /**
740     * Finishes the service when it's not monitoring any more processes.
741     */
742    private void stopSelfWhenDone() {
743        if (mProcesses.size() > 0) {
744            if (DEBUG) Log.d(TAG, "Staying alive, waiting for IDs " + mProcesses);
745            return;
746        }
747        Log.v(TAG, "No more processes to handle, shutting down");
748        stopSelf();
749    }
750
751    /**
752     * Handles the BUGREPORT_FINISHED intent sent by {@code dumpstate}.
753     */
754    private void onBugreportFinished(int id, Intent intent) {
755        final File bugreportFile = getFileExtra(intent, EXTRA_BUGREPORT);
756        if (bugreportFile == null) {
757            // Should never happen, dumpstate always set the file.
758            Log.wtf(TAG, "Missing " + EXTRA_BUGREPORT + " on intent " + intent);
759            return;
760        }
761        mInfoDialog.onBugreportFinished(id);
762        BugreportInfo info = getInfo(id);
763        if (info == null) {
764            // Happens when BUGREPORT_FINISHED was received without a BUGREPORT_STARTED first.
765            Log.v(TAG, "Creating info for untracked ID " + id);
766            info = new BugreportInfo(mContext, id);
767            mProcesses.put(id, info);
768        }
769        info.renameScreenshots(mScreenshotsDir);
770        info.bugreportFile = bugreportFile;
771
772        final int max = intent.getIntExtra(EXTRA_MAX, -1);
773        if (max != -1) {
774            MetricsLogger.histogram(this, "dumpstate_duration", max);
775            info.max = max;
776        }
777
778        final File screenshot = getFileExtra(intent, EXTRA_SCREENSHOT);
779        if (screenshot != null) {
780            info.addScreenshot(screenshot);
781        }
782        info.finished = true;
783
784        final Configuration conf = mContext.getResources().getConfiguration();
785        if ((conf.uiMode & Configuration.UI_MODE_TYPE_MASK) != Configuration.UI_MODE_TYPE_WATCH) {
786            triggerLocalNotification(mContext, info);
787        }
788    }
789
790    /**
791     * Responsible for triggering a notification that allows the user to start a "share" intent with
792     * the bugreport. On watches we have other methods to allow the user to start this intent
793     * (usually by triggering it on another connected device); we don't need to display the
794     * notification in this case.
795     */
796    private void triggerLocalNotification(final Context context, final BugreportInfo info) {
797        if (!info.bugreportFile.exists() || !info.bugreportFile.canRead()) {
798            Log.e(TAG, "Could not read bugreport file " + info.bugreportFile);
799            Toast.makeText(context, R.string.bugreport_unreadable_text, Toast.LENGTH_LONG).show();
800            stopProgress(info.id);
801            return;
802        }
803
804        boolean isPlainText = info.bugreportFile.getName().toLowerCase().endsWith(".txt");
805        if (!isPlainText) {
806            // Already zipped, send it right away.
807            sendBugreportNotification(context, info);
808        } else {
809            // Asynchronously zip the file first, then send it.
810            sendZippedBugreportNotification(context, info);
811        }
812    }
813
814    private static Intent buildWarningIntent(Context context, Intent sendIntent) {
815        final Intent intent = new Intent(context, BugreportWarningActivity.class);
816        intent.putExtra(Intent.EXTRA_INTENT, sendIntent);
817        return intent;
818    }
819
820    /**
821     * Build {@link Intent} that can be used to share the given bugreport.
822     */
823    private static Intent buildSendIntent(Context context, BugreportInfo info) {
824        // Files are kept on private storage, so turn into Uris that we can
825        // grant temporary permissions for.
826        final Uri bugreportUri = getUri(context, info.bugreportFile);
827
828        final Intent intent = new Intent(Intent.ACTION_SEND_MULTIPLE);
829        final String mimeType = "application/vnd.android.bugreport";
830        intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION);
831        intent.addCategory(Intent.CATEGORY_DEFAULT);
832        intent.setType(mimeType);
833
834        final String subject = !TextUtils.isEmpty(info.title) ?
835                info.title : bugreportUri.getLastPathSegment();
836        intent.putExtra(Intent.EXTRA_SUBJECT, subject);
837
838        // EXTRA_TEXT should be an ArrayList, but some clients are expecting a single String.
839        // So, to avoid an exception on Intent.migrateExtraStreamToClipData(), we need to manually
840        // create the ClipData object with the attachments URIs.
841        final StringBuilder messageBody = new StringBuilder("Build info: ")
842            .append(SystemProperties.get("ro.build.description"))
843            .append("\nSerial number: ")
844            .append(SystemProperties.get("ro.serialno"));
845        if (!TextUtils.isEmpty(info.description)) {
846            messageBody.append("\nDescription: ").append(info.description);
847        }
848        intent.putExtra(Intent.EXTRA_TEXT, messageBody.toString());
849        final ClipData clipData = new ClipData(null, new String[] { mimeType },
850                new ClipData.Item(null, null, null, bugreportUri));
851        final ArrayList<Uri> attachments = Lists.newArrayList(bugreportUri);
852        for (File screenshot : info.screenshotFiles) {
853            final Uri screenshotUri = getUri(context, screenshot);
854            clipData.addItem(new ClipData.Item(null, null, null, screenshotUri));
855            attachments.add(screenshotUri);
856        }
857        intent.setClipData(clipData);
858        intent.putParcelableArrayListExtra(Intent.EXTRA_STREAM, attachments);
859
860        final Account sendToAccount = findSendToAccount(context);
861        if (sendToAccount != null) {
862            intent.putExtra(Intent.EXTRA_EMAIL, new String[] { sendToAccount.name });
863        }
864
865        return intent;
866    }
867
868    /**
869     * Shares the bugreport upon user's request by issuing a {@link Intent#ACTION_SEND_MULTIPLE}
870     * intent, but issuing a warning dialog the first time.
871     */
872    private void shareBugreport(int id, BugreportInfo sharedInfo) {
873        MetricsLogger.action(this, MetricsEvent.ACTION_BUGREPORT_NOTIFICATION_ACTION_SHARE);
874        BugreportInfo info = getInfo(id);
875        if (info == null) {
876            // Service was terminated but notification persisted
877            info = sharedInfo;
878            Log.d(TAG, "shareBugreport(): no info for ID " + id + " on managed processes ("
879                    + mProcesses + "), using info from intent instead (" + info + ")");
880        }
881
882        addDetailsToZipFile(mContext, info);
883
884        final Intent sendIntent = buildSendIntent(mContext, info);
885        final Intent notifIntent;
886
887        // Send through warning dialog by default
888        if (getWarningState(mContext, STATE_SHOW) == STATE_SHOW) {
889            notifIntent = buildWarningIntent(mContext, sendIntent);
890        } else {
891            notifIntent = sendIntent;
892        }
893        notifIntent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
894
895        // Send the share intent...
896        mContext.startActivity(notifIntent);
897
898        // ... and stop watching this process.
899        stopProgress(id);
900    }
901
902    /**
903     * Sends a notification indicating the bugreport has finished so use can share it.
904     */
905    private static void sendBugreportNotification(Context context, BugreportInfo info) {
906
907        // Since adding the details can take a while, do it before notifying user.
908        addDetailsToZipFile(context, info);
909
910        final Intent shareIntent = new Intent(INTENT_BUGREPORT_SHARE);
911        shareIntent.setClass(context, BugreportProgressService.class);
912        shareIntent.setAction(INTENT_BUGREPORT_SHARE);
913        shareIntent.putExtra(EXTRA_ID, info.id);
914        shareIntent.putExtra(EXTRA_INFO, info);
915
916        final String title = context.getString(R.string.bugreport_finished_title, info.id);
917        final Notification.Builder builder = new Notification.Builder(context)
918                .setSmallIcon(com.android.internal.R.drawable.stat_sys_adb)
919                .setContentTitle(title)
920                .setTicker(title)
921                .setContentText(context.getString(R.string.bugreport_finished_text))
922                .setContentIntent(PendingIntent.getService(context, info.id, shareIntent,
923                        PendingIntent.FLAG_UPDATE_CURRENT))
924                .setDeleteIntent(newCancelIntent(context, info))
925                .setLocalOnly(true)
926                .setColor(context.getColor(
927                        com.android.internal.R.color.system_notification_accent_color));
928
929        if (!TextUtils.isEmpty(info.name)) {
930            builder.setContentInfo(info.name);
931        }
932
933        Log.v(TAG, "Sending 'Share' notification for ID " + info.id + ": " + title);
934        NotificationManager.from(context).notify(TAG, info.id, builder.build());
935    }
936
937    /**
938     * Sends a notification indicating the bugreport is being updated so the user can wait until it
939     * finishes - at this point there is nothing to be done other than waiting, hence it has no
940     * pending action.
941     */
942    private static void sendBugreportBeingUpdatedNotification(Context context, int id) {
943        final String title = context.getString(R.string.bugreport_updating_title);
944        final Notification.Builder builder = new Notification.Builder(context)
945                .setSmallIcon(com.android.internal.R.drawable.stat_sys_adb)
946                .setContentTitle(title)
947                .setTicker(title)
948                .setContentText(context.getString(R.string.bugreport_updating_wait))
949                .setLocalOnly(true)
950                .setColor(context.getColor(
951                        com.android.internal.R.color.system_notification_accent_color));
952        Log.v(TAG, "Sending 'Updating zip' notification for ID " + id + ": " + title);
953        NotificationManager.from(context).notify(TAG, id, builder.build());
954    }
955
956    /**
957     * Sends a zipped bugreport notification.
958     */
959    private static void sendZippedBugreportNotification(final Context context,
960            final BugreportInfo info) {
961        new AsyncTask<Void, Void, Void>() {
962            @Override
963            protected Void doInBackground(Void... params) {
964                zipBugreport(info);
965                sendBugreportNotification(context, info);
966                return null;
967            }
968        }.execute();
969    }
970
971    /**
972     * Zips a bugreport file, returning the path to the new file (or to the
973     * original in case of failure).
974     */
975    private static void zipBugreport(BugreportInfo info) {
976        final String bugreportPath = info.bugreportFile.getAbsolutePath();
977        final String zippedPath = bugreportPath.replace(".txt", ".zip");
978        Log.v(TAG, "zipping " + bugreportPath + " as " + zippedPath);
979        final File bugreportZippedFile = new File(zippedPath);
980        try (InputStream is = new FileInputStream(info.bugreportFile);
981                ZipOutputStream zos = new ZipOutputStream(
982                        new BufferedOutputStream(new FileOutputStream(bugreportZippedFile)))) {
983            addEntry(zos, info.bugreportFile.getName(), is);
984            // Delete old file
985            final boolean deleted = info.bugreportFile.delete();
986            if (deleted) {
987                Log.v(TAG, "deleted original bugreport (" + bugreportPath + ")");
988            } else {
989                Log.e(TAG, "could not delete original bugreport (" + bugreportPath + ")");
990            }
991            info.bugreportFile = bugreportZippedFile;
992        } catch (IOException e) {
993            Log.e(TAG, "exception zipping file " + zippedPath, e);
994        }
995    }
996
997    /**
998     * Adds the user-provided info into the bugreport zip file.
999     * <p>
1000     * If user provided a title, it will be saved into a {@code title.txt} entry; similarly, the
1001     * description will be saved on {@code description.txt}.
1002     */
1003    private static void addDetailsToZipFile(Context context, BugreportInfo info) {
1004        if (info.bugreportFile == null) {
1005            // One possible reason is a bug in the Parcelization code.
1006            Log.wtf(TAG, "addDetailsToZipFile(): no bugreportFile on " + info);
1007            return;
1008        }
1009        if (TextUtils.isEmpty(info.title) && TextUtils.isEmpty(info.description)) {
1010            Log.d(TAG, "Not touching zip file since neither title nor description are set");
1011            return;
1012        }
1013        if (info.addedDetailsToZip || info.addingDetailsToZip) {
1014            Log.d(TAG, "Already added details to zip file for " + info);
1015            return;
1016        }
1017        info.addingDetailsToZip = true;
1018
1019        // It's not possible to add a new entry into an existing file, so we need to create a new
1020        // zip, copy all entries, then rename it.
1021        sendBugreportBeingUpdatedNotification(context, info.id); // ...and that takes time
1022        final File dir = info.bugreportFile.getParentFile();
1023        final File tmpZip = new File(dir, "tmp-" + info.bugreportFile.getName());
1024        Log.d(TAG, "Writing temporary zip file (" + tmpZip + ") with title and/or description");
1025        try (ZipFile oldZip = new ZipFile(info.bugreportFile);
1026                ZipOutputStream zos = new ZipOutputStream(new FileOutputStream(tmpZip))) {
1027
1028            // First copy contents from original zip.
1029            Enumeration<? extends ZipEntry> entries = oldZip.entries();
1030            while (entries.hasMoreElements()) {
1031                final ZipEntry entry = entries.nextElement();
1032                final String entryName = entry.getName();
1033                if (!entry.isDirectory()) {
1034                    addEntry(zos, entryName, entry.getTime(), oldZip.getInputStream(entry));
1035                } else {
1036                    Log.w(TAG, "skipping directory entry: " + entryName);
1037                }
1038            }
1039
1040            // Then add the user-provided info.
1041            addEntry(zos, "title.txt", info.title);
1042            addEntry(zos, "description.txt", info.description);
1043        } catch (IOException e) {
1044            info.addingDetailsToZip = false;
1045            Log.e(TAG, "exception zipping file " + tmpZip, e);
1046            return;
1047        }
1048
1049        if (!tmpZip.renameTo(info.bugreportFile)) {
1050            Log.e(TAG, "Could not rename " + tmpZip + " to " + info.bugreportFile);
1051        }
1052        info.addedDetailsToZip = true;
1053        info.addingDetailsToZip = false;
1054    }
1055
1056    private static void addEntry(ZipOutputStream zos, String entry, String text)
1057            throws IOException {
1058        if (DEBUG) Log.v(TAG, "adding entry '" + entry + "': " + text);
1059        if (!TextUtils.isEmpty(text)) {
1060            addEntry(zos, entry, new ByteArrayInputStream(text.getBytes(StandardCharsets.UTF_8)));
1061        }
1062    }
1063
1064    private static void addEntry(ZipOutputStream zos, String entryName, InputStream is)
1065            throws IOException {
1066        addEntry(zos, entryName, System.currentTimeMillis(), is);
1067    }
1068
1069    private static void addEntry(ZipOutputStream zos, String entryName, long timestamp,
1070            InputStream is) throws IOException {
1071        final ZipEntry entry = new ZipEntry(entryName);
1072        entry.setTime(timestamp);
1073        zos.putNextEntry(entry);
1074        final int totalBytes = Streams.copy(is, zos);
1075        if (DEBUG) Log.v(TAG, "size of '" + entryName + "' entry: " + totalBytes + " bytes");
1076        zos.closeEntry();
1077    }
1078
1079    /**
1080     * Find the best matching {@link Account} based on build properties.
1081     */
1082    private static Account findSendToAccount(Context context) {
1083        final AccountManager am = (AccountManager) context.getSystemService(
1084                Context.ACCOUNT_SERVICE);
1085
1086        String preferredDomain = SystemProperties.get("sendbug.preferred.domain");
1087        if (!preferredDomain.startsWith("@")) {
1088            preferredDomain = "@" + preferredDomain;
1089        }
1090
1091        final Account[] accounts = am.getAccounts();
1092        Account foundAccount = null;
1093        for (Account account : accounts) {
1094            if (Patterns.EMAIL_ADDRESS.matcher(account.name).matches()) {
1095                if (!preferredDomain.isEmpty()) {
1096                    // if we have a preferred domain and it matches, return; otherwise keep
1097                    // looking
1098                    if (account.name.endsWith(preferredDomain)) {
1099                        return account;
1100                    } else {
1101                        foundAccount = account;
1102                    }
1103                    // if we don't have a preferred domain, just return since it looks like
1104                    // an email address
1105                } else {
1106                    return account;
1107                }
1108            }
1109        }
1110        return foundAccount;
1111    }
1112
1113    static Uri getUri(Context context, File file) {
1114        return file != null ? FileProvider.getUriForFile(context, AUTHORITY, file) : null;
1115    }
1116
1117    static File getFileExtra(Intent intent, String key) {
1118        final String path = intent.getStringExtra(key);
1119        if (path != null) {
1120            return new File(path);
1121        } else {
1122            return null;
1123        }
1124    }
1125
1126    private static boolean setSystemProperty(String key, String value) {
1127        try {
1128            if (DEBUG) Log.v(TAG, "Setting system property " + key + " to " + value);
1129            SystemProperties.set(key, value);
1130        } catch (IllegalArgumentException e) {
1131            Log.e(TAG, "Could not set property " + key + " to " + value, e);
1132            return false;
1133        }
1134        return true;
1135    }
1136
1137    /**
1138     * Updates the system property used by {@code dumpstate} to rename the final bugreport files.
1139     */
1140    private boolean setBugreportNameProperty(int pid, String name) {
1141        Log.d(TAG, "Updating bugreport name to " + name);
1142        final String key = DUMPSTATE_PREFIX + pid + NAME_SUFFIX;
1143        return setSystemProperty(key, name);
1144    }
1145
1146    /**
1147     * Updates the user-provided details of a bugreport.
1148     */
1149    private void updateBugreportInfo(int id, String name, String title, String description) {
1150        final BugreportInfo info = getInfo(id);
1151        if (info == null) {
1152            return;
1153        }
1154        if (title != null && !title.equals(info.title)) {
1155            MetricsLogger.action(this, MetricsEvent.ACTION_BUGREPORT_DETAILS_TITLE_CHANGED);
1156        }
1157        info.title = title;
1158        if (description != null && !description.equals(info.description)) {
1159            MetricsLogger.action(this, MetricsEvent.ACTION_BUGREPORT_DETAILS_DESCRIPTION_CHANGED);
1160        }
1161        info.description = description;
1162        if (name != null && !name.equals(info.name)) {
1163            MetricsLogger.action(this, MetricsEvent.ACTION_BUGREPORT_DETAILS_NAME_CHANGED);
1164            info.name = name;
1165            updateProgress(info);
1166        }
1167    }
1168
1169    private void collapseNotificationBar() {
1170        sendBroadcast(new Intent(Intent.ACTION_CLOSE_SYSTEM_DIALOGS));
1171    }
1172
1173    private static Looper newLooper(String name) {
1174        final HandlerThread thread = new HandlerThread(name, THREAD_PRIORITY_BACKGROUND);
1175        thread.start();
1176        return thread.getLooper();
1177    }
1178
1179    /**
1180     * Takes a screenshot and save it to the given location.
1181     */
1182    private static boolean takeScreenshot(Context context, String screenshotFile) {
1183        ((Vibrator) context.getSystemService(Context.VIBRATOR_SERVICE))
1184                .vibrate(150);
1185        final ProcessBuilder screencap = new ProcessBuilder()
1186                .command("/system/bin/screencap", "-p", screenshotFile);
1187        Log.d(TAG, "Taking screenshot using " + screencap.command());
1188        try {
1189            final int exitValue = screencap.start().waitFor();
1190            if (exitValue == 0) {
1191                return true;
1192            }
1193            Log.e(TAG, "screencap (" + screencap.command() + ") failed: " + exitValue);
1194        } catch (IOException e) {
1195            Log.e(TAG, "screencap (" + screencap.command() + ") failed", e);
1196        } catch (InterruptedException e) {
1197            Log.w(TAG, "Thread interrupted while screencap still running");
1198            Thread.currentThread().interrupt();
1199        }
1200        return false;
1201    }
1202
1203    /**
1204     * Checks whether a character is valid on bugreport names.
1205     */
1206    @VisibleForTesting
1207    static boolean isValid(char c) {
1208        return (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9')
1209                || c == '_' || c == '-';
1210    }
1211
1212    /**
1213     * Helper class encapsulating the UI elements and logic used to display a dialog where user
1214     * can change the details of a bugreport.
1215     */
1216    private final class BugreportInfoDialog {
1217        private EditText mInfoName;
1218        private EditText mInfoTitle;
1219        private EditText mInfoDescription;
1220        private AlertDialog mDialog;
1221        private Button mOkButton;
1222        private int mId;
1223        private int mPid;
1224
1225        /**
1226         * Last "committed" value of the bugreport name.
1227         * <p>
1228         * Once initially set, it's only updated when user clicks the OK button.
1229         */
1230        private String mSavedName;
1231
1232        /**
1233         * Last value of the bugreport name as entered by the user.
1234         * <p>
1235         * Every time it's changed the equivalent system property is changed as well, but if the
1236         * user clicks CANCEL, the old value (stored on {@code mSavedName} is restored.
1237         * <p>
1238         * This logic handles the corner-case scenario where {@code dumpstate} finishes after the
1239         * user changed the name but didn't clicked OK yet (for example, because the user is typing
1240         * the description). The only drawback is that if the user changes the name while
1241         * {@code dumpstate} is running but clicks CANCEL after it finishes, then the final name
1242         * will be the one that has been canceled. But when {@code dumpstate} finishes the {code
1243         * name} UI is disabled and the old name restored anyways, so the user will be "alerted" of
1244         * such drawback.
1245         */
1246        private String mTempName;
1247
1248        /**
1249         * Sets its internal state and displays the dialog.
1250         */
1251        private void initialize(final Context context, BugreportInfo info) {
1252            final String dialogTitle =
1253                    context.getString(R.string.bugreport_info_dialog_title, info.id);
1254            // First initializes singleton.
1255            if (mDialog == null) {
1256                @SuppressLint("InflateParams")
1257                // It's ok pass null ViewRoot on AlertDialogs.
1258                final View view = View.inflate(context, R.layout.dialog_bugreport_info, null);
1259
1260                mInfoName = (EditText) view.findViewById(R.id.name);
1261                mInfoTitle = (EditText) view.findViewById(R.id.title);
1262                mInfoDescription = (EditText) view.findViewById(R.id.description);
1263
1264                mInfoName.setOnFocusChangeListener(new OnFocusChangeListener() {
1265
1266                    @Override
1267                    public void onFocusChange(View v, boolean hasFocus) {
1268                        if (hasFocus) {
1269                            return;
1270                        }
1271                        sanitizeName();
1272                    }
1273                });
1274
1275                mDialog = new AlertDialog.Builder(context)
1276                        .setView(view)
1277                        .setTitle(dialogTitle)
1278                        .setCancelable(false)
1279                        .setPositiveButton(context.getString(com.android.internal.R.string.ok),
1280                                null)
1281                        .setNegativeButton(context.getString(com.android.internal.R.string.cancel),
1282                                new DialogInterface.OnClickListener()
1283                                {
1284                                    @Override
1285                                    public void onClick(DialogInterface dialog, int id)
1286                                    {
1287                                        MetricsLogger.action(context,
1288                                                MetricsEvent.ACTION_BUGREPORT_DETAILS_CANCELED);
1289                                        if (!mTempName.equals(mSavedName)) {
1290                                            // Must restore dumpstate's name since it was changed
1291                                            // before user clicked OK.
1292                                            setBugreportNameProperty(mPid, mSavedName);
1293                                        }
1294                                    }
1295                                })
1296                        .create();
1297
1298                mDialog.getWindow().setAttributes(
1299                        new WindowManager.LayoutParams(
1300                                WindowManager.LayoutParams.TYPE_SYSTEM_DIALOG));
1301
1302            } else {
1303                // Re-use view, but reset fields first.
1304                mDialog.setTitle(dialogTitle);
1305                mInfoName.setText(null);
1306                mInfoTitle.setText(null);
1307                mInfoDescription.setText(null);
1308            }
1309
1310            // Then set fields.
1311            mSavedName = mTempName = info.name;
1312            mId = info.id;
1313            mPid = info.pid;
1314            if (!TextUtils.isEmpty(info.name)) {
1315                mInfoName.setText(info.name);
1316            }
1317            if (!TextUtils.isEmpty(info.title)) {
1318                mInfoTitle.setText(info.title);
1319            }
1320            if (!TextUtils.isEmpty(info.description)) {
1321                mInfoDescription.setText(info.description);
1322            }
1323
1324            // And finally display it.
1325            mDialog.show();
1326
1327            // TODO: in a traditional AlertDialog, when the positive button is clicked the
1328            // dialog is always closed, but we need to validate the name first, so we need to
1329            // get a reference to it, which is only available after it's displayed.
1330            // It would be cleaner to use a regular dialog instead, but let's keep this
1331            // workaround for now and change it later, when we add another button to take
1332            // extra screenshots.
1333            if (mOkButton == null) {
1334                mOkButton = mDialog.getButton(DialogInterface.BUTTON_POSITIVE);
1335                mOkButton.setOnClickListener(new View.OnClickListener() {
1336
1337                    @Override
1338                    public void onClick(View view) {
1339                        MetricsLogger.action(context, MetricsEvent.ACTION_BUGREPORT_DETAILS_SAVED);
1340                        sanitizeName();
1341                        final String name = mInfoName.getText().toString();
1342                        final String title = mInfoTitle.getText().toString();
1343                        final String description = mInfoDescription.getText().toString();
1344
1345                        updateBugreportInfo(mId, name, title, description);
1346                        mDialog.dismiss();
1347                    }
1348                });
1349            }
1350        }
1351
1352        /**
1353         * Sanitizes the user-provided value for the {@code name} field, automatically replacing
1354         * invalid characters if necessary.
1355         */
1356        private void sanitizeName() {
1357            String name = mInfoName.getText().toString();
1358            if (name.equals(mTempName)) {
1359                if (DEBUG) Log.v(TAG, "name didn't change, no need to sanitize: " + name);
1360                return;
1361            }
1362            final StringBuilder safeName = new StringBuilder(name.length());
1363            boolean changed = false;
1364            for (int i = 0; i < name.length(); i++) {
1365                final char c = name.charAt(i);
1366                if (isValid(c)) {
1367                    safeName.append(c);
1368                } else {
1369                    changed = true;
1370                    safeName.append('_');
1371                }
1372            }
1373            if (changed) {
1374                Log.v(TAG, "changed invalid name '" + name + "' to '" + safeName + "'");
1375                name = safeName.toString();
1376                mInfoName.setText(name);
1377            }
1378            mTempName = name;
1379
1380            // Must update system property for the cases where dumpstate finishes
1381            // while the user is still entering other fields (like title or
1382            // description)
1383            setBugreportNameProperty(mPid, name);
1384        }
1385
1386       /**
1387         * Notifies the dialog that the bugreport has finished so it disables the {@code name}
1388         * field.
1389         * <p>Once the bugreport is finished dumpstate has already generated the final files, so
1390         * changing the name would have no effect.
1391         */
1392        private void onBugreportFinished(int id) {
1393            if (mInfoName != null) {
1394                mInfoName.setEnabled(false);
1395                mInfoName.setText(mSavedName);
1396            }
1397        }
1398
1399    }
1400
1401    /**
1402     * Information about a bugreport process while its in progress.
1403     */
1404    private static final class BugreportInfo implements Parcelable {
1405        private final Context context;
1406
1407        /**
1408         * Sequential, user-friendly id used to identify the bugreport.
1409         */
1410        final int id;
1411
1412        /**
1413         * {@code pid} of the {@code dumpstate} process generating the bugreport.
1414         */
1415        final int pid;
1416
1417        /**
1418         * Name of the bugreport, will be used to rename the final files.
1419         * <p>
1420         * Initial value is the bugreport filename reported by {@code dumpstate}, but user can
1421         * change it later to a more meaningful name.
1422         */
1423        String name;
1424
1425        /**
1426         * User-provided, one-line summary of the bug; when set, will be used as the subject
1427         * of the {@link Intent#ACTION_SEND_MULTIPLE} intent.
1428         */
1429        String title;
1430
1431        /**
1432         * User-provided, detailed description of the bugreport; when set, will be added to the body
1433         * of the {@link Intent#ACTION_SEND_MULTIPLE} intent.
1434         */
1435        String description;
1436
1437        /**
1438         * Maximum progress of the bugreport generation.
1439         */
1440        int max;
1441
1442        /**
1443         * Current progress of the bugreport generation.
1444         */
1445        int progress;
1446
1447        /**
1448         * Time of the last progress update.
1449         */
1450        long lastUpdate = System.currentTimeMillis();
1451
1452        /**
1453         * Time of the last progress update when Parcel was created.
1454         */
1455        String formattedLastUpdate;
1456
1457        /**
1458         * Path of the main bugreport file.
1459         */
1460        File bugreportFile;
1461
1462        /**
1463         * Path of the screenshot files.
1464         */
1465        List<File> screenshotFiles = new ArrayList<>(1);
1466
1467        /**
1468         * Whether dumpstate sent an intent informing it has finished.
1469         */
1470        boolean finished;
1471
1472        /**
1473         * Whether the details entries have been added to the bugreport yet.
1474         */
1475        boolean addingDetailsToZip;
1476        boolean addedDetailsToZip;
1477
1478        /**
1479         * Internal counter used to name screenshot files.
1480         */
1481        int screenshotCounter;
1482
1483        /**
1484         * Constructor for tracked bugreports - typically called upon receiving BUGREPORT_STARTED.
1485         */
1486        BugreportInfo(Context context, int id, int pid, String name, int max) {
1487            this.context = context;
1488            this.id = id;
1489            this.pid = pid;
1490            this.name = name;
1491            this.max = max;
1492        }
1493
1494        /**
1495         * Constructor for untracked bugreports - typically called upon receiving BUGREPORT_FINISHED
1496         * without a previous call to BUGREPORT_STARTED.
1497         */
1498        BugreportInfo(Context context, int id) {
1499            this(context, id, id, null, 0);
1500            this.finished = true;
1501        }
1502
1503        /**
1504         * Gets the name for next screenshot file.
1505         */
1506        String getPathNextScreenshot() {
1507            screenshotCounter ++;
1508            return "screenshot-" + pid + "-" + screenshotCounter + ".png";
1509        }
1510
1511        /**
1512         * Saves the location of a taken screenshot so it can be sent out at the end.
1513         */
1514        void addScreenshot(File screenshot) {
1515            screenshotFiles.add(screenshot);
1516        }
1517
1518        /**
1519         * Rename all screenshots files so that they contain the user-generated name instead of pid.
1520         */
1521        void renameScreenshots(File screenshotDir) {
1522            if (TextUtils.isEmpty(name)) {
1523                return;
1524            }
1525            final List<File> renamedFiles = new ArrayList<>(screenshotFiles.size());
1526            for (File oldFile : screenshotFiles) {
1527                final String oldName = oldFile.getName();
1528                final String newName = oldName.replaceFirst(Integer.toString(pid), name);
1529                final File newFile;
1530                if (!newName.equals(oldName)) {
1531                    final File renamedFile = new File(screenshotDir, newName);
1532                    newFile = oldFile.renameTo(renamedFile) ? renamedFile : oldFile;
1533                } else {
1534                    Log.w(TAG, "Name didn't change: " + oldName); // Shouldn't happen.
1535                    newFile = oldFile;
1536                }
1537                renamedFiles.add(newFile);
1538            }
1539            screenshotFiles = renamedFiles;
1540        }
1541
1542        String getFormattedLastUpdate() {
1543            if (context == null) {
1544                // Restored from Parcel
1545                return formattedLastUpdate == null ?
1546                        Long.toString(lastUpdate) : formattedLastUpdate;
1547            }
1548            return DateUtils.formatDateTime(context, lastUpdate,
1549                    DateUtils.FORMAT_SHOW_DATE | DateUtils.FORMAT_SHOW_TIME);
1550        }
1551
1552        @Override
1553        public String toString() {
1554            final float percent = ((float) progress * 100 / max);
1555            return "id: " + id + ", pid: " + pid + ", name: " + name + ", finished: " + finished
1556                    + "\n\ttitle: " + title + "\n\tdescription: " + description
1557                    + "\n\tfile: " + bugreportFile + "\n\tscreenshots: " + screenshotFiles
1558                    + "\n\tprogress: " + progress + "/" + max + " (" + percent + ")"
1559                    + "\n\tlast_update: " + getFormattedLastUpdate()
1560                    + "\naddingDetailsToZip: " + addingDetailsToZip
1561                    + " addedDetailsToZip: " + addedDetailsToZip;
1562        }
1563
1564        // Parcelable contract
1565        protected BugreportInfo(Parcel in) {
1566            context = null;
1567            id = in.readInt();
1568            pid = in.readInt();
1569            name = in.readString();
1570            title = in.readString();
1571            description = in.readString();
1572            max = in.readInt();
1573            progress = in.readInt();
1574            lastUpdate = in.readLong();
1575            formattedLastUpdate = in.readString();
1576            bugreportFile = readFile(in);
1577
1578            int screenshotSize = in.readInt();
1579            for (int i = 1; i <= screenshotSize; i++) {
1580                  screenshotFiles.add(readFile(in));
1581            }
1582
1583            finished = in.readInt() == 1;
1584            screenshotCounter = in.readInt();
1585        }
1586
1587        @Override
1588        public void writeToParcel(Parcel dest, int flags) {
1589            dest.writeInt(id);
1590            dest.writeInt(pid);
1591            dest.writeString(name);
1592            dest.writeString(title);
1593            dest.writeString(description);
1594            dest.writeInt(max);
1595            dest.writeInt(progress);
1596            dest.writeLong(lastUpdate);
1597            dest.writeString(getFormattedLastUpdate());
1598            writeFile(dest, bugreportFile);
1599
1600            dest.writeInt(screenshotFiles.size());
1601            for (File screenshotFile : screenshotFiles) {
1602                writeFile(dest, screenshotFile);
1603            }
1604
1605            dest.writeInt(finished ? 1 : 0);
1606            dest.writeInt(screenshotCounter);
1607        }
1608
1609        @Override
1610        public int describeContents() {
1611            return 0;
1612        }
1613
1614        private void writeFile(Parcel dest, File file) {
1615            dest.writeString(file == null ? null : file.getPath());
1616        }
1617
1618        private File readFile(Parcel in) {
1619            final String path = in.readString();
1620            return path == null ? null : new File(path);
1621        }
1622
1623        public static final Parcelable.Creator<BugreportInfo> CREATOR =
1624                new Parcelable.Creator<BugreportInfo>() {
1625            public BugreportInfo createFromParcel(Parcel source) {
1626                return new BugreportInfo(source);
1627            }
1628
1629            public BugreportInfo[] newArray(int size) {
1630                return new BugreportInfo[size];
1631            }
1632        };
1633
1634    }
1635}
1636