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