Statistics
| Branch: | Tag: | Revision:

fdroidclient / F-Droid / src / org / fdroid / fdroid / net / ApkDownloader.java @ 645f9fc5

History | View | Annotate | Download (10 KB)

1
/*
2
 * Copyright (C) 2010-2012 Ciaran Gultnieks <ciaran@ciarang.com>
3
 * Copyright (C) 2011 Henrik Tunedal <tunedal@gmail.com>
4
 *
5
 * This program is free software; you can redistribute it and/or
6
 * modify it under the terms of the GNU General Public License
7
 * as published by the Free Software Foundation; either version 3
8
 * of the License, or (at your option) any later version.
9
 *
10
 * This program is distributed in the hope that it will be useful,
11
 * but WITHOUT ANY WARRANTY; without even the implied warranty of
12
 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
13
 * GNU General Public License for more details.
14
 *
15
 * You should have received a copy of the GNU General Public License
16
 * along with this program; if not, write to the Free Software
17
 * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston,
18
 * MA 02110-1301, USA.
19
 */
20

    
21
package org.fdroid.fdroid.net;
22

    
23
import android.content.Context;
24
import android.content.Intent;
25
import android.os.Bundle;
26
import android.support.annotation.NonNull;
27
import android.support.v4.content.LocalBroadcastManager;
28
import android.util.Log;
29

    
30
import org.fdroid.fdroid.Hasher;
31
import org.fdroid.fdroid.Preferences;
32
import org.fdroid.fdroid.ProgressListener;
33
import org.fdroid.fdroid.Utils;
34
import org.fdroid.fdroid.compat.FileCompat;
35
import org.fdroid.fdroid.data.Apk;
36
import org.fdroid.fdroid.data.App;
37
import org.fdroid.fdroid.data.SanitizedFile;
38

    
39
import java.io.File;
40
import java.io.IOException;
41
import java.security.NoSuchAlgorithmException;
42

    
43
/**
44
 * Downloads and verifies (against the Apk.hash) the apk file.
45
 * If the file has previously been downloaded, it will make use of that
46
 * instead, without going to the network to download a new one.
47
 */
48
public class ApkDownloader implements AsyncDownloader.Listener {
49

    
50
    private static final String TAG = "ApkDownloader";
51

    
52
    public static final String EVENT_APK_DOWNLOAD_COMPLETE = "apkDownloadComplete";
53
    public static final String EVENT_APK_DOWNLOAD_CANCELLED = "apkDownloadCancelled";
54
    public static final String EVENT_ERROR = "apkDownloadError";
55

    
56
    public static final String ACTION_STATUS = "apkDownloadStatus";
57
    public static final String EXTRA_TYPE = "apkDownloadStatusType";
58
    public static final String EXTRA_URL = "apkDownloadUrl";
59

    
60
    public static final int ERROR_HASH_MISMATCH = 101;
61
    public static final int ERROR_DOWNLOAD_FAILED = 102;
62

    
63
    private static final String EVENT_SOURCE_ID = "sourceId";
64
    private static long downloadIdCounter = 0;
65

    
66
    /**
67
     * Used as a key to pass data through with an error event, explaining the type of event.
68
     */
69
    public static final String EVENT_DATA_ERROR_TYPE = "apkDownloadErrorType";
70

    
71
    @NonNull private final App app;
72
    @NonNull private final Apk curApk;
73
    @NonNull private final Context context;
74
    @NonNull private final String repoAddress;
75
    @NonNull private final SanitizedFile localFile;
76
    @NonNull private final SanitizedFile potentiallyCachedFile;
77

    
78
    private ProgressListener listener;
79
    private AsyncDownloader dlWrapper = null;
80
    private boolean isComplete = false;
81

    
82
    private final long id = ++downloadIdCounter;
83

    
84
    public void setProgressListener(ProgressListener listener) {
85
        this.listener = listener;
86
    }
87

    
88
    public void removeProgressListener() {
89
        setProgressListener(null);
90
    }
91

    
92
    public ApkDownloader(@NonNull final Context context, @NonNull final App app, @NonNull final Apk apk, @NonNull final String repoAddress) {
93
        this.context = context;
94
        this.app = app;
95
        curApk = apk;
96
        this.repoAddress = repoAddress;
97
        localFile = new SanitizedFile(Utils.getApkDownloadDir(context), apk.apkName);
98
        potentiallyCachedFile = new SanitizedFile(Utils.getApkCacheDir(context), apk.apkName);
99
    }
100

    
101
    /**
102
     * The downloaded APK. Valid only when getStatus() has returned STATUS.DONE.
103
     */
104
    public SanitizedFile localFile() {
105
        return localFile;
106
    }
107

    
108
    /**
109
     * When stopping/starting downloaders multiple times (on different threads), it can
110
     * get weird whereby different threads are sending progress events. It is important
111
     * to be able to see which downloader these progress events are coming from.
112
     */
113
    public boolean isEventFromThis(Event event) {
114
        return event.getData().containsKey(EVENT_SOURCE_ID) && event.getData().getLong(EVENT_SOURCE_ID) == id;
115
    }
116

    
117
    private Hasher createHasher(File apkFile) {
118
        Hasher hasher;
119
        try {
120
            hasher = new Hasher(curApk.hashType, apkFile);
121
        } catch (NoSuchAlgorithmException e) {
122
            Log.e(TAG, "Error verifying hash of cached apk at " + apkFile + ". " +
123
                    "I don't understand what the " + curApk.hashType + " hash algorithm is :(");
124
            hasher = null;
125
        }
126
        return hasher;
127
    }
128

    
129
    private boolean hashMatches(@NonNull final File apkFile) {
130
        if (!apkFile.exists()) {
131
            return false;
132
        }
133
        Hasher hasher = createHasher(apkFile);
134
        return hasher != null && hasher.match(curApk.hash);
135
    }
136

    
137
    /**
138
     * If an existing cached version exists, and matches the hash of the apk we
139
     * want to download, then we will return true. Otherwise, we return false
140
     * (and remove the cached file - if it exists and didn't match the correct hash).
141
     */
142
    private boolean verifyOrDelete(@NonNull final File apkFile) {
143
        if (apkFile.exists()) {
144
            if (hashMatches(apkFile)) {
145
                Utils.DebugLog(TAG, "Using cached apk at " + apkFile);
146
                return true;
147
            }
148
            Utils.DebugLog(TAG, "Not using cached apk at " + apkFile + "(hash doesn't match, will delete file)");
149
            delete(apkFile);
150
        }
151
        return false;
152
    }
153

    
154
    private void delete(@NonNull final File file) {
155
        if (file.exists()) {
156
            if (!file.delete()) {
157
                Log.w(TAG, "Could not delete file " + file);
158
            }
159
        }
160
    }
161

    
162
    private void prepareApkFileAndSendCompleteMessage() {
163

    
164
        // Need the apk to be world readable, so that the installer is able to read it.
165
        // Note that saving it into external storage for the purpose of letting the installer
166
        // have access is insecure, because apps with permission to write to the external
167
        // storage can overwrite the app between F-Droid asking for it to be installed and
168
        // the installer actually installing it.
169
        FileCompat.setReadable(localFile, true, false);
170

    
171
        isComplete = true;
172
        sendMessage(EVENT_APK_DOWNLOAD_COMPLETE);
173
    }
174

    
175
    public boolean isComplete() {
176
        return this.isComplete;
177
    }
178

    
179
    /**
180
     * If the download successfully spins up a new thread to start downloading, then we return
181
     * true, otherwise false. This is useful, e.g. when we use a cached version, and so don't
182
     * want to bother with progress dialogs et al.
183
     */
184
    public boolean download() {
185

    
186
        // Can we use the cached version?
187
        if (verifyOrDelete(potentiallyCachedFile)) {
188
            delete(localFile);
189
            Utils.copyQuietly(potentiallyCachedFile, localFile);
190
            prepareApkFileAndSendCompleteMessage();
191
            return false;
192
        }
193

    
194
        String remoteAddress = Utils.getApkUrl(repoAddress, curApk);
195
        Utils.DebugLog(TAG, "Downloading apk from " + remoteAddress + " to " + localFile);
196

    
197
        try {
198
            dlWrapper = DownloaderFactory.createAsync(context, remoteAddress, localFile, app.name + " " + curApk.version, curApk.id, this);
199
            dlWrapper.download();
200
            return true;
201
        } catch (IOException e) {
202
            onErrorDownloading(e.getLocalizedMessage());
203
        }
204

    
205
        return false;
206
    }
207

    
208
    private void sendMessage(String type) {
209
        sendProgressEvent(new ProgressListener.Event(type));
210
    }
211

    
212
    private void sendError(int errorType) {
213
        Bundle data = new Bundle(1);
214
        data.putInt(EVENT_DATA_ERROR_TYPE, errorType);
215
        sendProgressEvent(new Event(EVENT_ERROR, data));
216
    }
217

    
218
    // TODO: Completely remove progress listener, only use broadcasts...
219
    private void sendProgressEvent(Event event) {
220

    
221
        event.getData().putLong(EVENT_SOURCE_ID, id);
222

    
223
        if (listener != null) {
224
            listener.onProgress(event);
225
        }
226

    
227
        Intent intent = new Intent(ACTION_STATUS);
228
        intent.putExtras(event.getData());
229
        intent.putExtra(EXTRA_TYPE, event.type);
230
        intent.putExtra(EXTRA_URL, Utils.getApkUrl(repoAddress, curApk));
231
        LocalBroadcastManager.getInstance(context).sendBroadcast(intent);
232
    }
233

    
234
    @Override
235
    public void onErrorDownloading(String localisedExceptionDetails) {
236
        Log.e(TAG, "Download failed: " + localisedExceptionDetails);
237
        sendError(ERROR_DOWNLOAD_FAILED);
238
        delete(localFile);
239
    }
240

    
241
    private void cacheIfRequired() {
242
        if (Preferences.get().shouldCacheApks()) {
243
            Utils.DebugLog(TAG, "Copying .apk file to cache at " + potentiallyCachedFile.getAbsolutePath());
244
            Utils.copyQuietly(localFile, potentiallyCachedFile);
245
        }
246
    }
247

    
248
    @Override
249
    public void onDownloadComplete() {
250

    
251
        if (!verifyOrDelete(localFile)) {
252
            sendError(ERROR_HASH_MISMATCH);
253
            return;
254
        }
255

    
256
        cacheIfRequired();
257

    
258
        Utils.DebugLog(TAG, "Download finished: " + localFile);
259
        prepareApkFileAndSendCompleteMessage();
260
    }
261

    
262
    @Override
263
    public void onDownloadCancelled() {
264
        sendMessage(EVENT_APK_DOWNLOAD_CANCELLED);
265
    }
266

    
267
    @Override
268
    public void onProgress(Event event) {
269
        sendProgressEvent(event);
270
    }
271

    
272
    /**
273
     * Attempts to cancel the download (if in progress) and also removes the progress
274
     * listener
275
     *
276
     * @param userRequested - true if the user requested the cancel (via button click), otherwise false.
277
     */
278
    public void cancel(boolean userRequested) {
279
        if (dlWrapper != null) {
280
            dlWrapper.attemptCancel(userRequested);
281
        }
282
    }
283

    
284
    public Apk getApk() { return curApk; }
285

    
286
    public int getBytesRead() { return dlWrapper != null ? dlWrapper.getBytesRead() : 0; }
287

    
288
    public int getTotalBytes() { return dlWrapper != null ? dlWrapper.getTotalBytes() : 0; }
289
}