mobile/android/base/background/healthreport/upload/AndroidSubmissionClient.java

Wed, 31 Dec 2014 07:22:50 +0100

author
Michael Schloh von Bennewitz <michael@schloh.com>
date
Wed, 31 Dec 2014 07:22:50 +0100
branch
TOR_BUG_3246
changeset 4
fc2d59ddac77
permissions
-rw-r--r--

Correct previous dual key logic pending first delivery installment.

michael@0 1 /* This Source Code Form is subject to the terms of the Mozilla Public
michael@0 2 * License, v. 2.0. If a copy of the MPL was not distributed with this
michael@0 3 * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
michael@0 4
michael@0 5 package org.mozilla.gecko.background.healthreport.upload;
michael@0 6
michael@0 7 import java.io.IOException;
michael@0 8 import java.io.UnsupportedEncodingException;
michael@0 9 import java.net.URISyntaxException;
michael@0 10 import java.util.ArrayList;
michael@0 11 import java.util.Collection;
michael@0 12
michael@0 13 import org.json.JSONException;
michael@0 14 import org.json.JSONObject;
michael@0 15 import org.mozilla.gecko.BrowserLocaleManager;
michael@0 16 import org.mozilla.gecko.background.bagheera.BagheeraClient;
michael@0 17 import org.mozilla.gecko.background.bagheera.BagheeraRequestDelegate;
michael@0 18 import org.mozilla.gecko.background.common.GlobalConstants;
michael@0 19 import org.mozilla.gecko.background.common.log.Logger;
michael@0 20 import org.mozilla.gecko.background.healthreport.Environment;
michael@0 21 import org.mozilla.gecko.background.healthreport.EnvironmentBuilder;
michael@0 22 import org.mozilla.gecko.background.healthreport.HealthReportConstants;
michael@0 23 import org.mozilla.gecko.background.healthreport.HealthReportDatabaseStorage;
michael@0 24 import org.mozilla.gecko.background.healthreport.HealthReportGenerator;
michael@0 25 import org.mozilla.gecko.background.healthreport.HealthReportStorage;
michael@0 26 import org.mozilla.gecko.background.healthreport.HealthReportStorage.Field;
michael@0 27 import org.mozilla.gecko.background.healthreport.HealthReportStorage.MeasurementFields;
michael@0 28 import org.mozilla.gecko.background.healthreport.ProfileInformationCache;
michael@0 29 import org.mozilla.gecko.sync.net.BaseResource;
michael@0 30
michael@0 31 import android.content.ContentProviderClient;
michael@0 32 import android.content.Context;
michael@0 33 import android.content.SharedPreferences;
michael@0 34 import ch.boye.httpclientandroidlib.HttpResponse;
michael@0 35
michael@0 36 public class AndroidSubmissionClient implements SubmissionClient {
michael@0 37 protected static final String LOG_TAG = AndroidSubmissionClient.class.getSimpleName();
michael@0 38
michael@0 39 private static final String MEASUREMENT_NAME_SUBMISSIONS = "org.mozilla.healthreport.submissions";
michael@0 40 private static final int MEASUREMENT_VERSION_SUBMISSIONS = 1;
michael@0 41
michael@0 42 protected final Context context;
michael@0 43 protected final SharedPreferences sharedPreferences;
michael@0 44 protected final String profilePath;
michael@0 45
michael@0 46 public AndroidSubmissionClient(Context context, SharedPreferences sharedPreferences, String profilePath) {
michael@0 47 this.context = context;
michael@0 48 this.sharedPreferences = sharedPreferences;
michael@0 49 this.profilePath = profilePath;
michael@0 50 }
michael@0 51
michael@0 52 public SharedPreferences getSharedPreferences() {
michael@0 53 return sharedPreferences;
michael@0 54 }
michael@0 55
michael@0 56 public String getDocumentServerURI() {
michael@0 57 return getSharedPreferences().getString(HealthReportConstants.PREF_DOCUMENT_SERVER_URI, HealthReportConstants.DEFAULT_DOCUMENT_SERVER_URI);
michael@0 58 }
michael@0 59
michael@0 60 public String getDocumentServerNamespace() {
michael@0 61 return getSharedPreferences().getString(HealthReportConstants.PREF_DOCUMENT_SERVER_NAMESPACE, HealthReportConstants.DEFAULT_DOCUMENT_SERVER_NAMESPACE);
michael@0 62 }
michael@0 63
michael@0 64 public long getLastUploadLocalTime() {
michael@0 65 return getSharedPreferences().getLong(HealthReportConstants.PREF_LAST_UPLOAD_LOCAL_TIME, 0L);
michael@0 66 }
michael@0 67
michael@0 68 public String getLastUploadDocumentId() {
michael@0 69 return getSharedPreferences().getString(HealthReportConstants.PREF_LAST_UPLOAD_DOCUMENT_ID, null);
michael@0 70 }
michael@0 71
michael@0 72 public boolean hasUploadBeenRequested() {
michael@0 73 return getSharedPreferences().contains(HealthReportConstants.PREF_LAST_UPLOAD_REQUESTED);
michael@0 74 }
michael@0 75
michael@0 76 public void setLastUploadLocalTimeAndDocumentId(long localTime, String id) {
michael@0 77 getSharedPreferences().edit()
michael@0 78 .putLong(HealthReportConstants.PREF_LAST_UPLOAD_LOCAL_TIME, localTime)
michael@0 79 .putString(HealthReportConstants.PREF_LAST_UPLOAD_DOCUMENT_ID, id)
michael@0 80 .commit();
michael@0 81 }
michael@0 82
michael@0 83 protected HealthReportDatabaseStorage getStorage(final ContentProviderClient client) {
michael@0 84 return EnvironmentBuilder.getStorage(client, profilePath);
michael@0 85 }
michael@0 86
michael@0 87 protected JSONObject generateDocument(final long localTime, final long last,
michael@0 88 final SubmissionsTracker tracker) throws JSONException {
michael@0 89 final long since = localTime - GlobalConstants.MILLISECONDS_PER_SIX_MONTHS;
michael@0 90 final HealthReportGenerator generator = tracker.getGenerator();
michael@0 91 return generator.generateDocument(since, last, profilePath);
michael@0 92 }
michael@0 93
michael@0 94 protected void uploadPayload(String id, String payload, Collection<String> oldIds, BagheeraRequestDelegate uploadDelegate) {
michael@0 95 final BagheeraClient client = new BagheeraClient(getDocumentServerURI());
michael@0 96
michael@0 97 Logger.pii(LOG_TAG, "New health report has id " + id +
michael@0 98 "and obsoletes " + (oldIds != null ? Integer.toString(oldIds.size()) : "no") + " old ids.");
michael@0 99
michael@0 100 try {
michael@0 101 client.uploadJSONDocument(getDocumentServerNamespace(),
michael@0 102 id,
michael@0 103 payload,
michael@0 104 oldIds,
michael@0 105 uploadDelegate);
michael@0 106 } catch (Exception e) {
michael@0 107 uploadDelegate.handleError(e);
michael@0 108 }
michael@0 109 }
michael@0 110
michael@0 111 @Override
michael@0 112 public void upload(long localTime, String id, Collection<String> oldIds, Delegate delegate) {
michael@0 113 // We abuse the life-cycle of an Android ContentProvider slightly by holding
michael@0 114 // onto a ContentProviderClient while we generate a payload. This keeps our
michael@0 115 // database storage alive, and may also allow us to share a database
michael@0 116 // connection with a BrowserHealthRecorder from Fennec. The ContentProvider
michael@0 117 // owns all underlying Storage instances, so we don't need to explicitly
michael@0 118 // close them.
michael@0 119 ContentProviderClient client = EnvironmentBuilder.getContentProviderClient(context);
michael@0 120 if (client == null) {
michael@0 121 // TODO: Bug 910898 - Store client failure in SharedPrefs so we can increment next time with storage.
michael@0 122 delegate.onHardFailure(localTime, null, "Could not fetch content provider client.", null);
michael@0 123 return;
michael@0 124 }
michael@0 125
michael@0 126 try {
michael@0 127 // Storage instance is owned by HealthReportProvider, so we don't need to
michael@0 128 // close it. It's worth noting that this call will fail if called
michael@0 129 // out-of-process.
michael@0 130 final HealthReportDatabaseStorage storage = getStorage(client);
michael@0 131 if (storage == null) {
michael@0 132 // TODO: Bug 910898 - Store error in SharedPrefs so we can increment next time with storage.
michael@0 133 delegate.onHardFailure(localTime, null, "No storage when generating report.", null);
michael@0 134 return;
michael@0 135 }
michael@0 136
michael@0 137 long last = Math.max(getLastUploadLocalTime(), HealthReportConstants.EARLIEST_LAST_PING);
michael@0 138 if (!storage.hasEventSince(last)) {
michael@0 139 delegate.onHardFailure(localTime, null, "No new events in storage.", null);
michael@0 140 return;
michael@0 141 }
michael@0 142
michael@0 143 initializeStorageForUploadProviders(storage);
michael@0 144
michael@0 145 final SubmissionsTracker tracker =
michael@0 146 getSubmissionsTracker(storage, localTime, hasUploadBeenRequested());
michael@0 147 try {
michael@0 148 // TODO: Bug 910898 - Add errors from sharedPrefs to tracker.
michael@0 149 final JSONObject document = generateDocument(localTime, last, tracker);
michael@0 150 if (document == null) {
michael@0 151 delegate.onHardFailure(localTime, null, "Generator returned null document.", null);
michael@0 152 return;
michael@0 153 }
michael@0 154
michael@0 155 final BagheeraRequestDelegate uploadDelegate = tracker.getDelegate(delegate, localTime,
michael@0 156 true, id);
michael@0 157 this.uploadPayload(id, document.toString(), oldIds, uploadDelegate);
michael@0 158 } catch (Exception e) {
michael@0 159 // Incrementing the failure count here could potentially cause the failure count to be
michael@0 160 // incremented twice, but this helper class checks and prevents this.
michael@0 161 tracker.incrementUploadClientFailureCount();
michael@0 162 throw e;
michael@0 163 }
michael@0 164 } catch (Exception e) {
michael@0 165 // TODO: Bug 910898 - Store client failure in SharedPrefs so we can increment next time with storage.
michael@0 166 Logger.warn(LOG_TAG, "Got exception generating document.", e);
michael@0 167 delegate.onHardFailure(localTime, null, "Got exception uploading.", e);
michael@0 168 return;
michael@0 169 } finally {
michael@0 170 client.release();
michael@0 171 }
michael@0 172 }
michael@0 173
michael@0 174 protected SubmissionsTracker getSubmissionsTracker(final HealthReportStorage storage,
michael@0 175 final long localTime, final boolean hasUploadBeenRequested) {
michael@0 176 return new SubmissionsTracker(storage, localTime, hasUploadBeenRequested);
michael@0 177 }
michael@0 178
michael@0 179 @Override
michael@0 180 public void delete(final long localTime, final String id, Delegate delegate) {
michael@0 181 final BagheeraClient client = new BagheeraClient(getDocumentServerURI());
michael@0 182
michael@0 183 Logger.pii(LOG_TAG, "Deleting health report with id " + id + ".");
michael@0 184
michael@0 185 BagheeraRequestDelegate deleteDelegate = new RequestDelegate(delegate, localTime, false, id);
michael@0 186 try {
michael@0 187 client.deleteDocument(getDocumentServerNamespace(), id, deleteDelegate);
michael@0 188 } catch (Exception e) {
michael@0 189 deleteDelegate.handleError(e);
michael@0 190 }
michael@0 191 }
michael@0 192
michael@0 193 protected class RequestDelegate implements BagheeraRequestDelegate {
michael@0 194 protected final Delegate delegate;
michael@0 195 protected final boolean isUpload;
michael@0 196 protected final String methodString;
michael@0 197 protected final long localTime;
michael@0 198 protected final String id;
michael@0 199
michael@0 200 public RequestDelegate(Delegate delegate, long localTime, boolean isUpload, String id) {
michael@0 201 this.delegate = delegate;
michael@0 202 this.localTime = localTime;
michael@0 203 this.isUpload = isUpload;
michael@0 204 this.methodString = this.isUpload ? "upload" : "delete";
michael@0 205 this.id = id;
michael@0 206 }
michael@0 207
michael@0 208 @Override
michael@0 209 public String getUserAgent() {
michael@0 210 return HealthReportConstants.USER_AGENT;
michael@0 211 }
michael@0 212
michael@0 213 @Override
michael@0 214 public void handleSuccess(int status, String namespace, String id, HttpResponse response) {
michael@0 215 BaseResource.consumeEntity(response);
michael@0 216 if (isUpload) {
michael@0 217 setLastUploadLocalTimeAndDocumentId(localTime, id);
michael@0 218 }
michael@0 219 Logger.debug(LOG_TAG, "Successful " + methodString + " at " + localTime + ".");
michael@0 220 delegate.onSuccess(localTime, id);
michael@0 221 }
michael@0 222
michael@0 223 /**
michael@0 224 * Bagheera status codes:
michael@0 225 *
michael@0 226 * 403 Forbidden - Violated access restrictions. Most likely because of the method used.
michael@0 227 * 413 Request Too Large - Request payload was larger than the configured maximum.
michael@0 228 * 400 Bad Request - Returned if the POST/PUT failed validation in some manner.
michael@0 229 * 404 Not Found - Returned if the URI path doesn't exist or if the URI was not in the proper format.
michael@0 230 * 500 Server Error - General server error. Someone with access should look at the logs for more details.
michael@0 231 */
michael@0 232 @Override
michael@0 233 public void handleFailure(int status, String namespace, HttpResponse response) {
michael@0 234 BaseResource.consumeEntity(response);
michael@0 235 Logger.debug(LOG_TAG, "Failed " + methodString + " at " + localTime + ".");
michael@0 236 if (status >= 500) {
michael@0 237 delegate.onSoftFailure(localTime, id, "Got status " + status + " from server.", null);
michael@0 238 return;
michael@0 239 }
michael@0 240 // Things are either bad locally (bad payload format, too much data) or
michael@0 241 // bad remotely (badly configured server, temporarily unavailable). Try
michael@0 242 // again tomorrow.
michael@0 243 delegate.onHardFailure(localTime, id, "Got status " + status + " from server.", null);
michael@0 244 }
michael@0 245
michael@0 246 @Override
michael@0 247 public void handleError(Exception e) {
michael@0 248 Logger.debug(LOG_TAG, "Exception during " + methodString + " at " + localTime + ".", e);
michael@0 249 if (e instanceof IOException) {
michael@0 250 // Let's assume IO exceptions are Android dropping the network.
michael@0 251 delegate.onSoftFailure(localTime, id, "Got exception during " + methodString + ".", e);
michael@0 252 return;
michael@0 253 }
michael@0 254 delegate.onHardFailure(localTime, id, "Got exception during " + methodString + ".", e);
michael@0 255 }
michael@0 256 };
michael@0 257
michael@0 258 private void initializeStorageForUploadProviders(HealthReportDatabaseStorage storage) {
michael@0 259 storage.beginInitialization();
michael@0 260 try {
michael@0 261 initializeSubmissionsProvider(storage);
michael@0 262 storage.finishInitialization();
michael@0 263 } catch (Exception e) {
michael@0 264 // TODO: Bug 910898 - Store error in SharedPrefs so we can increment next time with storage.
michael@0 265 storage.abortInitialization();
michael@0 266 throw new IllegalStateException("Could not initialize storage for upload provider.", e);
michael@0 267 }
michael@0 268 }
michael@0 269
michael@0 270 private void initializeSubmissionsProvider(HealthReportDatabaseStorage storage) {
michael@0 271 storage.ensureMeasurementInitialized(
michael@0 272 MEASUREMENT_NAME_SUBMISSIONS,
michael@0 273 MEASUREMENT_VERSION_SUBMISSIONS,
michael@0 274 new MeasurementFields() {
michael@0 275 @Override
michael@0 276 public Iterable<FieldSpec> getFields() {
michael@0 277 final ArrayList<FieldSpec> out = new ArrayList<FieldSpec>();
michael@0 278 for (SubmissionsFieldName fieldName : SubmissionsFieldName.values()) {
michael@0 279 FieldSpec spec = new FieldSpec(fieldName.getName(), Field.TYPE_INTEGER_COUNTER);
michael@0 280 out.add(spec);
michael@0 281 }
michael@0 282 return out;
michael@0 283 }
michael@0 284 });
michael@0 285 }
michael@0 286
michael@0 287 public static enum SubmissionsFieldName {
michael@0 288 FIRST_ATTEMPT("firstDocumentUploadAttempt"),
michael@0 289 CONTINUATION_ATTEMPT("continuationDocumentUploadAttempt"),
michael@0 290 SUCCESS("uploadSuccess"),
michael@0 291 TRANSPORT_FAILURE("uploadTransportFailure"),
michael@0 292 SERVER_FAILURE("uploadServerFailure"),
michael@0 293 CLIENT_FAILURE("uploadClientFailure");
michael@0 294
michael@0 295 private final String name;
michael@0 296
michael@0 297 SubmissionsFieldName(String name) {
michael@0 298 this.name = name;
michael@0 299 }
michael@0 300
michael@0 301 public String getName() {
michael@0 302 return name;
michael@0 303 }
michael@0 304
michael@0 305 public int getID(HealthReportStorage storage) {
michael@0 306 final Field field = storage.getField(MEASUREMENT_NAME_SUBMISSIONS,
michael@0 307 MEASUREMENT_VERSION_SUBMISSIONS,
michael@0 308 name);
michael@0 309 return field.getID();
michael@0 310 }
michael@0 311 }
michael@0 312
michael@0 313 /**
michael@0 314 * Encapsulates the counting mechanisms for submissions status counts. Ensures multiple failures
michael@0 315 * and successes are not recorded for a single instance.
michael@0 316 */
michael@0 317 public class SubmissionsTracker {
michael@0 318 private final HealthReportStorage storage;
michael@0 319 private final ProfileInformationCache profileCache;
michael@0 320 private final int day;
michael@0 321 private final int envID;
michael@0 322
michael@0 323 private boolean isUploadStatusCountIncremented;
michael@0 324
michael@0 325 public SubmissionsTracker(final HealthReportStorage storage, final long localTime,
michael@0 326 final boolean hasUploadBeenRequested) throws IllegalStateException {
michael@0 327 this.storage = storage;
michael@0 328 this.profileCache = getProfileInformationCache();
michael@0 329 this.day = storage.getDay(localTime);
michael@0 330 this.envID = registerCurrentEnvironment();
michael@0 331
michael@0 332 this.isUploadStatusCountIncremented = false;
michael@0 333
michael@0 334 if (!hasUploadBeenRequested) {
michael@0 335 incrementFirstUploadAttemptCount();
michael@0 336 } else {
michael@0 337 incrementContinuationAttemptCount();
michael@0 338 }
michael@0 339 }
michael@0 340
michael@0 341 protected ProfileInformationCache getProfileInformationCache() {
michael@0 342 final ProfileInformationCache profileCache = new ProfileInformationCache(profilePath);
michael@0 343 if (!profileCache.restoreUnlessInitialized()) {
michael@0 344 Logger.warn(LOG_TAG, "Not enough profile information to compute current environment.");
michael@0 345 throw new IllegalStateException("Could not retrieve current environment.");
michael@0 346 }
michael@0 347 return profileCache;
michael@0 348 }
michael@0 349
michael@0 350 protected int registerCurrentEnvironment() {
michael@0 351 return EnvironmentBuilder.registerCurrentEnvironment(storage, profileCache);
michael@0 352 }
michael@0 353
michael@0 354 protected void incrementFirstUploadAttemptCount() {
michael@0 355 Logger.debug(LOG_TAG, "Incrementing first upload attempt field.");
michael@0 356 storage.incrementDailyCount(envID, day, SubmissionsFieldName.FIRST_ATTEMPT.getID(storage));
michael@0 357 }
michael@0 358
michael@0 359 protected void incrementContinuationAttemptCount() {
michael@0 360 Logger.debug(LOG_TAG, "Incrementing continuation upload attempt field.");
michael@0 361 storage.incrementDailyCount(envID, day, SubmissionsFieldName.CONTINUATION_ATTEMPT.getID(storage));
michael@0 362 }
michael@0 363
michael@0 364 public void incrementUploadSuccessCount() {
michael@0 365 incrementStatusCount(SubmissionsFieldName.SUCCESS.getID(storage), "success");
michael@0 366 }
michael@0 367
michael@0 368 public void incrementUploadClientFailureCount() {
michael@0 369 incrementStatusCount(SubmissionsFieldName.CLIENT_FAILURE.getID(storage), "client failure");
michael@0 370 }
michael@0 371
michael@0 372 public void incrementUploadTransportFailureCount() {
michael@0 373 incrementStatusCount(SubmissionsFieldName.TRANSPORT_FAILURE.getID(storage), "transport failure");
michael@0 374 }
michael@0 375
michael@0 376 public void incrementUploadServerFailureCount() {
michael@0 377 incrementStatusCount(SubmissionsFieldName.SERVER_FAILURE.getID(storage), "server failure");
michael@0 378 }
michael@0 379
michael@0 380 private void incrementStatusCount(final int fieldID, final String countType) {
michael@0 381 if (!isUploadStatusCountIncremented) {
michael@0 382 Logger.debug(LOG_TAG, "Incrementing upload attempt " + countType + " count.");
michael@0 383 storage.incrementDailyCount(envID, day, fieldID);
michael@0 384 isUploadStatusCountIncremented = true;
michael@0 385 } else {
michael@0 386 Logger.warn(LOG_TAG, "Upload status count already incremented - not incrementing " +
michael@0 387 countType + " count.");
michael@0 388 }
michael@0 389 }
michael@0 390
michael@0 391 public TrackingGenerator getGenerator() {
michael@0 392 return new TrackingGenerator();
michael@0 393 }
michael@0 394
michael@0 395 public class TrackingGenerator extends HealthReportGenerator {
michael@0 396 public TrackingGenerator() {
michael@0 397 super(storage);
michael@0 398 }
michael@0 399
michael@0 400 @Override
michael@0 401 public JSONObject generateDocument(long since, long lastPingTime,
michael@0 402 String generationProfilePath) throws JSONException {
michael@0 403
michael@0 404 // Let's make sure we have an accurate locale.
michael@0 405 BrowserLocaleManager.getInstance().getAndApplyPersistedLocale(context);
michael@0 406
michael@0 407 final JSONObject document;
michael@0 408 // If the given profilePath matches the one we cached for the tracker, use the cached env.
michael@0 409 if (profilePath != null && profilePath.equals(generationProfilePath)) {
michael@0 410 final Environment environment = getCurrentEnvironment();
michael@0 411 document = super.generateDocument(since, lastPingTime, environment);
michael@0 412 } else {
michael@0 413 document = super.generateDocument(since, lastPingTime, generationProfilePath);
michael@0 414 }
michael@0 415
michael@0 416 if (document == null) {
michael@0 417 incrementUploadClientFailureCount();
michael@0 418 }
michael@0 419 return document;
michael@0 420 }
michael@0 421
michael@0 422 protected Environment getCurrentEnvironment() {
michael@0 423 return EnvironmentBuilder.getCurrentEnvironment(profileCache);
michael@0 424 }
michael@0 425 }
michael@0 426
michael@0 427 public TrackingRequestDelegate getDelegate(final Delegate delegate, final long localTime,
michael@0 428 final boolean isUpload, final String id) {
michael@0 429 return new TrackingRequestDelegate(delegate, localTime, isUpload, id);
michael@0 430 }
michael@0 431
michael@0 432 public class TrackingRequestDelegate extends RequestDelegate {
michael@0 433 public TrackingRequestDelegate(final Delegate delegate, final long localTime,
michael@0 434 final boolean isUpload, final String id) {
michael@0 435 super(delegate, localTime, isUpload, id);
michael@0 436 }
michael@0 437
michael@0 438 @Override
michael@0 439 public void handleSuccess(int status, String namespace, String id, HttpResponse response) {
michael@0 440 super.handleSuccess(status, namespace, id, response);
michael@0 441 incrementUploadSuccessCount();
michael@0 442 }
michael@0 443
michael@0 444 @Override
michael@0 445 public void handleFailure(int status, String namespace, HttpResponse response) {
michael@0 446 super.handleFailure(status, namespace, response);
michael@0 447 incrementUploadServerFailureCount();
michael@0 448 }
michael@0 449
michael@0 450 @Override
michael@0 451 public void handleError(Exception e) {
michael@0 452 super.handleError(e);
michael@0 453 if (e instanceof IllegalArgumentException ||
michael@0 454 e instanceof UnsupportedEncodingException ||
michael@0 455 e instanceof URISyntaxException) {
michael@0 456 incrementUploadClientFailureCount();
michael@0 457 } else {
michael@0 458 incrementUploadTransportFailureCount();
michael@0 459 }
michael@0 460 }
michael@0 461 }
michael@0 462 }
michael@0 463 }

mercurial