Thu, 22 Jan 2015 13:21:57 +0100
Incorporate requested changes from Mozilla in review:
https://bugzilla.mozilla.org/show_bug.cgi?id=1123480#c6
1 /* -*- Mode: Java; c-basic-offset: 4; tab-width: 20; indent-tabs-mode: nil; -*-
2 * This Source Code Form is subject to the terms of the Mozilla Public
3 * License, v. 2.0. If a copy of the MPL was not distributed with this
4 * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
6 package org.mozilla.gecko.updater;
8 import org.mozilla.gecko.AppConstants;
9 import org.mozilla.gecko.R;
11 import org.mozilla.apache.commons.codec.binary.Hex;
13 import org.w3c.dom.Document;
14 import org.w3c.dom.Node;
15 import org.w3c.dom.NodeList;
17 import android.app.AlarmManager;
18 import android.app.IntentService;
19 import android.app.Notification;
20 import android.app.NotificationManager;
21 import android.app.PendingIntent;
22 import android.app.Service;
23 import android.content.Context;
24 import android.content.Intent;
25 import android.content.SharedPreferences;
26 import android.net.ConnectivityManager;
27 import android.net.NetworkInfo;
28 import android.net.Uri;
29 import android.os.Environment;
30 import android.support.v4.app.NotificationCompat;
31 import android.support.v4.app.NotificationCompat.Builder;
32 import android.util.Log;
34 import java.io.BufferedInputStream;
35 import java.io.BufferedOutputStream;
36 import java.io.File;
37 import java.io.FileInputStream;
38 import java.io.FileOutputStream;
39 import java.io.InputStream;
40 import java.io.OutputStream;
41 import java.net.Proxy;
42 import java.net.ProxySelector;
43 import java.net.URL;
44 import java.net.URLConnection;
45 import java.security.MessageDigest;
46 import java.util.Calendar;
47 import java.util.GregorianCalendar;
48 import java.util.List;
49 import java.util.TimeZone;
51 import javax.xml.parsers.DocumentBuilder;
52 import javax.xml.parsers.DocumentBuilderFactory;
54 public class UpdateService extends IntentService {
55 private static final int BUFSIZE = 8192;
56 private static final int NOTIFICATION_ID = 0x3e40ddbd;
58 private static final String LOGTAG = "UpdateService";
60 private static final int INTERVAL_LONG = 86400000; // in milliseconds
61 private static final int INTERVAL_SHORT = 14400000; // again, in milliseconds
62 private static final int INTERVAL_RETRY = 3600000;
64 private static final String PREFS_NAME = "UpdateService";
65 private static final String KEY_LAST_BUILDID = "UpdateService.lastBuildID";
66 private static final String KEY_LAST_HASH_FUNCTION = "UpdateService.lastHashFunction";
67 private static final String KEY_LAST_HASH_VALUE = "UpdateService.lastHashValue";
68 private static final String KEY_LAST_FILE_NAME = "UpdateService.lastFileName";
69 private static final String KEY_LAST_ATTEMPT_DATE = "UpdateService.lastAttemptDate";
70 private static final String KEY_AUTODOWNLOAD_POLICY = "UpdateService.autoDownloadPolicy";
72 private SharedPreferences mPrefs;
74 private NotificationManager mNotificationManager;
75 private ConnectivityManager mConnectivityManager;
76 private Builder mBuilder;
78 private boolean mDownloading;
79 private boolean mCancelDownload;
80 private boolean mApplyImmediately;
82 public UpdateService() {
83 super("updater");
84 }
86 @Override
87 public void onCreate () {
88 super.onCreate();
90 mPrefs = getSharedPreferences(PREFS_NAME, 0);
91 mNotificationManager = (NotificationManager)getSystemService(Context.NOTIFICATION_SERVICE);
92 mConnectivityManager = (ConnectivityManager)getSystemService(Context.CONNECTIVITY_SERVICE);
93 mCancelDownload = false;
94 }
96 @Override
97 public synchronized int onStartCommand (Intent intent, int flags, int startId) {
98 // If we are busy doing a download, the new Intent here would normally be queued for
99 // execution once that is done. In this case, however, we want to flip the boolean
100 // while that is running, so handle that now.
101 if (mDownloading && UpdateServiceHelper.ACTION_APPLY_UPDATE.equals(intent.getAction())) {
102 Log.i(LOGTAG, "will apply update when download finished");
104 mApplyImmediately = true;
105 showDownloadNotification();
106 } else if (UpdateServiceHelper.ACTION_CANCEL_DOWNLOAD.equals(intent.getAction())) {
107 mCancelDownload = true;
108 } else {
109 super.onStartCommand(intent, flags, startId);
110 }
112 return Service.START_REDELIVER_INTENT;
113 }
115 @Override
116 protected void onHandleIntent (Intent intent) {
117 if (UpdateServiceHelper.ACTION_REGISTER_FOR_UPDATES.equals(intent.getAction())) {
118 int policy = intent.getIntExtra(UpdateServiceHelper.EXTRA_AUTODOWNLOAD_NAME, -1);
119 if (policy >= 0) {
120 setAutoDownloadPolicy(policy);
121 }
123 registerForUpdates(false);
124 } else if (UpdateServiceHelper.ACTION_CHECK_FOR_UPDATE.equals(intent.getAction())) {
125 startUpdate(intent.getIntExtra(UpdateServiceHelper.EXTRA_UPDATE_FLAGS_NAME, 0));
126 // Use this instead for forcing a download from about:fennec
127 // startUpdate(UpdateServiceHelper.FLAG_FORCE_DOWNLOAD | UpdateServiceHelper.FLAG_REINSTALL);
128 } else if (UpdateServiceHelper.ACTION_DOWNLOAD_UPDATE.equals(intent.getAction())) {
129 // We always want to do the download and apply it here
130 mApplyImmediately = true;
131 startUpdate(UpdateServiceHelper.FLAG_FORCE_DOWNLOAD);
132 } else if (UpdateServiceHelper.ACTION_APPLY_UPDATE.equals(intent.getAction())) {
133 applyUpdate(intent.getStringExtra(UpdateServiceHelper.EXTRA_PACKAGE_PATH_NAME));
134 }
135 }
137 private static boolean hasFlag(int flags, int flag) {
138 return (flags & flag) == flag;
139 }
141 private void sendCheckUpdateResult(UpdateServiceHelper.CheckUpdateResult result) {
142 Intent resultIntent = new Intent(UpdateServiceHelper.ACTION_CHECK_UPDATE_RESULT);
143 resultIntent.putExtra("result", result.toString());
144 sendBroadcast(resultIntent);
145 }
147 private int getUpdateInterval(boolean isRetry) {
148 int interval;
149 if (isRetry) {
150 interval = INTERVAL_RETRY;
151 } else if (!AppConstants.RELEASE_BUILD) {
152 interval = INTERVAL_SHORT;
153 } else {
154 interval = INTERVAL_LONG;
155 }
157 return interval;
158 }
160 private void registerForUpdates(boolean isRetry) {
161 Calendar lastAttempt = getLastAttemptDate();
162 Calendar now = new GregorianCalendar(TimeZone.getTimeZone("GMT"));
164 int interval = getUpdateInterval(isRetry);
166 if (lastAttempt == null || (now.getTimeInMillis() - lastAttempt.getTimeInMillis()) > interval) {
167 // We've either never attempted an update, or we are passed the desired
168 // time. Start an update now.
169 Log.i(LOGTAG, "no update has ever been attempted, checking now");
170 startUpdate(0);
171 return;
172 }
174 AlarmManager manager = (AlarmManager) getSystemService(Context.ALARM_SERVICE);
175 if (manager == null)
176 return;
178 PendingIntent pending = PendingIntent.getService(this, 0, new Intent(UpdateServiceHelper.ACTION_CHECK_FOR_UPDATE, null, this, UpdateService.class), PendingIntent.FLAG_UPDATE_CURRENT);
179 manager.cancel(pending);
181 lastAttempt.setTimeInMillis(lastAttempt.getTimeInMillis() + interval);
182 Log.i(LOGTAG, "next update will be at: " + lastAttempt.getTime());
184 manager.set(AlarmManager.RTC_WAKEUP, lastAttempt.getTimeInMillis(), pending);
185 }
187 private void startUpdate(int flags) {
188 setLastAttemptDate();
190 NetworkInfo netInfo = mConnectivityManager.getActiveNetworkInfo();
191 if (netInfo == null || !netInfo.isConnected()) {
192 Log.i(LOGTAG, "not connected to the network");
193 registerForUpdates(true);
194 sendCheckUpdateResult(UpdateServiceHelper.CheckUpdateResult.NOT_AVAILABLE);
195 return;
196 }
198 registerForUpdates(false);
200 UpdateInfo info = findUpdate(hasFlag(flags, UpdateServiceHelper.FLAG_REINSTALL));
201 boolean haveUpdate = (info != null);
203 if (!haveUpdate) {
204 Log.i(LOGTAG, "no update available");
205 sendCheckUpdateResult(UpdateServiceHelper.CheckUpdateResult.NOT_AVAILABLE);
206 return;
207 }
209 Log.i(LOGTAG, "update available, buildID = " + info.buildID);
211 int connectionType = netInfo.getType();
212 int autoDownloadPolicy = getAutoDownloadPolicy();
215 /**
216 * We only start a download automatically if one of following criteria are met:
217 *
218 * - We have a FORCE_DOWNLOAD flag passed in
219 * - The preference is set to 'always'
220 * - The preference is set to 'wifi' and we are actually using wifi (or regular ethernet)
221 */
222 boolean shouldStartDownload = hasFlag(flags, UpdateServiceHelper.FLAG_FORCE_DOWNLOAD) ||
223 autoDownloadPolicy == UpdateServiceHelper.AUTODOWNLOAD_ENABLED ||
224 (autoDownloadPolicy == UpdateServiceHelper.AUTODOWNLOAD_WIFI &&
225 (connectionType == ConnectivityManager.TYPE_WIFI || connectionType == ConnectivityManager.TYPE_ETHERNET));
227 if (!shouldStartDownload) {
228 Log.i(LOGTAG, "not initiating automatic update download due to policy " + autoDownloadPolicy);
229 sendCheckUpdateResult(UpdateServiceHelper.CheckUpdateResult.AVAILABLE);
231 // We aren't autodownloading here, so prompt to start the update
232 Notification notification = new Notification(R.drawable.ic_status_logo, null, System.currentTimeMillis());
234 Intent notificationIntent = new Intent(UpdateServiceHelper.ACTION_DOWNLOAD_UPDATE);
235 notificationIntent.setClass(this, UpdateService.class);
237 PendingIntent contentIntent = PendingIntent.getService(this, 0, notificationIntent, PendingIntent.FLAG_UPDATE_CURRENT);
238 notification.flags = Notification.FLAG_AUTO_CANCEL;
240 notification.setLatestEventInfo(this, getResources().getString(R.string.updater_start_title),
241 getResources().getString(R.string.updater_start_select),
242 contentIntent);
244 mNotificationManager.notify(NOTIFICATION_ID, notification);
246 return;
247 }
249 File pkg = downloadUpdatePackage(info, hasFlag(flags, UpdateServiceHelper.FLAG_OVERWRITE_EXISTING));
250 if (pkg == null) {
251 sendCheckUpdateResult(UpdateServiceHelper.CheckUpdateResult.NOT_AVAILABLE);
252 return;
253 }
255 Log.i(LOGTAG, "have update package at " + pkg);
257 saveUpdateInfo(info, pkg);
258 sendCheckUpdateResult(UpdateServiceHelper.CheckUpdateResult.DOWNLOADED);
260 if (mApplyImmediately) {
261 applyUpdate(pkg);
262 } else {
263 // Prompt to apply the update
264 Notification notification = new Notification(R.drawable.ic_status_logo, null, System.currentTimeMillis());
266 Intent notificationIntent = new Intent(UpdateServiceHelper.ACTION_APPLY_UPDATE);
267 notificationIntent.setClass(this, UpdateService.class);
268 notificationIntent.putExtra(UpdateServiceHelper.EXTRA_PACKAGE_PATH_NAME, pkg.getAbsolutePath());
270 PendingIntent contentIntent = PendingIntent.getService(this, 0, notificationIntent, PendingIntent.FLAG_UPDATE_CURRENT);
271 notification.flags = Notification.FLAG_AUTO_CANCEL;
273 notification.setLatestEventInfo(this, getResources().getString(R.string.updater_apply_title),
274 getResources().getString(R.string.updater_apply_select),
275 contentIntent);
277 mNotificationManager.notify(NOTIFICATION_ID, notification);
278 }
279 }
281 private URLConnection openConnectionWithProxy(URL url) throws java.net.URISyntaxException, java.io.IOException {
282 Log.i(LOGTAG, "opening connection with url: " + url);
284 ProxySelector ps = ProxySelector.getDefault();
285 Proxy proxy = Proxy.NO_PROXY;
286 if (ps != null) {
287 List<Proxy> proxies = ps.select(url.toURI());
288 if (proxies != null && !proxies.isEmpty()) {
289 proxy = proxies.get(0);
290 }
291 }
293 return url.openConnection(proxy);
294 }
296 private UpdateInfo findUpdate(boolean force) {
297 try {
298 URL url = UpdateServiceHelper.getUpdateUrl(this, force);
300 DocumentBuilder builder = DocumentBuilderFactory.newInstance().newDocumentBuilder();
301 Document dom = builder.parse(openConnectionWithProxy(url).getInputStream());
303 NodeList nodes = dom.getElementsByTagName("update");
304 if (nodes == null || nodes.getLength() == 0)
305 return null;
307 Node updateNode = nodes.item(0);
308 Node buildIdNode = updateNode.getAttributes().getNamedItem("buildID");
309 if (buildIdNode == null)
310 return null;
312 nodes = dom.getElementsByTagName("patch");
313 if (nodes == null || nodes.getLength() == 0)
314 return null;
316 Node patchNode = nodes.item(0);
317 Node urlNode = patchNode.getAttributes().getNamedItem("URL");
318 Node hashFunctionNode = patchNode.getAttributes().getNamedItem("hashFunction");
319 Node hashValueNode = patchNode.getAttributes().getNamedItem("hashValue");
320 Node sizeNode = patchNode.getAttributes().getNamedItem("size");
322 if (urlNode == null || hashFunctionNode == null ||
323 hashValueNode == null || sizeNode == null) {
324 return null;
325 }
327 // Fill in UpdateInfo from the XML data
328 UpdateInfo info = new UpdateInfo();
329 info.url = new URL(urlNode.getTextContent());
330 info.buildID = buildIdNode.getTextContent();
331 info.hashFunction = hashFunctionNode.getTextContent();
332 info.hashValue = hashValueNode.getTextContent();
334 try {
335 info.size = Integer.parseInt(sizeNode.getTextContent());
336 } catch (NumberFormatException e) {
337 Log.e(LOGTAG, "Failed to find APK size: ", e);
338 return null;
339 }
341 // Make sure we have all the stuff we need to apply the update
342 if (!info.isValid()) {
343 Log.e(LOGTAG, "missing some required update information, have: " + info);
344 return null;
345 }
347 return info;
348 } catch (Exception e) {
349 Log.e(LOGTAG, "failed to check for update: ", e);
350 return null;
351 }
352 }
354 private MessageDigest createMessageDigest(String hashFunction) {
355 String javaHashFunction = null;
357 if ("sha512".equalsIgnoreCase(hashFunction)) {
358 javaHashFunction = "SHA-512";
359 } else {
360 Log.e(LOGTAG, "Unhandled hash function: " + hashFunction);
361 return null;
362 }
364 try {
365 return MessageDigest.getInstance(javaHashFunction);
366 } catch (java.security.NoSuchAlgorithmException e) {
367 Log.e(LOGTAG, "Couldn't find algorithm " + javaHashFunction, e);
368 return null;
369 }
370 }
372 private void showDownloadNotification() {
373 showDownloadNotification(null);
374 }
376 private void showDownloadNotification(File downloadFile) {
378 Intent notificationIntent = new Intent(UpdateServiceHelper.ACTION_APPLY_UPDATE);
379 notificationIntent.setClass(this, UpdateService.class);
381 Intent cancelIntent = new Intent(UpdateServiceHelper.ACTION_CANCEL_DOWNLOAD);
382 cancelIntent.setClass(this, UpdateService.class);
384 if (downloadFile != null)
385 notificationIntent.putExtra(UpdateServiceHelper.EXTRA_PACKAGE_PATH_NAME, downloadFile.getAbsolutePath());
387 PendingIntent contentIntent = PendingIntent.getService(this, 0, notificationIntent, PendingIntent.FLAG_UPDATE_CURRENT);
388 PendingIntent deleteIntent = PendingIntent.getService(this, 0, cancelIntent, PendingIntent.FLAG_CANCEL_CURRENT);
390 mBuilder = new NotificationCompat.Builder(this);
391 mBuilder.setContentTitle(getResources().getString(R.string.updater_downloading_title))
392 .setContentText(mApplyImmediately ? "" : getResources().getString(R.string.updater_downloading_select))
393 .setSmallIcon(android.R.drawable.stat_sys_download)
394 .setContentIntent(contentIntent)
395 .setDeleteIntent(deleteIntent);
397 mBuilder.setProgress(100, 0, true);
398 mNotificationManager.notify(NOTIFICATION_ID, mBuilder.build());
399 }
401 private void showDownloadFailure() {
402 Notification notification = new Notification(R.drawable.ic_status_logo, null, System.currentTimeMillis());
404 Intent notificationIntent = new Intent(UpdateServiceHelper.ACTION_CHECK_FOR_UPDATE);
405 notificationIntent.setClass(this, UpdateService.class);
407 PendingIntent contentIntent = PendingIntent.getService(this, 0, notificationIntent, PendingIntent.FLAG_UPDATE_CURRENT);
409 notification.setLatestEventInfo(this, getResources().getString(R.string.updater_downloading_title_failed),
410 getResources().getString(R.string.updater_downloading_retry),
411 contentIntent);
413 mNotificationManager.notify(NOTIFICATION_ID, notification);
414 }
416 private File downloadUpdatePackage(UpdateInfo info, boolean overwriteExisting) {
417 File downloadFile = new File(Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS), new File(info.url.getFile()).getName());
419 if (!overwriteExisting && info.buildID.equals(getLastBuildID()) && downloadFile.exists()) {
420 // The last saved buildID is the same as the one for the current update. We also have a file
421 // already downloaded, so it's probably the package we want. Verify it to be sure and just
422 // return that if it matches.
424 if (verifyDownloadedPackage(downloadFile)) {
425 Log.i(LOGTAG, "using existing update package");
426 return downloadFile;
427 } else {
428 // Didn't match, so we're going to download a new one.
429 downloadFile.delete();
430 }
431 }
433 Log.i(LOGTAG, "downloading update package");
434 sendCheckUpdateResult(UpdateServiceHelper.CheckUpdateResult.DOWNLOADING);
436 OutputStream output = null;
437 InputStream input = null;
439 mDownloading = true;
440 mCancelDownload = false;
441 showDownloadNotification(downloadFile);
443 try {
444 URLConnection conn = openConnectionWithProxy(info.url);
445 int length = conn.getContentLength();
447 output = new BufferedOutputStream(new FileOutputStream(downloadFile));
448 input = new BufferedInputStream(conn.getInputStream());
450 byte[] buf = new byte[BUFSIZE];
451 int len = 0;
453 int bytesRead = 0;
454 int lastNotify = 0;
456 while ((len = input.read(buf, 0, BUFSIZE)) > 0 && !mCancelDownload) {
457 output.write(buf, 0, len);
458 bytesRead += len;
459 // Updating the notification takes time so only do it every 1MB
460 if(bytesRead - lastNotify > 1048576) {
461 mBuilder.setProgress(length, bytesRead, false);
462 mNotificationManager.notify(NOTIFICATION_ID, mBuilder.build());
463 lastNotify = bytesRead;
464 }
465 }
467 mNotificationManager.cancel(NOTIFICATION_ID);
469 // if the download was canceled by the user
470 // delete the update package
471 if (mCancelDownload) {
472 Log.i(LOGTAG, "download canceled by user!");
473 downloadFile.delete();
475 return null;
476 } else {
477 Log.i(LOGTAG, "completed update download!");
478 return downloadFile;
479 }
480 } catch (Exception e) {
481 downloadFile.delete();
482 showDownloadFailure();
484 Log.e(LOGTAG, "failed to download update: ", e);
485 return null;
486 } finally {
487 try {
488 if (input != null)
489 input.close();
490 } catch (java.io.IOException e) {}
492 try {
493 if (output != null)
494 output.close();
495 } catch (java.io.IOException e) {}
497 mDownloading = false;
498 }
499 }
501 private boolean verifyDownloadedPackage(File updateFile) {
502 MessageDigest digest = createMessageDigest(getLastHashFunction());
503 if (digest == null)
504 return false;
506 InputStream input = null;
508 try {
509 input = new BufferedInputStream(new FileInputStream(updateFile));
511 byte[] buf = new byte[BUFSIZE];
512 int len;
513 while ((len = input.read(buf, 0, BUFSIZE)) > 0) {
514 digest.update(buf, 0, len);
515 }
516 } catch (java.io.IOException e) {
517 Log.e(LOGTAG, "Failed to verify update package: ", e);
518 return false;
519 } finally {
520 try {
521 if (input != null)
522 input.close();
523 } catch(java.io.IOException e) {}
524 }
526 String hex = Hex.encodeHexString(digest.digest());
527 if (!hex.equals(getLastHashValue())) {
528 Log.e(LOGTAG, "Package hash does not match");
529 return false;
530 }
532 return true;
533 }
535 private void applyUpdate(String updatePath) {
536 if (updatePath == null) {
537 updatePath = mPrefs.getString(KEY_LAST_FILE_NAME, null);
538 }
539 applyUpdate(new File(updatePath));
540 }
542 private void applyUpdate(File updateFile) {
543 mApplyImmediately = false;
545 if (!updateFile.exists())
546 return;
548 Log.i(LOGTAG, "Verifying package: " + updateFile);
550 if (!verifyDownloadedPackage(updateFile)) {
551 Log.e(LOGTAG, "Not installing update, failed verification");
552 return;
553 }
555 Intent intent = new Intent(Intent.ACTION_VIEW);
556 intent.setDataAndType(Uri.fromFile(updateFile), "application/vnd.android.package-archive");
557 intent.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
558 startActivity(intent);
559 }
561 private String getLastBuildID() {
562 return mPrefs.getString(KEY_LAST_BUILDID, null);
563 }
565 private String getLastHashFunction() {
566 return mPrefs.getString(KEY_LAST_HASH_FUNCTION, null);
567 }
569 private String getLastHashValue() {
570 return mPrefs.getString(KEY_LAST_HASH_VALUE, null);
571 }
573 private Calendar getLastAttemptDate() {
574 long lastAttempt = mPrefs.getLong(KEY_LAST_ATTEMPT_DATE, -1);
575 if (lastAttempt < 0)
576 return null;
578 GregorianCalendar cal = new GregorianCalendar(TimeZone.getTimeZone("GMT"));
579 cal.setTimeInMillis(lastAttempt);
580 return cal;
581 }
583 private void setLastAttemptDate() {
584 SharedPreferences.Editor editor = mPrefs.edit();
585 editor.putLong(KEY_LAST_ATTEMPT_DATE, System.currentTimeMillis());
586 editor.commit();
587 }
589 private int getAutoDownloadPolicy() {
590 return mPrefs.getInt(KEY_AUTODOWNLOAD_POLICY, UpdateServiceHelper.AUTODOWNLOAD_WIFI);
591 }
593 private void setAutoDownloadPolicy(int policy) {
594 SharedPreferences.Editor editor = mPrefs.edit();
595 editor.putInt(KEY_AUTODOWNLOAD_POLICY, policy);
596 editor.commit();
597 }
599 private void saveUpdateInfo(UpdateInfo info, File downloaded) {
600 SharedPreferences.Editor editor = mPrefs.edit();
601 editor.putString(KEY_LAST_BUILDID, info.buildID);
602 editor.putString(KEY_LAST_HASH_FUNCTION, info.hashFunction);
603 editor.putString(KEY_LAST_HASH_VALUE, info.hashValue);
604 editor.putString(KEY_LAST_FILE_NAME, downloaded.toString());
605 editor.commit();
606 }
608 private class UpdateInfo {
609 public URL url;
610 public String buildID;
611 public String hashFunction;
612 public String hashValue;
613 public int size;
615 private boolean isNonEmpty(String s) {
616 return s != null && s.length() > 0;
617 }
619 public boolean isValid() {
620 return url != null && isNonEmpty(buildID) &&
621 isNonEmpty(hashFunction) && isNonEmpty(hashValue) && size > 0;
622 }
624 @Override
625 public String toString() {
626 return "url = " + url + ", buildID = " + buildID + ", hashFunction = " + hashFunction + ", hashValue = " + hashValue + ", size = " + size;
627 }
628 }
629 }