BugreportProgressService.java revision 719aaae3c167c2b15525dbe5c7db514a2c0c8269
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 com.android.shell.BugreportPrefs.STATE_SHOW;
20import static com.android.shell.BugreportPrefs.getWarningState;
21
22import java.io.BufferedOutputStream;
23import java.io.File;
24import java.io.FileDescriptor;
25import java.io.FileInputStream;
26import java.io.FileOutputStream;
27import java.io.IOException;
28import java.io.InputStream;
29import java.io.PrintWriter;
30import java.text.NumberFormat;
31import java.util.ArrayList;
32import java.util.Date;
33import java.util.zip.ZipEntry;
34import java.util.zip.ZipOutputStream;
35
36import libcore.io.Streams;
37
38import com.google.android.collect.Lists;
39
40import android.accounts.Account;
41import android.accounts.AccountManager;
42import android.app.Notification;
43import android.app.Notification.Action;
44import android.app.NotificationManager;
45import android.app.PendingIntent;
46import android.app.Service;
47import android.content.ClipData;
48import android.content.Context;
49import android.content.Intent;
50import android.content.res.Configuration;
51import android.net.Uri;
52import android.os.AsyncTask;
53import android.os.Handler;
54import android.os.HandlerThread;
55import android.os.IBinder;
56import android.os.Looper;
57import android.os.Message;
58import android.os.Parcelable;
59import android.os.Process;
60import android.os.SystemProperties;
61import android.support.v4.content.FileProvider;
62import android.text.format.DateUtils;
63import android.util.Log;
64import android.util.Patterns;
65import android.util.SparseArray;
66import android.widget.Toast;
67
68/**
69 * Service used to keep progress of bug reports processes ({@code dumpstate}).
70 * <p>
71 * The workflow is:
72 * <ol>
73 * <li>When {@code dumpstate} starts, it sends a {@code BUGREPORT_STARTED} with its pid and the
74 * estimated total effort.
75 * <li>{@link BugreportReceiver} receives the intent and delegates it to this service.
76 * <li>Upon start, this service:
77 * <ol>
78 * <li>Issues a system notification so user can watch the progresss (which is 0% initially).
79 * <li>Polls the {@link SystemProperties} for updates on the {@code dumpstate} progress.
80 * <li>If the progress changed, it updates the system notification.
81 * </ol>
82 * <li>As {@code dumpstate} progresses, it updates the system property.
83 * <li>When {@code dumpstate} finishes, it sends a {@code BUGREPORT_FINISHED} intent.
84 * <li>{@link BugreportReceiver} receives the intent and delegates it to this service, which in
85 * turn:
86 * <ol>
87 * <li>Updates the system notification so user can share the bug report.
88 * <li>Stops monitoring that {@code dumpstate} process.
89 * <li>Stops itself if it doesn't have any process left to monitor.
90 * </ol>
91 * </ol>
92 */
93public class BugreportProgressService extends Service {
94    private static final String TAG = "Shell";
95    private static final boolean DEBUG = false;
96
97    private static final String AUTHORITY = "com.android.shell";
98
99    static final String INTENT_BUGREPORT_STARTED = "android.intent.action.BUGREPORT_STARTED";
100    static final String INTENT_BUGREPORT_FINISHED = "android.intent.action.BUGREPORT_FINISHED";
101    static final String INTENT_BUGREPORT_CANCEL = "android.intent.action.BUGREPORT_CANCEL";
102
103    static final String EXTRA_BUGREPORT = "android.intent.extra.BUGREPORT";
104    static final String EXTRA_SCREENSHOT = "android.intent.extra.SCREENSHOT";
105    static final String EXTRA_PID = "android.intent.extra.PID";
106    static final String EXTRA_MAX = "android.intent.extra.MAX";
107    static final String EXTRA_NAME = "android.intent.extra.NAME";
108    static final String EXTRA_ORIGINAL_INTENT = "android.intent.extra.ORIGINAL_INTENT";
109
110    private static final int MSG_SERVICE_COMMAND = 1;
111    private static final int MSG_POLL = 2;
112
113    /** Polling frequency, in milliseconds. */
114    private static final long POLLING_FREQUENCY = 500;
115
116    /** How long (in ms) a dumpstate process will be monitored if it didn't show progress. */
117    private static final long INACTIVITY_TIMEOUT = 3 * DateUtils.MINUTE_IN_MILLIS;
118
119    /** System properties used for monitoring progress. */
120    private static final String DUMPSTATE_PREFIX = "dumpstate.";
121    private static final String PROGRESS_SUFFIX = ".progress";
122    private static final String MAX_SUFFIX = ".max";
123
124    /** System property (and value) used for stop dumpstate. */
125    private static final String CTL_STOP = "ctl.stop";
126    private static final String BUGREPORT_SERVICE = "bugreport";
127
128    /** Managed dumpstate processes (keyed by pid) */
129    private final SparseArray<BugreportInfo> mProcesses = new SparseArray<>();
130
131    private Looper mServiceLooper;
132    private ServiceHandler mServiceHandler;
133
134    @Override
135    public void onCreate() {
136        HandlerThread thread = new HandlerThread("BugreportProgressServiceThread",
137                Process.THREAD_PRIORITY_BACKGROUND);
138        thread.start();
139
140        mServiceLooper = thread.getLooper();
141        mServiceHandler = new ServiceHandler(mServiceLooper);
142    }
143
144    @Override
145    public int onStartCommand(Intent intent, int flags, int startId) {
146        if (intent != null) {
147            // Handle it in a separate thread.
148            Message msg = mServiceHandler.obtainMessage();
149            msg.what = MSG_SERVICE_COMMAND;
150            msg.obj = intent;
151            mServiceHandler.sendMessage(msg);
152        }
153
154        // If service is killed it cannot be recreated because it would not know which
155        // dumpstate PIDs it would have to watch.
156        return START_NOT_STICKY;
157    }
158
159    @Override
160    public IBinder onBind(Intent intent) {
161        return null;
162    }
163
164    @Override
165    public void onDestroy() {
166        mServiceLooper.quit();
167        super.onDestroy();
168    }
169
170    @Override
171    protected void dump(FileDescriptor fd, PrintWriter writer, String[] args) {
172        writer.printf("Monitored dumpstate processes: \n");
173        synchronized (mProcesses) {
174            for (int i = 0; i < mProcesses.size(); i++) {
175              writer.printf("\t%s\n", mProcesses.valueAt(i));
176            }
177        }
178    }
179
180    private final class ServiceHandler extends Handler {
181        public ServiceHandler(Looper looper) {
182            super(looper);
183            pollProgress();
184        }
185
186        @Override
187        public void handleMessage(Message msg) {
188            if (msg.what == MSG_POLL) {
189                pollProgress();
190                return;
191            }
192
193            if (msg.what != MSG_SERVICE_COMMAND) {
194                // Sanity check.
195                Log.e(TAG, "Invalid message type: " + msg.what);
196                return;
197            }
198
199            // At this point it's handling onStartCommand(), whose intent contains the extras
200            // originally received by BugreportReceiver.
201            if (!(msg.obj instanceof Intent)) {
202                // Sanity check.
203                Log.e(TAG, "Internal error: invalid msg.obj: " + msg.obj);
204                return;
205            }
206            final Parcelable parcel = ((Intent) msg.obj).getParcelableExtra(EXTRA_ORIGINAL_INTENT);
207            if (!(parcel instanceof Intent)) {
208                // Sanity check.
209                Log.e(TAG, "Internal error: msg.obj is missing extra " + EXTRA_ORIGINAL_INTENT);
210                return;
211            }
212
213            final Intent intent = (Intent) parcel;
214            final String action = intent.getAction();
215            int pid = intent.getIntExtra(EXTRA_PID, 0);
216            int max = intent.getIntExtra(EXTRA_MAX, -1);
217            String name = intent.getStringExtra(EXTRA_NAME);
218
219            if (DEBUG) Log.v(TAG, "action: " + action + ", name: " + name + ", pid: " + pid
220                    + ", max: "+ max);
221            switch (action) {
222                case INTENT_BUGREPORT_STARTED:
223                    if (!startProgress(name, pid, max)) {
224                        stopSelfWhenDone();
225                        return;
226                    }
227                    break;
228                case INTENT_BUGREPORT_FINISHED:
229                    if (pid == -1) {
230                        // Shouldn't happen, unless BUGREPORT_FINISHED is received from a legacy,
231                        // out-of-sync dumpstate process.
232                        Log.w(TAG, "Missing " + EXTRA_PID + " on intent " + intent);
233                    }
234                    stopProgress(pid, intent);
235                    break;
236                case INTENT_BUGREPORT_CANCEL:
237                    cancel(pid);
238                    break;
239                default:
240                    Log.w(TAG, "Unsupported intent: " + action);
241            }
242            return;
243
244        }
245
246        /**
247         * Creates the {@link BugreportInfo} for a process and issue a system notification to
248         * indicate its progress.
249         *
250         * @return whether it succeeded or not.
251         */
252        private boolean startProgress(String name, int pid, int max) {
253            if (name == null) {
254                Log.w(TAG, "Missing " + EXTRA_NAME + " on start intent");
255                name = "N/A";
256            }
257            if (pid == -1) {
258                Log.e(TAG, "Missing " + EXTRA_PID + " on start intent");
259                return false;
260            }
261            if (max <= 0) {
262                Log.e(TAG, "Invalid value for extra " + EXTRA_MAX + ": " + max);
263                return false;
264            }
265
266            final BugreportInfo info = new BugreportInfo(getApplicationContext(), pid, name, max);
267            synchronized (mProcesses) {
268                if (mProcesses.indexOfKey(pid) >= 0) {
269                    Log.w(TAG, "PID " + pid + " already watched");
270                } else {
271                    mProcesses.put(info.pid, info);
272                }
273            }
274            updateProgress(info);
275            return true;
276        }
277
278        /**
279         * Updates the system notification for a given bug report.
280         */
281        private void updateProgress(BugreportInfo info) {
282            if (info.max <= 0 || info.progress < 0 || info.name == null) {
283                Log.e(TAG, "Invalid progress values for " + info);
284                return;
285            }
286
287            final Context context = getApplicationContext();
288            final NumberFormat nf = NumberFormat.getPercentInstance();
289            nf.setMinimumFractionDigits(2);
290            nf.setMaximumFractionDigits(2);
291            final String percentText = nf.format((double) info.progress / info.max);
292
293            final Intent cancelIntent = new Intent(context, BugreportReceiver.class);
294            cancelIntent.setAction(INTENT_BUGREPORT_CANCEL);
295            cancelIntent.putExtra(EXTRA_PID, info.pid);
296            final Action cancelAction = new Action.Builder(null,
297                    context.getString(com.android.internal.R.string.cancel),
298                    PendingIntent.getBroadcast(context, info.pid, cancelIntent,
299                            PendingIntent.FLAG_CANCEL_CURRENT)).build();
300
301            final String title = context.getString(R.string.bugreport_in_progress_title);
302            final Notification notification = new Notification.Builder(context)
303                    .setSmallIcon(com.android.internal.R.drawable.stat_sys_adb)
304                    .setContentTitle(title)
305                    .setTicker(title)
306                    .setContentText(info.name)
307                    .setContentInfo(percentText)
308                    .setProgress(info.max, info.progress, false)
309                    .setOngoing(true)
310                    .setLocalOnly(true)
311                    .setColor(context.getColor(
312                            com.android.internal.R.color.system_notification_accent_color))
313                    .addAction(cancelAction)
314                    .build();
315
316            NotificationManager.from(context).notify(TAG, info.pid, notification);
317        }
318
319        /**
320         * Finalizes the progress on a given process and sends the finished intent.
321         */
322        private void stopProgress(int pid, Intent intent) {
323            synchronized (mProcesses) {
324                if (mProcesses.indexOfKey(pid) < 0) {
325                    Log.w(TAG, "PID not watched: " + pid);
326                } else {
327                    mProcesses.remove(pid);
328                }
329                stopSelfWhenDone();
330            }
331            if (DEBUG) Log.v(TAG, "stopProgress(" + pid + "): cancel notification");
332            NotificationManager.from(getApplicationContext()).cancel(TAG, pid);
333            if (intent != null) {
334                // Bug report finished fine: send a new, different notification.
335                if (DEBUG) Log.v(TAG, "stopProgress(" + pid + "): finish bug report");
336                onBugreportFinished(pid, intent);
337            }
338        }
339
340        /**
341         * Cancels a bugreport upon user's request.
342         */
343        private void cancel(int pid) {
344            Log.i(TAG, "Cancelling PID " + pid + " on user's request");
345            SystemProperties.set(CTL_STOP, BUGREPORT_SERVICE);
346            stopProgress(pid, null);
347        }
348
349        /**
350         * Poll {@link SystemProperties} to get the progress on each monitored process.
351         */
352        private void pollProgress() {
353            synchronized (mProcesses) {
354                if (mProcesses.size() == 0) {
355                    Log.d(TAG, "No process to poll progress.");
356                }
357                for (int i = 0; i < mProcesses.size(); i++) {
358                    final int pid = mProcesses.keyAt(i);
359                    final String progressKey = DUMPSTATE_PREFIX + pid + PROGRESS_SUFFIX;
360                    final int progress = SystemProperties.getInt(progressKey, 0);
361                    if (progress == 0) {
362                        Log.v(TAG, "System property " + progressKey + " is not set yet");
363                        continue;
364                    }
365                    final int max = SystemProperties.getInt(DUMPSTATE_PREFIX + pid + MAX_SUFFIX, 0);
366                    final BugreportInfo info = mProcesses.valueAt(i);
367                    final boolean maxChanged = max > 0 && max != info.max;
368                    final boolean progressChanged = progress > 0 && progress != info.progress;
369
370                    if (progressChanged || maxChanged) {
371                        if (progressChanged) {
372                            if (DEBUG) Log.v(TAG, "Updating progress for PID " + pid + " from "
373                                    + info.progress + " to " + progress);
374                            info.progress = progress;
375                        }
376                        if (maxChanged) {
377                            Log.i(TAG, "Updating max progress for PID " + pid + " from " + info.max
378                                    + " to " + max);
379                            info.max = max;
380                        }
381                        info.lastUpdate = System.currentTimeMillis();
382                        updateProgress(info);
383                    } else {
384                        long inactiveTime = System.currentTimeMillis() - info.lastUpdate;
385                        if (inactiveTime >= INACTIVITY_TIMEOUT) {
386                            Log.w(TAG, "No progress update for process " + pid + " since "
387                                    + info.getFormattedLastUpdate());
388                            stopProgress(info.pid, null);
389                        }
390                    }
391                }
392                // Keep polling...
393                sendEmptyMessageDelayed(MSG_POLL, POLLING_FREQUENCY);
394            }
395        }
396
397        /**
398         * Finishes the service when it's not monitoring any more processes.
399         */
400        private void stopSelfWhenDone() {
401            synchronized (mProcesses) {
402                if (mProcesses.size() > 0) {
403                    if (DEBUG) Log.v(TAG, "Staying alive, waiting for pids " + mProcesses);
404                    return;
405                }
406                Log.v(TAG, "No more pids to handle, shutting down");
407                stopSelf();
408            }
409        }
410
411        private void onBugreportFinished(int pid, Intent intent) {
412            final Context context = getApplicationContext();
413            final Configuration conf = context.getResources().getConfiguration();
414            final File bugreportFile = getFileExtra(intent, EXTRA_BUGREPORT);
415            final File screenshotFile = getFileExtra(intent, EXTRA_SCREENSHOT);
416
417            if ((conf.uiMode & Configuration.UI_MODE_TYPE_MASK) != Configuration.UI_MODE_TYPE_WATCH) {
418                triggerLocalNotification(context, pid, bugreportFile, screenshotFile);
419            }
420        }
421    }
422
423    /**
424     * Responsible for triggering a notification that allows the user to start a "share" intent with
425     * the bug report. On watches we have other methods to allow the user to start this intent
426     * (usually by triggering it on another connected device); we don't need to display the
427     * notification in this case.
428     */
429    private static void triggerLocalNotification(final Context context, final int pid,
430            final File bugreportFile, final File screenshotFile) {
431        if (!bugreportFile.exists() || !bugreportFile.canRead()) {
432            Log.e(TAG, "Could not read bugreport file " + bugreportFile);
433            Toast.makeText(context, context.getString(R.string.bugreport_unreadable_text),
434                    Toast.LENGTH_LONG).show();
435            return;
436        }
437
438        boolean isPlainText = bugreportFile.getName().toLowerCase().endsWith(".txt");
439        if (!isPlainText) {
440            // Already zipped, send it right away.
441            sendBugreportNotification(context, pid, bugreportFile, screenshotFile);
442        } else {
443            // Asynchronously zip the file first, then send it.
444            sendZippedBugreportNotification(context, pid, bugreportFile, screenshotFile);
445        }
446    }
447
448    private static Intent buildWarningIntent(Context context, Intent sendIntent) {
449        final Intent intent = new Intent(context, BugreportWarningActivity.class);
450        intent.putExtra(Intent.EXTRA_INTENT, sendIntent);
451        return intent;
452    }
453
454    /**
455     * Build {@link Intent} that can be used to share the given bugreport.
456     */
457    private static Intent buildSendIntent(Context context, Uri bugreportUri, Uri screenshotUri) {
458        final Intent intent = new Intent(Intent.ACTION_SEND_MULTIPLE);
459        final String mimeType = "application/vnd.android.bugreport";
460        intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION);
461        intent.addCategory(Intent.CATEGORY_DEFAULT);
462        intent.setType(mimeType);
463
464        intent.putExtra(Intent.EXTRA_SUBJECT, bugreportUri.getLastPathSegment());
465
466        // EXTRA_TEXT should be an ArrayList, but some clients are expecting a single String.
467        // So, to avoid an exception on Intent.migrateExtraStreamToClipData(), we need to manually
468        // create the ClipData object with the attachments URIs.
469        String messageBody = String.format("Build info: %s\nSerial number:%s",
470                SystemProperties.get("ro.build.description"), SystemProperties.get("ro.serialno"));
471        intent.putExtra(Intent.EXTRA_TEXT, messageBody);
472        final ClipData clipData = new ClipData(null, new String[] { mimeType },
473                new ClipData.Item(null, null, null, bugreportUri));
474        final ArrayList<Uri> attachments = Lists.newArrayList(bugreportUri);
475        if (screenshotUri != null) {
476            clipData.addItem(new ClipData.Item(null, null, null, screenshotUri));
477            attachments.add(screenshotUri);
478        }
479        intent.setClipData(clipData);
480        intent.putParcelableArrayListExtra(Intent.EXTRA_STREAM, attachments);
481
482        final Account sendToAccount = findSendToAccount(context);
483        if (sendToAccount != null) {
484            intent.putExtra(Intent.EXTRA_EMAIL, new String[] { sendToAccount.name });
485        }
486
487        return intent;
488    }
489
490    /**
491     * Sends a bugreport notitication.
492     */
493    private static void sendBugreportNotification(Context context, int pid, File bugreportFile,
494            File screenshotFile) {
495        // Files are kept on private storage, so turn into Uris that we can
496        // grant temporary permissions for.
497        final Uri bugreportUri = getUri(context, bugreportFile);
498        final Uri screenshotUri = getUri(context, screenshotFile);
499
500        Intent sendIntent = buildSendIntent(context, bugreportUri, screenshotUri);
501        Intent notifIntent;
502
503        // Send through warning dialog by default
504        if (getWarningState(context, STATE_SHOW) == STATE_SHOW) {
505            notifIntent = buildWarningIntent(context, sendIntent);
506        } else {
507            notifIntent = sendIntent;
508        }
509        notifIntent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
510
511        final String title = context.getString(R.string.bugreport_finished_title);
512        final Notification.Builder builder = new Notification.Builder(context)
513                .setSmallIcon(com.android.internal.R.drawable.stat_sys_adb)
514                .setContentTitle(title)
515                .setTicker(title)
516                .setContentText(context.getString(R.string.bugreport_finished_text))
517                .setContentIntent(PendingIntent.getActivity(
518                        context, 0, notifIntent, PendingIntent.FLAG_CANCEL_CURRENT))
519                .setAutoCancel(true)
520                .setLocalOnly(true)
521                .setColor(context.getColor(
522                        com.android.internal.R.color.system_notification_accent_color));
523
524        NotificationManager.from(context).notify(TAG, pid, builder.build());
525    }
526
527    /**
528     * Sends a zipped bugreport notification.
529     */
530    private static void sendZippedBugreportNotification(final Context context,
531            final int pid, final File bugreportFile, final File screenshotFile) {
532        new AsyncTask<Void, Void, Void>() {
533            @Override
534            protected Void doInBackground(Void... params) {
535                File zippedFile = zipBugreport(bugreportFile);
536                sendBugreportNotification(context, pid, zippedFile, screenshotFile);
537                return null;
538            }
539        }.execute();
540    }
541
542    /**
543     * Zips a bugreport file, returning the path to the new file (or to the
544     * original in case of failure).
545     */
546    private static File zipBugreport(File bugreportFile) {
547        String bugreportPath = bugreportFile.getAbsolutePath();
548        String zippedPath = bugreportPath.replace(".txt", ".zip");
549        Log.v(TAG, "zipping " + bugreportPath + " as " + zippedPath);
550        File bugreportZippedFile = new File(zippedPath);
551        try (InputStream is = new FileInputStream(bugreportFile);
552                ZipOutputStream zos = new ZipOutputStream(
553                        new BufferedOutputStream(new FileOutputStream(bugreportZippedFile)))) {
554            ZipEntry entry = new ZipEntry(bugreportFile.getName());
555            entry.setTime(bugreportFile.lastModified());
556            zos.putNextEntry(entry);
557            int totalBytes = Streams.copy(is, zos);
558            Log.v(TAG, "size of original bugreport: " + totalBytes + " bytes");
559            zos.closeEntry();
560            // Delete old file;
561            boolean deleted = bugreportFile.delete();
562            if (deleted) {
563                Log.v(TAG, "deleted original bugreport (" + bugreportPath + ")");
564            } else {
565                Log.e(TAG, "could not delete original bugreport (" + bugreportPath + ")");
566            }
567            return bugreportZippedFile;
568        } catch (IOException e) {
569            Log.e(TAG, "exception zipping file " + zippedPath, e);
570            return bugreportFile; // Return original.
571        }
572    }
573
574    /**
575     * Find the best matching {@link Account} based on build properties.
576     */
577    private static Account findSendToAccount(Context context) {
578        final AccountManager am = (AccountManager) context.getSystemService(
579                Context.ACCOUNT_SERVICE);
580
581        String preferredDomain = SystemProperties.get("sendbug.preferred.domain");
582        if (!preferredDomain.startsWith("@")) {
583            preferredDomain = "@" + preferredDomain;
584        }
585
586        final Account[] accounts = am.getAccounts();
587        Account foundAccount = null;
588        for (Account account : accounts) {
589            if (Patterns.EMAIL_ADDRESS.matcher(account.name).matches()) {
590                if (!preferredDomain.isEmpty()) {
591                    // if we have a preferred domain and it matches, return; otherwise keep
592                    // looking
593                    if (account.name.endsWith(preferredDomain)) {
594                        return account;
595                    } else {
596                        foundAccount = account;
597                    }
598                    // if we don't have a preferred domain, just return since it looks like
599                    // an email address
600                } else {
601                    return account;
602                }
603            }
604        }
605        return foundAccount;
606    }
607
608    private static Uri getUri(Context context, File file) {
609        return file != null ? FileProvider.getUriForFile(context, AUTHORITY, file) : null;
610    }
611
612    static File getFileExtra(Intent intent, String key) {
613        final String path = intent.getStringExtra(key);
614        if (path != null) {
615            return new File(path);
616        } else {
617            return null;
618        }
619    }
620
621    /**
622     * Information about a bug report process while its in progress.
623     */
624    private static final class BugreportInfo {
625        private final Context context;
626
627        /**
628         * {@code pid} of the {@code dumpstate} process generating the bug report.
629         */
630        final int pid;
631
632        /**
633         * Name of the bug report, will be used to rename the final files.
634         * <p>
635         * Initial value is the bug report filename reported by {@code dumpstate}, but user can
636         * change it later to a more meaningful name.
637         */
638        String name;
639
640        /**
641         * Maximum progress of the bug report generation.
642         */
643        int max;
644
645        /**
646         * Current progress of the bug report generation.
647         */
648        int progress;
649
650        /**
651         * Time of the last progress update.
652         */
653        long lastUpdate = System.currentTimeMillis();
654
655        BugreportInfo(Context context, int pid, String name, int max) {
656            this.context = context;
657            this.pid = pid;
658            this.name = name;
659            this.max = max;
660        }
661
662        String getFormattedLastUpdate() {
663            return DateUtils.formatDateTime(context, lastUpdate,
664                    DateUtils.FORMAT_SHOW_DATE | DateUtils.FORMAT_SHOW_TIME);
665        }
666
667        @Override
668        public String toString() {
669            final float percent = ((float) progress * 100 / max);
670            return "Progress for " + name + " (pid=" + pid + "): " + progress + "/" + max
671                    + " (" + percent + "%) Last update: " + getFormattedLastUpdate();
672        }
673    }
674}
675