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