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