1/*
2 * Copyright (C) 2008 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 */
16package com.android.quake;
17
18
19import java.io.File;
20import java.io.FileInputStream;
21import java.io.FileNotFoundException;
22import java.io.FileOutputStream;
23import java.io.IOException;
24import java.io.InputStream;
25import java.io.OutputStream;
26import java.io.UnsupportedEncodingException;
27import java.net.MalformedURLException;
28import java.net.URL;
29import java.nio.channels.FileLock;
30
31import java.security.MessageDigest;
32import java.security.NoSuchAlgorithmException;
33import java.text.DecimalFormat;
34import java.util.ArrayList;
35import java.util.HashMap;
36import java.util.HashSet;
37
38import org.apache.http.Header;
39import org.apache.http.HttpEntity;
40import org.apache.http.HttpResponse;
41import org.apache.http.HttpStatus;
42import org.apache.http.client.ClientProtocolException;
43import org.apache.http.client.methods.HttpGet;
44import org.apache.http.client.methods.HttpHead;
45import org.xml.sax.Attributes;
46import org.xml.sax.SAXException;
47import org.xml.sax.helpers.DefaultHandler;
48
49import android.app.Activity;
50import android.app.AlertDialog;
51import android.app.AlertDialog.Builder;
52import android.content.DialogInterface;
53import android.content.Intent;
54import android.os.Bundle;
55import android.os.Handler;
56import android.os.Message;
57import android.os.SystemClock;
58import android.net.http.AndroidHttpClient;
59import android.util.Log;
60import android.util.Xml;
61import android.view.View;
62import android.view.Window;
63import android.view.WindowManager;
64import android.widget.Button;
65import android.widget.TextView;
66
67public class DownloaderActivity extends Activity {
68
69    /**
70     * Checks if data has been downloaded. If so, returns true. If not,
71     * starts an activity to download the data and returns false. If this
72     * function returns false the caller should immediately return from its
73     * onCreate method. The calling activity will later be restarted
74     * (using a copy of its original intent) once the data download completes.
75     * @param activity The calling activity.
76     * @param customText A text string that is displayed in the downloader UI.
77     * @param fileConfigUrl The URL of the download configuration URL.
78     * @param configVersion The version of the configuration file.
79     * @param dataPath The directory on the device where we want to store the
80     * data.
81     * @param userAgent The user agent string to use when fetching URLs.
82     * @return true if the data has already been downloaded successfully, or
83     * false if the data needs to be downloaded.
84     */
85    public static boolean ensureDownloaded(Activity activity,
86            String customText, String fileConfigUrl,
87            String configVersion, String dataPath,
88            String userAgent) {
89        File dest = new File(dataPath);
90        if (dest.exists()) {
91            // Check version
92            if (versionMatches(dest, configVersion)) {
93                Log.i(LOG_TAG, "Versions match, no need to download.");
94                return true;
95            }
96        }
97        Intent intent = PreconditionActivityHelper.createPreconditionIntent(
98                activity, DownloaderActivity.class);
99        intent.putExtra(EXTRA_CUSTOM_TEXT, customText);
100        intent.putExtra(EXTRA_FILE_CONFIG_URL, fileConfigUrl);
101        intent.putExtra(EXTRA_CONFIG_VERSION, configVersion);
102        intent.putExtra(EXTRA_DATA_PATH, dataPath);
103        intent.putExtra(EXTRA_USER_AGENT, userAgent);
104        PreconditionActivityHelper.startPreconditionActivityAndFinish(
105                activity, intent);
106        return false;
107    }
108
109    /**
110     * Delete a directory and all its descendants.
111     * @param directory The directory to delete
112     * @return true if the directory was deleted successfully.
113     */
114    public static boolean deleteData(String directory) {
115        return deleteTree(new File(directory), true);
116    }
117
118    private static boolean deleteTree(File base, boolean deleteBase) {
119        boolean result = true;
120        if (base.isDirectory()) {
121            for (File child : base.listFiles()) {
122                result &= deleteTree(child, true);
123            }
124        }
125        if (deleteBase) {
126            result &= base.delete();
127        }
128        return result;
129    }
130
131    private static boolean versionMatches(File dest, String expectedVersion) {
132        Config config = getLocalConfig(dest, LOCAL_CONFIG_FILE);
133        if (config != null) {
134            return config.version.equals(expectedVersion);
135        }
136        return false;
137    }
138
139    private static Config getLocalConfig(File destPath, String configFilename) {
140        File configPath = new File(destPath, configFilename);
141        FileInputStream is;
142        try {
143            is = new FileInputStream(configPath);
144        } catch (FileNotFoundException e) {
145            return null;
146        }
147        try {
148            Config config = ConfigHandler.parse(is);
149            return config;
150        } catch (Exception e) {
151            Log.e(LOG_TAG, "Unable to read local config file", e);
152            return null;
153        } finally {
154            quietClose(is);
155        }
156    }
157
158    @Override
159    protected void onCreate(Bundle savedInstanceState) {
160        super.onCreate(savedInstanceState);
161        Intent intent = getIntent();
162        requestWindowFeature(Window.FEATURE_CUSTOM_TITLE);
163        setContentView(R.layout.downloader);
164        getWindow().setFeatureInt(Window.FEATURE_CUSTOM_TITLE,
165                R.layout.downloader_title);
166        ((TextView) findViewById(R.id.customText)).setText(
167                intent.getStringExtra(EXTRA_CUSTOM_TEXT));
168        mProgress = (TextView) findViewById(R.id.progress);
169        mTimeRemaining = (TextView) findViewById(R.id.time_remaining);
170        Button button = (Button) findViewById(R.id.cancel);
171        button.setOnClickListener(new Button.OnClickListener() {
172            public void onClick(View v) {
173                if (mDownloadThread != null) {
174                    mSuppressErrorMessages = true;
175                    mDownloadThread.interrupt();
176                }
177            }
178        });
179        startDownloadThread();
180    }
181
182    private void startDownloadThread() {
183        mSuppressErrorMessages = false;
184        mProgress.setText("");
185        mTimeRemaining.setText("");
186        mDownloadThread = new Thread(new Downloader(), "Downloader");
187        mDownloadThread.setPriority(Thread.NORM_PRIORITY - 1);
188        mDownloadThread.start();
189    }
190
191    @Override
192    protected void onResume() {
193        super.onResume();
194    }
195
196    @Override
197    protected void onDestroy() {
198        super.onDestroy();
199        mSuppressErrorMessages = true;
200        mDownloadThread.interrupt();
201        try {
202            mDownloadThread.join();
203        } catch (InterruptedException e) {
204            // Don't care.
205        }
206    }
207
208    private void onDownloadSucceeded() {
209        Log.i(LOG_TAG, "Download succeeded");
210        PreconditionActivityHelper.startOriginalActivityAndFinish(this);
211    }
212
213    private void onDownloadFailed(String reason) {
214        Log.e(LOG_TAG, "Download stopped: " + reason);
215        String shortReason;
216        int index = reason.indexOf('\n');
217        if (index >= 0) {
218            shortReason = reason.substring(0, index);
219        } else {
220            shortReason = reason;
221        }
222        AlertDialog alert = new Builder(this).create();
223        alert.setTitle(R.string.download_activity_download_stopped);
224
225        if (!mSuppressErrorMessages) {
226            alert.setMessage(shortReason);
227        }
228
229        alert.setButton(getString(R.string.download_activity_retry),
230                new DialogInterface.OnClickListener() {
231            public void onClick(DialogInterface dialog, int which) {
232                startDownloadThread();
233            }
234
235        });
236        alert.setButton2(getString(R.string.download_activity_quit),
237                new DialogInterface.OnClickListener() {
238            public void onClick(DialogInterface dialog, int which) {
239                finish();
240            }
241
242        });
243        try {
244            alert.show();
245        } catch (WindowManager.BadTokenException e) {
246            // Happens when the Back button is used to exit the activity.
247            // ignore.
248        }
249    }
250
251    private void onReportProgress(int progress) {
252        mProgress.setText(mPercentFormat.format(progress / 10000.0));
253        long now = SystemClock.elapsedRealtime();
254        if (mStartTime == 0) {
255            mStartTime = now;
256        }
257        long delta = now - mStartTime;
258        String timeRemaining = getString(R.string.download_activity_time_remaining_unknown);
259        if ((delta > 3 * MS_PER_SECOND) && (progress > 100)) {
260            long totalTime = 10000 * delta / progress;
261            long timeLeft = Math.max(0L, totalTime - delta);
262            if (timeLeft > MS_PER_DAY) {
263                timeRemaining = Long.toString(
264                    (timeLeft + MS_PER_DAY - 1) / MS_PER_DAY)
265                    + " "
266                    + getString(R.string.download_activity_time_remaining_days);
267            } else if (timeLeft > MS_PER_HOUR) {
268                timeRemaining = Long.toString(
269                        (timeLeft + MS_PER_HOUR - 1) / MS_PER_HOUR)
270                        + " "
271                        + getString(R.string.download_activity_time_remaining_hours);
272            } else if (timeLeft > MS_PER_MINUTE) {
273                timeRemaining = Long.toString(
274                        (timeLeft + MS_PER_MINUTE - 1) / MS_PER_MINUTE)
275                        + " "
276                        + getString(R.string.download_activity_time_remaining_minutes);
277            } else {
278                timeRemaining = Long.toString(
279                        (timeLeft + MS_PER_SECOND - 1) / MS_PER_SECOND)
280                        + " "
281                        + getString(R.string.download_activity_time_remaining_seconds);
282            }
283        }
284        mTimeRemaining.setText(timeRemaining);
285    }
286
287    private static void quietClose(InputStream is) {
288        try {
289            if (is != null) {
290                is.close();
291            }
292        } catch (IOException e) {
293            // Don't care.
294        }
295    }
296
297    private static void quietClose(OutputStream os) {
298        try {
299            if (os != null) {
300                os.close();
301            }
302        } catch (IOException e) {
303            // Don't care.
304        }
305    }
306
307    private static class Config {
308        long getSize() {
309            long result = 0;
310            for(File file : mFiles) {
311                result += file.getSize();
312            }
313            return result;
314        }
315        static class File {
316            public File(String src, String dest, String md5, long size) {
317                if (src != null) {
318                    this.mParts.add(new Part(src, md5, size));
319                }
320                this.dest = dest;
321            }
322            static class Part {
323                Part(String src, String md5, long size) {
324                    this.src = src;
325                    this.md5 = md5;
326                    this.size = size;
327                }
328                String src;
329                String md5;
330                long size;
331            }
332            ArrayList<Part> mParts = new ArrayList<Part>();
333            String dest;
334            long getSize() {
335                long result = 0;
336                for(Part part : mParts) {
337                    if (part.size > 0) {
338                        result += part.size;
339                    }
340                }
341                return result;
342            }
343        }
344        String version;
345        ArrayList<File> mFiles = new ArrayList<File>();
346    }
347
348    /**
349     * <config version="">
350     *   <file src="http:..." dest ="b.x" />
351     *   <file dest="b.x">
352     *     <part src="http:..." />
353     *     ...
354     *   ...
355     * </config>
356     *
357     */
358    private static class ConfigHandler extends DefaultHandler {
359
360        public static Config parse(InputStream is) throws SAXException,
361            UnsupportedEncodingException, IOException {
362            ConfigHandler handler = new ConfigHandler();
363            Xml.parse(is, Xml.findEncodingByName("UTF-8"), handler);
364            return handler.mConfig;
365        }
366
367        private ConfigHandler() {
368            mConfig = new Config();
369        }
370
371        @Override
372        public void startElement(String uri, String localName, String qName,
373                Attributes attributes) throws SAXException {
374            if (localName.equals("config")) {
375                mConfig.version = getRequiredString(attributes, "version");
376            } else if (localName.equals("file")) {
377                String src = attributes.getValue("", "src");
378                String dest = getRequiredString(attributes, "dest");
379                String md5 = attributes.getValue("", "md5");
380                long size = getLong(attributes, "size", -1);
381                mConfig.mFiles.add(new Config.File(src, dest, md5, size));
382            } else if (localName.equals("part")) {
383                String src = getRequiredString(attributes, "src");
384                String md5 = attributes.getValue("", "md5");
385                long size = getLong(attributes, "size", -1);
386                int length = mConfig.mFiles.size();
387                if (length > 0) {
388                    mConfig.mFiles.get(length-1).mParts.add(
389                            new Config.File.Part(src, md5, size));
390                }
391            }
392        }
393
394        private static String getRequiredString(Attributes attributes,
395                String localName) throws SAXException {
396            String result = attributes.getValue("", localName);
397            if (result == null) {
398                throw new SAXException("Expected attribute " + localName);
399            }
400            return result;
401        }
402
403        private static long getLong(Attributes attributes, String localName,
404                long defaultValue) {
405            String value = attributes.getValue("", localName);
406            if (value == null) {
407                return defaultValue;
408            } else {
409                return Long.parseLong(value);
410            }
411        }
412
413        public Config mConfig;
414    }
415
416    private class DownloaderException extends Exception {
417        public DownloaderException(String reason) {
418            super(reason);
419        }
420    }
421
422    private class Downloader implements Runnable {
423        public void run() {
424            Intent intent = getIntent();
425            mFileConfigUrl = intent.getStringExtra(EXTRA_FILE_CONFIG_URL);
426            mConfigVersion = intent.getStringExtra(EXTRA_CONFIG_VERSION);
427            mDataPath = intent.getStringExtra(EXTRA_DATA_PATH);
428            mUserAgent = intent.getStringExtra(EXTRA_USER_AGENT);
429
430            mDataDir = new File(mDataPath);
431
432            try {
433                // Download files.
434                mHttpClient = AndroidHttpClient.newInstance(mUserAgent);
435                try {
436                    Config config = getConfig();
437                    filter(config);
438                    persistantDownload(config);
439                    verify(config);
440                    cleanup();
441                    reportSuccess();
442                } finally {
443                    mHttpClient.close();
444                }
445            } catch (Exception e) {
446                reportFailure(e.toString() + "\n" + Log.getStackTraceString(e));
447            }
448        }
449
450        private void persistantDownload(Config config)
451        throws ClientProtocolException, DownloaderException, IOException {
452            while(true) {
453                try {
454                    download(config);
455                    break;
456                } catch(java.net.SocketException e) {
457                    if (mSuppressErrorMessages) {
458                        throw e;
459                    }
460                } catch(java.net.SocketTimeoutException e) {
461                    if (mSuppressErrorMessages) {
462                        throw e;
463                    }
464                }
465                Log.i(LOG_TAG, "Network connectivity issue, retrying.");
466            }
467        }
468
469        private void filter(Config config)
470        throws IOException, DownloaderException {
471            File filteredFile = new File(mDataDir, LOCAL_FILTERED_FILE);
472            if (filteredFile.exists()) {
473                return;
474            }
475
476            File localConfigFile = new File(mDataDir, LOCAL_CONFIG_FILE_TEMP);
477            HashSet<String> keepSet = new HashSet<String>();
478            keepSet.add(localConfigFile.getCanonicalPath());
479
480            HashMap<String, Config.File> fileMap =
481                new HashMap<String, Config.File>();
482            for(Config.File file : config.mFiles) {
483                String canonicalPath =
484                    new File(mDataDir, file.dest).getCanonicalPath();
485                fileMap.put(canonicalPath, file);
486            }
487            recursiveFilter(mDataDir, fileMap, keepSet, false);
488            touch(filteredFile);
489        }
490
491        private void touch(File file) throws FileNotFoundException {
492            FileOutputStream os = new FileOutputStream(file);
493            quietClose(os);
494        }
495
496        private boolean recursiveFilter(File base,
497                HashMap<String, Config.File> fileMap,
498                HashSet<String> keepSet, boolean filterBase)
499        throws IOException, DownloaderException {
500            boolean result = true;
501            if (base.isDirectory()) {
502                for (File child : base.listFiles()) {
503                    result &= recursiveFilter(child, fileMap, keepSet, true);
504                }
505            }
506            if (filterBase) {
507                if (base.isDirectory()) {
508                    if (base.listFiles().length == 0) {
509                        result &= base.delete();
510                    }
511                } else {
512                    if (!shouldKeepFile(base, fileMap, keepSet)) {
513                        result &= base.delete();
514                    }
515                }
516            }
517            return result;
518        }
519
520        private boolean shouldKeepFile(File file,
521                HashMap<String, Config.File> fileMap,
522                HashSet<String> keepSet)
523        throws IOException, DownloaderException {
524            String canonicalPath = file.getCanonicalPath();
525            if (keepSet.contains(canonicalPath)) {
526                return true;
527            }
528            Config.File configFile = fileMap.get(canonicalPath);
529            if (configFile == null) {
530                return false;
531            }
532            return verifyFile(configFile, false);
533        }
534
535        private void reportSuccess() {
536            mHandler.sendMessage(
537                    Message.obtain(mHandler, MSG_DOWNLOAD_SUCCEEDED));
538        }
539
540        private void reportFailure(String reason) {
541            mHandler.sendMessage(
542                    Message.obtain(mHandler, MSG_DOWNLOAD_FAILED, reason));
543        }
544
545        private void reportProgress(int progress) {
546            mHandler.sendMessage(
547                    Message.obtain(mHandler, MSG_REPORT_PROGRESS, progress, 0));
548        }
549
550        private Config getConfig() throws DownloaderException,
551            ClientProtocolException, IOException, SAXException {
552            Config config = null;
553            if (mDataDir.exists()) {
554                config = getLocalConfig(mDataDir, LOCAL_CONFIG_FILE_TEMP);
555                if ((config == null)
556                        || !mConfigVersion.equals(config.version)) {
557                    if (config == null) {
558                        Log.i(LOG_TAG, "Couldn't find local config.");
559                    } else {
560                        Log.i(LOG_TAG, "Local version out of sync. Wanted " +
561                                mConfigVersion + " but have " + config.version);
562                    }
563                    config = null;
564                }
565            } else {
566                Log.i(LOG_TAG, "Creating directory " + mDataPath);
567                mDataDir.mkdirs();
568                mDataDir.mkdir();
569                if (!mDataDir.exists()) {
570                    throw new DownloaderException(
571                            "Could not create the directory " + mDataPath);
572                }
573            }
574            if (config == null) {
575                File localConfig = download(mFileConfigUrl,
576                        LOCAL_CONFIG_FILE_TEMP);
577                InputStream is = new FileInputStream(localConfig);
578                try {
579                    config = ConfigHandler.parse(is);
580                } finally {
581                    quietClose(is);
582                }
583                if (! config.version.equals(mConfigVersion)) {
584                    throw new DownloaderException(
585                            "Configuration file version mismatch. Expected " +
586                            mConfigVersion + " received " +
587                            config.version);
588                }
589            }
590            return config;
591        }
592
593        private void noisyDelete(File file) throws IOException {
594            if (! file.delete() ) {
595                throw new IOException("could not delete " + file);
596            }
597        }
598
599        private void download(Config config) throws DownloaderException,
600            ClientProtocolException, IOException {
601            mDownloadedSize = 0;
602            getSizes(config);
603            Log.i(LOG_TAG, "Total bytes to download: "
604                    + mTotalExpectedSize);
605            for(Config.File file : config.mFiles) {
606                downloadFile(file);
607            }
608        }
609
610        private void downloadFile(Config.File file) throws DownloaderException,
611                FileNotFoundException, IOException, ClientProtocolException {
612            boolean append = false;
613            File dest = new File(mDataDir, file.dest);
614            long bytesToSkip = 0;
615            if (dest.exists() && dest.isFile()) {
616                append = true;
617                bytesToSkip = dest.length();
618                mDownloadedSize += bytesToSkip;
619            }
620            FileOutputStream os = null;
621            long offsetOfCurrentPart = 0;
622            try {
623                for(Config.File.Part part : file.mParts) {
624                    // The part.size==0 check below allows us to download
625                    // zero-length files.
626                    if ((part.size > bytesToSkip) || (part.size == 0)) {
627                        MessageDigest digest = null;
628                        if (part.md5 != null) {
629                            digest = createDigest();
630                            if (bytesToSkip > 0) {
631                                FileInputStream is = openInput(file.dest);
632                                try {
633                                    is.skip(offsetOfCurrentPart);
634                                    readIntoDigest(is, bytesToSkip, digest);
635                                } finally {
636                                    quietClose(is);
637                                }
638                            }
639                        }
640                        if (os == null) {
641                            os = openOutput(file.dest, append);
642                        }
643                        downloadPart(part.src, os, bytesToSkip,
644                                part.size, digest);
645                        if (digest != null) {
646                            String hash = getHash(digest);
647                            if (!hash.equalsIgnoreCase(part.md5)) {
648                                Log.e(LOG_TAG, "web MD5 checksums don't match. "
649                                        + part.src + "\nExpected "
650                                        + part.md5 + "\n     got " + hash);
651                                quietClose(os);
652                                dest.delete();
653                                throw new DownloaderException(
654                                      "Received bad data from web server");
655                            } else {
656                               Log.i(LOG_TAG, "web MD5 checksum matches.");
657                            }
658                        }
659                    }
660                    bytesToSkip -= Math.min(bytesToSkip, part.size);
661                    offsetOfCurrentPart += part.size;
662                }
663            } finally {
664                quietClose(os);
665            }
666        }
667
668        private void cleanup() throws IOException {
669            File filtered = new File(mDataDir, LOCAL_FILTERED_FILE);
670            noisyDelete(filtered);
671            File tempConfig = new File(mDataDir, LOCAL_CONFIG_FILE_TEMP);
672            File realConfig = new File(mDataDir, LOCAL_CONFIG_FILE);
673            tempConfig.renameTo(realConfig);
674        }
675
676        private void verify(Config config) throws DownloaderException,
677        ClientProtocolException, IOException {
678            Log.i(LOG_TAG, "Verifying...");
679            String failFiles = null;
680            for(Config.File file : config.mFiles) {
681                if (! verifyFile(file, true) ) {
682                    if (failFiles == null) {
683                        failFiles = file.dest;
684                    } else {
685                        failFiles += " " + file.dest;
686                    }
687                }
688            }
689            if (failFiles != null) {
690                throw new DownloaderException(
691                        "Possible bad SD-Card. MD5 sum incorrect for file(s) "
692                        + failFiles);
693            }
694        }
695
696        private boolean verifyFile(Config.File file, boolean deleteInvalid)
697                throws FileNotFoundException, DownloaderException, IOException {
698            Log.i(LOG_TAG, "verifying " + file.dest);
699            File dest = new File(mDataDir, file.dest);
700            if (! dest.exists()) {
701                Log.e(LOG_TAG, "File does not exist: " + dest.toString());
702                return false;
703            }
704            long fileSize = file.getSize();
705            long destLength = dest.length();
706            if (fileSize != destLength) {
707                Log.e(LOG_TAG, "Length doesn't match. Expected " + fileSize
708                        + " got " + destLength);
709                if (deleteInvalid) {
710                    dest.delete();
711                    return false;
712                }
713            }
714            FileInputStream is = new FileInputStream(dest);
715            try {
716                for(Config.File.Part part : file.mParts) {
717                    if (part.md5 == null) {
718                        continue;
719                    }
720                    MessageDigest digest = createDigest();
721                    readIntoDigest(is, part.size, digest);
722                    String hash = getHash(digest);
723                    if (!hash.equalsIgnoreCase(part.md5)) {
724                        Log.e(LOG_TAG, "MD5 checksums don't match. " +
725                                part.src + " Expected "
726                                + part.md5 + " got " + hash);
727                        if (deleteInvalid) {
728                            quietClose(is);
729                            dest.delete();
730                        }
731                        return false;
732                    }
733                }
734            } finally {
735                quietClose(is);
736            }
737            return true;
738        }
739
740        private void readIntoDigest(FileInputStream is, long bytesToRead,
741                MessageDigest digest) throws IOException {
742            while(bytesToRead > 0) {
743                int chunkSize = (int) Math.min(mFileIOBuffer.length,
744                        bytesToRead);
745                int bytesRead = is.read(mFileIOBuffer, 0, chunkSize);
746                if (bytesRead < 0) {
747                    break;
748                }
749                updateDigest(digest, bytesRead);
750                bytesToRead -= bytesRead;
751            }
752        }
753
754        private MessageDigest createDigest() throws DownloaderException {
755            MessageDigest digest;
756            try {
757                digest = MessageDigest.getInstance("MD5");
758            } catch (NoSuchAlgorithmException e) {
759                throw new DownloaderException("Couldn't create MD5 digest");
760            }
761            return digest;
762        }
763
764        private void updateDigest(MessageDigest digest, int bytesRead) {
765            if (bytesRead == mFileIOBuffer.length) {
766                digest.update(mFileIOBuffer);
767            } else {
768                // Work around an awkward API: Create a
769                // new buffer with just the valid bytes
770                byte[] temp = new byte[bytesRead];
771                System.arraycopy(mFileIOBuffer, 0,
772                        temp, 0, bytesRead);
773                digest.update(temp);
774            }
775        }
776
777        private String getHash(MessageDigest digest) {
778            StringBuilder builder = new StringBuilder();
779            for(byte b : digest.digest()) {
780                builder.append(Integer.toHexString((b >> 4) & 0xf));
781                builder.append(Integer.toHexString(b & 0xf));
782            }
783            return builder.toString();
784        }
785
786
787        /**
788         * Ensure we have sizes for all the items.
789         * @param config
790         * @throws ClientProtocolException
791         * @throws IOException
792         * @throws DownloaderException
793         */
794        private void getSizes(Config config)
795            throws ClientProtocolException, IOException, DownloaderException {
796            for (Config.File file : config.mFiles) {
797                for(Config.File.Part part : file.mParts) {
798                    if (part.size < 0) {
799                        part.size = getSize(part.src);
800                    }
801                }
802            }
803            mTotalExpectedSize = config.getSize();
804        }
805
806        private long getSize(String url) throws ClientProtocolException,
807            IOException {
808            url = normalizeUrl(url);
809            Log.i(LOG_TAG, "Head " + url);
810            HttpHead httpGet = new HttpHead(url);
811            HttpResponse response = mHttpClient.execute(httpGet);
812            if (response.getStatusLine().getStatusCode() != HttpStatus.SC_OK) {
813                throw new IOException("Unexpected Http status code "
814                    + response.getStatusLine().getStatusCode());
815            }
816            Header[] clHeaders = response.getHeaders("Content-Length");
817            if (clHeaders.length > 0) {
818                Header header = clHeaders[0];
819                return Long.parseLong(header.getValue());
820            }
821            return -1;
822        }
823
824        private String normalizeUrl(String url) throws MalformedURLException {
825            return (new URL(new URL(mFileConfigUrl), url)).toString();
826        }
827
828        private InputStream get(String url, long startOffset,
829                long expectedLength)
830            throws ClientProtocolException, IOException {
831            url = normalizeUrl(url);
832            Log.i(LOG_TAG, "Get " + url);
833
834            mHttpGet = new HttpGet(url);
835            int expectedStatusCode = HttpStatus.SC_OK;
836            if (startOffset > 0) {
837                String range = "bytes=" + startOffset + "-";
838                if (expectedLength >= 0) {
839                    range += expectedLength-1;
840                }
841                Log.i(LOG_TAG, "requesting byte range " + range);
842                mHttpGet.addHeader("Range", range);
843                expectedStatusCode = HttpStatus.SC_PARTIAL_CONTENT;
844            }
845            HttpResponse response = mHttpClient.execute(mHttpGet);
846            long bytesToSkip = 0;
847            int statusCode = response.getStatusLine().getStatusCode();
848            if (statusCode != expectedStatusCode) {
849                if ((statusCode == HttpStatus.SC_OK)
850                        && (expectedStatusCode
851                                == HttpStatus.SC_PARTIAL_CONTENT)) {
852                    Log.i(LOG_TAG, "Byte range request ignored");
853                    bytesToSkip = startOffset;
854                } else {
855                    throw new IOException("Unexpected Http status code "
856                            + statusCode + " expected "
857                            + expectedStatusCode);
858                }
859            }
860            HttpEntity entity = response.getEntity();
861            InputStream is = entity.getContent();
862            if (bytesToSkip > 0) {
863                is.skip(bytesToSkip);
864            }
865            return is;
866        }
867
868        private File download(String src, String dest)
869            throws DownloaderException, ClientProtocolException, IOException {
870            File destFile = new File(mDataDir, dest);
871            FileOutputStream os = openOutput(dest, false);
872            try {
873                downloadPart(src, os, 0, -1, null);
874            } finally {
875                os.close();
876            }
877            return destFile;
878        }
879
880        private void downloadPart(String src, FileOutputStream os,
881                long startOffset, long expectedLength, MessageDigest digest)
882            throws ClientProtocolException, IOException, DownloaderException {
883            boolean lengthIsKnown = expectedLength >= 0;
884            if (startOffset < 0) {
885                throw new IllegalArgumentException("Negative startOffset:"
886                        + startOffset);
887            }
888            if (lengthIsKnown && (startOffset > expectedLength)) {
889                throw new IllegalArgumentException(
890                        "startOffset > expectedLength" + startOffset + " "
891                        + expectedLength);
892            }
893            InputStream is = get(src, startOffset, expectedLength);
894            try {
895                long bytesRead = downloadStream(is, os, digest);
896                if (lengthIsKnown) {
897                    long expectedBytesRead = expectedLength - startOffset;
898                    if (expectedBytesRead != bytesRead) {
899                        Log.e(LOG_TAG, "Bad file transfer from server: " + src
900                                + " Expected " + expectedBytesRead
901                                + " Received " + bytesRead);
902                        throw new DownloaderException(
903                                "Incorrect number of bytes received from server");
904                    }
905                }
906            } finally {
907                is.close();
908                mHttpGet = null;
909            }
910        }
911
912        private FileOutputStream openOutput(String dest, boolean append)
913            throws FileNotFoundException, DownloaderException {
914            File destFile = new File(mDataDir, dest);
915            File parent = destFile.getParentFile();
916            if (! parent.exists()) {
917                parent.mkdirs();
918            }
919            if (! parent.exists()) {
920                throw new DownloaderException("Could not create directory "
921                        + parent.toString());
922            }
923            FileOutputStream os = new FileOutputStream(destFile, append);
924            return os;
925        }
926
927        private FileInputStream openInput(String src)
928            throws FileNotFoundException, DownloaderException {
929            File srcFile = new File(mDataDir, src);
930            File parent = srcFile.getParentFile();
931            if (! parent.exists()) {
932                parent.mkdirs();
933            }
934            if (! parent.exists()) {
935                throw new DownloaderException("Could not create directory "
936                        + parent.toString());
937            }
938            return new FileInputStream(srcFile);
939        }
940
941        private long downloadStream(InputStream is, FileOutputStream os,
942                MessageDigest digest)
943                throws DownloaderException, IOException {
944            long totalBytesRead = 0;
945            while(true){
946                if (Thread.interrupted()) {
947                    Log.i(LOG_TAG, "downloader thread interrupted.");
948                    mHttpGet.abort();
949                    throw new DownloaderException("Thread interrupted");
950                }
951                int bytesRead = is.read(mFileIOBuffer);
952                if (bytesRead < 0) {
953                    break;
954                }
955                if (digest != null) {
956                    updateDigest(digest, bytesRead);
957                }
958                totalBytesRead += bytesRead;
959                os.write(mFileIOBuffer, 0, bytesRead);
960                mDownloadedSize += bytesRead;
961                int progress = (int) (Math.min(mTotalExpectedSize,
962                        mDownloadedSize * 10000 /
963                        Math.max(1, mTotalExpectedSize)));
964                if (progress != mReportedProgress) {
965                    mReportedProgress = progress;
966                    reportProgress(progress);
967                }
968            }
969            return totalBytesRead;
970        }
971
972        private AndroidHttpClient mHttpClient;
973        private HttpGet mHttpGet;
974        private String mFileConfigUrl;
975        private String mConfigVersion;
976        private String mDataPath;
977        private File mDataDir;
978        private String mUserAgent;
979        private long mTotalExpectedSize;
980        private long mDownloadedSize;
981        private int mReportedProgress;
982        private final static int CHUNK_SIZE = 32 * 1024;
983        byte[] mFileIOBuffer = new byte[CHUNK_SIZE];
984    }
985
986    private final static String LOG_TAG = "Downloader";
987    private TextView mProgress;
988    private TextView mTimeRemaining;
989    private final DecimalFormat mPercentFormat = new DecimalFormat("0.00 %");
990    private long mStartTime;
991    private Thread mDownloadThread;
992    private boolean mSuppressErrorMessages;
993
994    private final static long MS_PER_SECOND = 1000;
995    private final static long MS_PER_MINUTE = 60 * 1000;
996    private final static long MS_PER_HOUR = 60 * 60 * 1000;
997    private final static long MS_PER_DAY = 24 * 60 * 60 * 1000;
998
999    private final static String LOCAL_CONFIG_FILE = ".downloadConfig";
1000    private final static String LOCAL_CONFIG_FILE_TEMP = ".downloadConfig_temp";
1001    private final static String LOCAL_FILTERED_FILE = ".downloadConfig_filtered";
1002    private final static String EXTRA_CUSTOM_TEXT = "DownloaderActivity_custom_text";
1003    private final static String EXTRA_FILE_CONFIG_URL = "DownloaderActivity_config_url";
1004    private final static String EXTRA_CONFIG_VERSION = "DownloaderActivity_config_version";
1005    private final static String EXTRA_DATA_PATH = "DownloaderActivity_data_path";
1006    private final static String EXTRA_USER_AGENT = "DownloaderActivity_user_agent";
1007
1008    private final static int MSG_DOWNLOAD_SUCCEEDED = 0;
1009    private final static int MSG_DOWNLOAD_FAILED = 1;
1010    private final static int MSG_REPORT_PROGRESS = 2;
1011
1012    private final Handler mHandler = new Handler() {
1013        @Override
1014        public void handleMessage(Message msg) {
1015            switch (msg.what) {
1016            case MSG_DOWNLOAD_SUCCEEDED:
1017                onDownloadSucceeded();
1018                break;
1019            case MSG_DOWNLOAD_FAILED:
1020                onDownloadFailed((String) msg.obj);
1021                break;
1022            case MSG_REPORT_PROGRESS:
1023                onReportProgress(msg.arg1);
1024                break;
1025            default:
1026                throw new IllegalArgumentException("Unknown message id "
1027                        + msg.what);
1028            }
1029        }
1030
1031    };
1032
1033}
1034