Statistics
| Branch: | Tag: | Revision:

fdroidclient / F-Droid / src / org / fdroid / fdroid / net / ApkDownloader.java @ 69ecaf02

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.os.Build;
25
import android.content.Intent;
26
import android.os.Bundle;
27
import android.support.annotation.NonNull;
28
import android.support.v4.content.LocalBroadcastManager;
29
import android.util.Log;
30

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

    
40
import java.io.File;
41
import java.io.IOException;
42
import java.net.URL;
43
import java.security.NoSuchAlgorithmException;
44

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

    
52
    private static final String TAG = "ApkDownloader";
53

    
54
    public static final String EVENT_APK_DOWNLOAD_COMPLETE = "apkDownloadComplete";
55
    public static final String EVENT_APK_DOWNLOAD_CANCELLED = "apkDownloadCancelled";
56
    public static final String EVENT_ERROR = "apkDownloadError";
57

    
58
    public static final String ACTION_STATUS = "apkDownloadStatus";
59
    public static final String EXTRA_TYPE = "apkDownloadStatusType";
60
    public static final String EXTRA_URL = "apkDownloadUrl";
61

    
62
    public static final int ERROR_HASH_MISMATCH = 101;
63
    public static final int ERROR_DOWNLOAD_FAILED = 102;
64

    
65
    private static final String EVENT_SOURCE_ID = "sourceId";
66
    private static long downloadIdCounter = 0;
67

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

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

    
80
    private ProgressListener listener;
81
    private AsyncDownloader dlWrapper = null;
82
    private boolean isComplete = false;
83

    
84
    private final long id = ++downloadIdCounter;
85

    
86
    public void setProgressListener(ProgressListener listener) {
87
        this.listener = listener;
88
    }
89

    
90
    public void removeProgressListener() {
91
        setProgressListener(null);
92
    }
93

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

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

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

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

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

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

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

    
164
    private void prepareApkFileAndSendCompleteMessage() {
165

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

    
173
        isComplete = true;
174
        sendMessage(EVENT_APK_DOWNLOAD_COMPLETE);
175
    }
176

    
177
    public boolean isComplete() {
178
        return this.isComplete;
179
    }
180

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

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

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

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

    
207
        return false;
208
    }
209

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

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

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

    
223
        event.getData().putLong(EVENT_SOURCE_ID, id);
224

    
225
        if (listener != null) {
226
            listener.onProgress(event);
227
        }
228

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

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

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

    
250
    @Override
251
    public void onDownloadComplete() {
252

    
253
        if (!verifyOrDelete(localFile)) {
254
            sendError(ERROR_HASH_MISMATCH);
255
            return;
256
        }
257

    
258
        cacheIfRequired();
259

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

    
264
    @Override
265
    public void onDownloadCancelled() {
266
        sendMessage(EVENT_APK_DOWNLOAD_CANCELLED);
267
    }
268

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

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

    
286
    public Apk getApk() { return curApk; }
287

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

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