summaryrefslogtreecommitdiffstats
path: root/mobile/android/base/java/org/mozilla/gecko/dlc/DownloadAction.java
blob: 8618d4699cefc300375513f88b0fc355349778f9 (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
/* -*- Mode: Java; c-basic-offset: 4; tab-width: 20; indent-tabs-mode: nil; -*-
 * This Source Code Form is subject to the terms of the Mozilla Public
 * License, v. 2.0. If a copy of the MPL was not distributed with this
 * file, You can obtain one at http://mozilla.org/MPL/2.0/. */

package org.mozilla.gecko.dlc;

import android.content.Context;
import android.net.ConnectivityManager;
import android.net.NetworkInfo;
import android.support.v4.net.ConnectivityManagerCompat;
import android.util.Log;

import org.mozilla.gecko.AppConstants;
import org.mozilla.gecko.dlc.catalog.DownloadContent;
import org.mozilla.gecko.dlc.catalog.DownloadContentCatalog;
import org.mozilla.gecko.util.HardwareUtils;
import org.mozilla.gecko.util.IOUtils;

import java.io.BufferedInputStream;
import java.io.BufferedOutputStream;
import java.io.File;
import java.io.FileInputStream;
import java.io.FileNotFoundException;
import java.io.FileOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.net.HttpURLConnection;
import java.net.MalformedURLException;
import java.net.URL;
import java.util.zip.GZIPInputStream;

/**
 * Download content that has been scheduled during "study" or "verify".
 */
public class DownloadAction extends BaseAction {
    private static final String LOGTAG = "DLCDownloadAction";

    private static final String CACHE_DIRECTORY = "downloadContent";

    private static final String CDN_BASE_URL = "https://fennec-catalog.cdn.mozilla.net/";

    public interface Callback {
        void onContentDownloaded(DownloadContent content);
    }

    private Callback callback;

    public DownloadAction(Callback callback) {
        this.callback = callback;
    }

    public void perform(Context context, DownloadContentCatalog catalog) {
        Log.d(LOGTAG, "Downloading content..");

        if (!isConnectedToNetwork(context)) {
            Log.d(LOGTAG, "No connected network available. Postponing download.");
            // TODO: Reschedule download (bug 1209498)
            return;
        }

        if (isActiveNetworkMetered(context)) {
            Log.d(LOGTAG, "Network is metered. Postponing download.");
            // TODO: Reschedule download (bug 1209498)
            return;
        }

        for (DownloadContent content : catalog.getScheduledDownloads()) {
            Log.d(LOGTAG, "Downloading: " + content);

            File temporaryFile = null;

            try {
                File destinationFile = getDestinationFile(context, content);
                if (destinationFile.exists() && verify(destinationFile, content.getChecksum())) {
                    Log.d(LOGTAG, "Content already exists and is up-to-date.");
                    catalog.markAsDownloaded(content);
                    continue;
                }

                temporaryFile = createTemporaryFile(context, content);

                if (!hasEnoughDiskSpace(content, destinationFile, temporaryFile)) {
                    Log.d(LOGTAG, "Not enough disk space to save content. Skipping download.");
                    continue;
                }

                // TODO: Check space on disk before downloading content (bug 1220145)
                final String url = createDownloadURL(content);

                if (!temporaryFile.exists() || temporaryFile.length() < content.getSize()) {
                    download(url, temporaryFile);
                }

                if (!verify(temporaryFile, content.getDownloadChecksum())) {
                    Log.w(LOGTAG, "Wrong checksum after download, content=" + content.getId());
                    temporaryFile.delete();
                    continue;
                }

                if (!content.isAssetArchive()) {
                    Log.e(LOGTAG, "Downloaded content is not of type 'asset-archive': " + content.getType());
                    temporaryFile.delete();
                    continue;
                }

                extract(temporaryFile, destinationFile, content.getChecksum());

                catalog.markAsDownloaded(content);

                Log.d(LOGTAG, "Successfully downloaded: " + content);

                if (callback != null) {
                    callback.onContentDownloaded(content);
                }

                if (temporaryFile != null && temporaryFile.exists()) {
                    temporaryFile.delete();
                }
            } catch (RecoverableDownloadContentException e) {
                Log.w(LOGTAG, "Downloading content failed (Recoverable): " + content, e);

                if (e.shouldBeCountedAsFailure()) {
                    catalog.rememberFailure(content, e.getErrorType());
                }

                // TODO: Reschedule download (bug 1209498)
            } catch (UnrecoverableDownloadContentException e) {
                Log.w(LOGTAG, "Downloading content failed (Unrecoverable): " + content, e);

                catalog.markAsPermanentlyFailed(content);

                if (temporaryFile != null && temporaryFile.exists()) {
                    temporaryFile.delete();
                }
            }
        }

        Log.v(LOGTAG, "Done");
    }

    protected void download(String source, File temporaryFile)
            throws RecoverableDownloadContentException, UnrecoverableDownloadContentException {
        InputStream inputStream = null;
        OutputStream outputStream = null;

        HttpURLConnection connection = null;

        try {
            connection = buildHttpURLConnection(source);

            final long offset = temporaryFile.exists() ? temporaryFile.length() : 0;
            if (offset > 0) {
                connection.setRequestProperty("Range", "bytes=" + offset + "-");
            }

            final int status = connection.getResponseCode();
            if (status != HttpURLConnection.HTTP_OK && status != HttpURLConnection.HTTP_PARTIAL) {
                // We are trying to be smart and only retry if this is an error that might resolve in the future.
                // TODO: This is guesstimating at best. We want to implement failure counters (Bug 1215106).
                if (status >= 500) {
                    // Recoverable: Server errors 5xx
                    throw new RecoverableDownloadContentException(RecoverableDownloadContentException.SERVER,
                                                                  "(Recoverable) Download failed. Status code: " + status);
                } else if (status >= 400) {
                    // Unrecoverable: Client errors 4xx - Unlikely that this version of the client will ever succeed.
                    throw new UnrecoverableDownloadContentException("(Unrecoverable) Download failed. Status code: " + status);
                } else {
                    // HttpsUrlConnection: -1 (No valid response code)
                    // Informational 1xx: They have no meaning to us.
                    // Successful 2xx: We don't know how to handle anything but 200.
                    // Redirection 3xx: HttpClient should have followed redirects if possible. We should not see those errors here.
                    throw new UnrecoverableDownloadContentException("(Unrecoverable) Download failed. Status code: " + status);
                }
            }

            inputStream = new BufferedInputStream(connection.getInputStream());
            outputStream = openFile(temporaryFile, status == HttpURLConnection.HTTP_PARTIAL);

            IOUtils.copy(inputStream, outputStream);

            inputStream.close();
            outputStream.close();
        } catch (IOException e) {
            // Recoverable: Just I/O discontinuation
            throw new RecoverableDownloadContentException(RecoverableDownloadContentException.NETWORK, e);
        } finally {
            IOUtils.safeStreamClose(inputStream);
            IOUtils.safeStreamClose(outputStream);

            if (connection != null) {
                connection.disconnect();
            }
        }
    }

    protected OutputStream openFile(File file, boolean append) throws FileNotFoundException {
        return new BufferedOutputStream(new FileOutputStream(file, append));
    }

    protected void extract(File sourceFile, File destinationFile, String checksum)
            throws UnrecoverableDownloadContentException, RecoverableDownloadContentException {
        InputStream inputStream = null;
        OutputStream outputStream = null;
        File temporaryFile = null;

        try {
            File destinationDirectory = destinationFile.getParentFile();
            if (!destinationDirectory.exists() && !destinationDirectory.mkdirs()) {
                throw new IOException("Destination directory does not exist and cannot be created");
            }

            temporaryFile = new File(destinationDirectory, destinationFile.getName() + ".tmp");

            inputStream = new GZIPInputStream(new BufferedInputStream(new FileInputStream(sourceFile)));
            outputStream = new BufferedOutputStream(new FileOutputStream(temporaryFile));

            IOUtils.copy(inputStream, outputStream);

            inputStream.close();
            outputStream.close();

            if (!verify(temporaryFile, checksum)) {
                Log.w(LOGTAG, "Checksum of extracted file does not match.");
                return;
            }

            move(temporaryFile, destinationFile);
        } catch (IOException e) {
            // We could not extract to the destination: Keep temporary file and try again next time we run.
            throw new RecoverableDownloadContentException(RecoverableDownloadContentException.DISK_IO, e);
        } finally {
            IOUtils.safeStreamClose(inputStream);
            IOUtils.safeStreamClose(outputStream);

            if (temporaryFile != null && temporaryFile.exists()) {
                temporaryFile.delete();
            }
        }
    }

    protected boolean isConnectedToNetwork(Context context) {
        ConnectivityManager manager = (ConnectivityManager) context.getSystemService(Context.CONNECTIVITY_SERVICE);
        NetworkInfo networkInfo = manager.getActiveNetworkInfo();

        return networkInfo != null && networkInfo.isConnected();
    }

    protected boolean isActiveNetworkMetered(Context context) {
        return ConnectivityManagerCompat.isActiveNetworkMetered(
                (ConnectivityManager) context.getSystemService(Context.CONNECTIVITY_SERVICE));
    }

    protected String createDownloadURL(DownloadContent content) {
        final String location = content.getLocation();

        return CDN_BASE_URL + content.getLocation();
    }

    protected File createTemporaryFile(Context context, DownloadContent content)
            throws RecoverableDownloadContentException {
        File cacheDirectory = new File(context.getCacheDir(), CACHE_DIRECTORY);

        if (!cacheDirectory.exists() && !cacheDirectory.mkdirs()) {
            // Recoverable: File system might not be mounted NOW and we didn't download anything yet anyways.
            throw new RecoverableDownloadContentException(RecoverableDownloadContentException.DISK_IO,
                                                          "Could not create cache directory: " + cacheDirectory);
        }

        return new File(cacheDirectory, content.getDownloadChecksum() + "-" + content.getId());
    }

    protected void move(File temporaryFile, File destinationFile)
            throws RecoverableDownloadContentException, UnrecoverableDownloadContentException {
        if (!temporaryFile.renameTo(destinationFile)) {
            Log.d(LOGTAG, "Could not move temporary file to destination. Trying to copy..");
            copy(temporaryFile, destinationFile);
            temporaryFile.delete();
        }
    }

    protected void copy(File temporaryFile, File destinationFile)
            throws RecoverableDownloadContentException, UnrecoverableDownloadContentException {
        InputStream inputStream = null;
        OutputStream outputStream = null;

        try {
            File destinationDirectory = destinationFile.getParentFile();
            if (!destinationDirectory.exists() && !destinationDirectory.mkdirs()) {
                throw new IOException("Destination directory does not exist and cannot be created");
            }

            inputStream = new BufferedInputStream(new FileInputStream(temporaryFile));
            outputStream = new BufferedOutputStream(new FileOutputStream(destinationFile));

            IOUtils.copy(inputStream, outputStream);

            inputStream.close();
            outputStream.close();
        } catch (IOException e) {
            // We could not copy the temporary file to its destination: Keep the temporary file and
            // try again the next time we run.
            throw new RecoverableDownloadContentException(RecoverableDownloadContentException.DISK_IO, e);
        } finally {
            IOUtils.safeStreamClose(inputStream);
            IOUtils.safeStreamClose(outputStream);
        }
    }

    protected boolean hasEnoughDiskSpace(DownloadContent content, File destinationFile, File temporaryFile) {
        final File temporaryDirectory = temporaryFile.getParentFile();
        if (temporaryDirectory.getUsableSpace() < content.getSize()) {
            return false;
        }

        final File destinationDirectory = destinationFile.getParentFile();
        // We need some more space to extract the file (getSize() returns the uncompressed size)
        if (destinationDirectory.getUsableSpace() < content.getSize() * 2) {
            return false;
        }

        return true;
    }
}