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