1.1 --- /dev/null Thu Jan 01 00:00:00 1970 +0000 1.2 +++ b/mobile/android/base/background/healthreport/upload/SubmissionPolicy.java Wed Dec 31 06:09:35 2014 +0100 1.3 @@ -0,0 +1,460 @@ 1.4 +/* This Source Code Form is subject to the terms of the Mozilla Public 1.5 + * License, v. 2.0. If a copy of the MPL was not distributed with this 1.6 + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ 1.7 + 1.8 +package org.mozilla.gecko.background.healthreport.upload; 1.9 + 1.10 +import java.net.MalformedURLException; 1.11 +import java.net.SocketException; 1.12 +import java.net.UnknownHostException; 1.13 +import java.util.Collection; 1.14 + 1.15 +import org.mozilla.gecko.background.common.log.Logger; 1.16 +import org.mozilla.gecko.background.healthreport.HealthReportConstants; 1.17 +import org.mozilla.gecko.background.healthreport.HealthReportUtils; 1.18 +import org.mozilla.gecko.background.healthreport.upload.SubmissionClient.Delegate; 1.19 + 1.20 +import android.content.SharedPreferences; 1.21 + 1.22 +/** 1.23 + * Manages scheduling of Firefox Health Report data submission. 1.24 + * 1.25 + * The rules of data submission are as follows: 1.26 + * 1.27 + * 1. Do not submit data more than once every 24 hours. 1.28 + * 1.29 + * 2. Try to submit as close to 24 hours apart as possible. 1.30 + * 1.31 + * 3. Do not submit too soon after application startup so as to not negatively 1.32 + * impact performance at startup. 1.33 + * 1.34 + * 4. Before first ever data submission, the user should be notified about data 1.35 + * collection practices. 1.36 + * 1.37 + * 5. User should have opportunity to react to this notification before data 1.38 + * submission. 1.39 + * 1.40 + * 6. Display of notification without any explicit user action constitutes 1.41 + * implicit consent after a certain duration of time. 1.42 + * 1.43 + * 7. If data submission fails, try at most 2 additional times before giving up 1.44 + * on that day's submission. 1.45 + * 1.46 + * On Android, items 4, 5, and 6 are addressed by displaying an Android 1.47 + * notification on first run. 1.48 + */ 1.49 +public class SubmissionPolicy { 1.50 + public static final String LOG_TAG = SubmissionPolicy.class.getSimpleName(); 1.51 + 1.52 + protected final SharedPreferences sharedPreferences; 1.53 + protected final SubmissionClient client; 1.54 + protected final boolean uploadEnabled; 1.55 + protected final ObsoleteDocumentTracker tracker; 1.56 + 1.57 + public SubmissionPolicy(final SharedPreferences sharedPreferences, 1.58 + final SubmissionClient client, 1.59 + final ObsoleteDocumentTracker tracker, 1.60 + boolean uploadEnabled) { 1.61 + if (sharedPreferences == null) { 1.62 + throw new IllegalArgumentException("sharedPreferences must not be null"); 1.63 + } 1.64 + this.sharedPreferences = sharedPreferences; 1.65 + this.client = client; 1.66 + this.tracker = tracker; 1.67 + this.uploadEnabled = uploadEnabled; 1.68 + } 1.69 + 1.70 + /** 1.71 + * Check what action must happen, advance counters and timestamps, and 1.72 + * possibly spawn a request to the server. 1.73 + * 1.74 + * @param localTime now. 1.75 + * @return true if a request was spawned; false otherwise. 1.76 + */ 1.77 + public boolean tick(final long localTime) { 1.78 + final long nextUpload = getNextSubmission(); 1.79 + 1.80 + // If the system clock were ever set to a time in the distant future, 1.81 + // it's possible our next schedule date is far out as well. We know 1.82 + // we shouldn't schedule for more than a day out, so we reset the next 1.83 + // scheduled date appropriately. 3 days was chosen to match desktop's 1.84 + // arbitrary choice. 1.85 + if (nextUpload >= localTime + 3 * getMinimumTimeBetweenUploads()) { 1.86 + Logger.warn(LOG_TAG, "Next upload scheduled far in the future; system clock reset? " + nextUpload + " > " + localTime); 1.87 + // Things are strange, we want to start again but we don't want to stampede. 1.88 + editor() 1.89 + .setNextSubmission(localTime + getMinimumTimeBetweenUploads()) 1.90 + .commit(); 1.91 + return false; 1.92 + } 1.93 + 1.94 + // Don't upload unless an interval has elapsed. 1.95 + if (localTime < nextUpload) { 1.96 + Logger.debug(LOG_TAG, "We uploaded less than an interval ago; skipping. " + nextUpload + " > " + localTime); 1.97 + return false; 1.98 + } 1.99 + 1.100 + if (!uploadEnabled) { 1.101 + // We only delete (rather than mark as obsolete during upload) when 1.102 + // uploading is disabled. We try to delete aggressively, since the volume 1.103 + // of deletes should be very low. But we don't want to send too many 1.104 + // delete requests at the same time, so we process these one at a time. In 1.105 + // the future (Bug 872756), we will be able to delete multiple documents 1.106 + // with one request. 1.107 + final String obsoleteId = tracker.getNextObsoleteId(); 1.108 + if (obsoleteId == null) { 1.109 + return false; 1.110 + } 1.111 + 1.112 + Editor editor = editor(); 1.113 + editor.setLastDeleteRequested(localTime); // Write committed by delegate. 1.114 + client.delete(localTime, obsoleteId, new DeleteDelegate(editor)); 1.115 + return true; 1.116 + } 1.117 + 1.118 + long firstRun = getFirstRunLocalTime(); 1.119 + if (firstRun < 0) { 1.120 + firstRun = localTime; 1.121 + // Make sure we start clean and as soon as possible. 1.122 + editor() 1.123 + .setFirstRunLocalTime(firstRun) 1.124 + .setNextSubmission(localTime + getMinimumTimeBeforeFirstSubmission()) 1.125 + .setCurrentDayFailureCount(0) 1.126 + .commit(); 1.127 + } 1.128 + 1.129 + // This case will occur if the nextSubmission time is not set (== -1) but firstRun is. 1.130 + if (localTime < firstRun + getMinimumTimeBeforeFirstSubmission()) { 1.131 + Logger.info(LOG_TAG, "Need to wait " + getMinimumTimeBeforeFirstSubmission() + " before first upload."); 1.132 + return false; 1.133 + } 1.134 + 1.135 + // The first upload attempt for a given document submission begins a 24-hour period in which 1.136 + // the upload will retry upon a soft failure. At the end of this period, the submission 1.137 + // failure count is reset, ensuring each day's first submission attempt has a zeroed failure 1.138 + // count. A period may also end on upload success or hard failure. 1.139 + if (localTime >= getCurrentDayResetTime()) { 1.140 + editor() 1.141 + .setCurrentDayResetTime(localTime + getMinimumTimeBetweenUploads()) 1.142 + .setCurrentDayFailureCount(0) 1.143 + .commit(); 1.144 + } 1.145 + 1.146 + String id = HealthReportUtils.generateDocumentId(); 1.147 + Collection<String> oldIds = tracker.getBatchOfObsoleteIds(); 1.148 + tracker.addObsoleteId(id); 1.149 + 1.150 + Editor editor = editor(); 1.151 + editor.setLastUploadRequested(localTime); // Write committed by delegate. 1.152 + client.upload(localTime, id, oldIds, new UploadDelegate(editor, oldIds)); 1.153 + return true; 1.154 + } 1.155 + 1.156 + /** 1.157 + * Return true if the upload that produced <code>e</code> definitely did not 1.158 + * produce a new record on the remote server. 1.159 + * 1.160 + * @param e 1.161 + * <code>Exception</code> that upload produced. 1.162 + * @return true if the server could not have a new record. 1.163 + */ 1.164 + protected boolean isLocalException(Exception e) { 1.165 + return (e instanceof MalformedURLException) || 1.166 + (e instanceof SocketException) || 1.167 + (e instanceof UnknownHostException); 1.168 + } 1.169 + 1.170 + protected class UploadDelegate implements Delegate { 1.171 + protected final Editor editor; 1.172 + protected final Collection<String> oldIds; 1.173 + 1.174 + public UploadDelegate(Editor editor, Collection<String> oldIds) { 1.175 + this.editor = editor; 1.176 + this.oldIds = oldIds; 1.177 + } 1.178 + 1.179 + @Override 1.180 + public void onSuccess(long localTime, String id) { 1.181 + long next = localTime + getMinimumTimeBetweenUploads(); 1.182 + tracker.markIdAsUploaded(id); 1.183 + tracker.purgeObsoleteIds(oldIds); 1.184 + editor 1.185 + .setNextSubmission(next) 1.186 + .setLastUploadSucceeded(localTime) 1.187 + .setCurrentDayFailureCount(0) 1.188 + .clearCurrentDayResetTime() // Set again on the next submission's first upload attempt. 1.189 + .commit(); 1.190 + if (Logger.LOG_PERSONAL_INFORMATION) { 1.191 + Logger.pii(LOG_TAG, "Successful upload with id " + id + " obsoleting " 1.192 + + oldIds.size() + " old records reported at " + localTime + "; next upload at " + next + "."); 1.193 + } else { 1.194 + Logger.info(LOG_TAG, "Successful upload obsoleting " + oldIds.size() 1.195 + + " old records reported at " + localTime + "; next upload at " + next + "."); 1.196 + } 1.197 + } 1.198 + 1.199 + @Override 1.200 + public void onHardFailure(long localTime, String id, String reason, Exception e) { 1.201 + long next = localTime + getMinimumTimeBetweenUploads(); 1.202 + if (isLocalException(e)) { 1.203 + Logger.info(LOG_TAG, "Hard failure caused by local exception; not tracking id and not decrementing attempts."); 1.204 + tracker.removeObsoleteId(id); 1.205 + } else { 1.206 + tracker.decrementObsoleteIdAttempts(oldIds); 1.207 + } 1.208 + editor 1.209 + .setNextSubmission(next) 1.210 + .setLastUploadFailed(localTime) 1.211 + .setCurrentDayFailureCount(0) 1.212 + .clearCurrentDayResetTime() // Set again on the next submission's first upload attempt. 1.213 + .commit(); 1.214 + Logger.warn(LOG_TAG, "Hard failure reported at " + localTime + ": " + reason + " Next upload at " + next + ".", e); 1.215 + } 1.216 + 1.217 + @Override 1.218 + public void onSoftFailure(long localTime, String id, String reason, Exception e) { 1.219 + int failuresToday = getCurrentDayFailureCount(); 1.220 + Logger.warn(LOG_TAG, "Soft failure reported at " + localTime + ": " + reason + " Previously failed " + failuresToday + " time(s) today."); 1.221 + 1.222 + if (failuresToday >= getMaximumFailuresPerDay()) { 1.223 + onHardFailure(localTime, id, "Reached the limit of daily upload attempts: " + failuresToday, e); 1.224 + return; 1.225 + } 1.226 + 1.227 + long next = localTime + getMinimumTimeAfterFailure(); 1.228 + if (isLocalException(e)) { 1.229 + Logger.info(LOG_TAG, "Soft failure caused by local exception; not tracking id and not decrementing attempts."); 1.230 + tracker.removeObsoleteId(id); 1.231 + } else { 1.232 + tracker.decrementObsoleteIdAttempts(oldIds); 1.233 + } 1.234 + editor 1.235 + .setNextSubmission(next) 1.236 + .setLastUploadFailed(localTime) 1.237 + .setCurrentDayFailureCount(failuresToday + 1) 1.238 + .commit(); 1.239 + Logger.info(LOG_TAG, "Retrying upload at " + next + "."); 1.240 + } 1.241 + } 1.242 + 1.243 + protected class DeleteDelegate implements Delegate { 1.244 + protected final Editor editor; 1.245 + 1.246 + public DeleteDelegate(Editor editor) { 1.247 + this.editor = editor; 1.248 + } 1.249 + 1.250 + @Override 1.251 + public void onSoftFailure(final long localTime, String id, String reason, Exception e) { 1.252 + long next = localTime + getMinimumTimeBetweenDeletes(); 1.253 + if (isLocalException(e)) { 1.254 + Logger.info(LOG_TAG, "Soft failure caused by local exception; not decrementing attempts."); 1.255 + } else { 1.256 + tracker.decrementObsoleteIdAttempts(id); 1.257 + } 1.258 + editor 1.259 + .setNextSubmission(next) 1.260 + .setLastDeleteFailed(localTime) 1.261 + .commit(); 1.262 + 1.263 + if (Logger.LOG_PERSONAL_INFORMATION) { 1.264 + Logger.info(LOG_TAG, "Got soft failure at " + localTime + " deleting obsolete document with id " + id + ": " + reason + " Trying again later."); 1.265 + } else { 1.266 + Logger.info(LOG_TAG, "Got soft failure at " + localTime + " deleting obsolete document: " + reason + " Trying again later."); 1.267 + } 1.268 + } 1.269 + 1.270 + @Override 1.271 + public void onHardFailure(final long localTime, String id, String reason, Exception e) { 1.272 + // We're never going to be able to delete this id, so don't keep trying. 1.273 + long next = localTime + getMinimumTimeBetweenDeletes(); 1.274 + tracker.removeObsoleteId(id); 1.275 + editor 1.276 + .setNextSubmission(next) 1.277 + .setLastDeleteFailed(localTime) 1.278 + .commit(); 1.279 + 1.280 + if (Logger.LOG_PERSONAL_INFORMATION) { 1.281 + Logger.warn(LOG_TAG, "Got hard failure at " + localTime + " deleting obsolete document with id " + id + ": " + reason + " Abandoning delete request.", e); 1.282 + } else { 1.283 + Logger.warn(LOG_TAG, "Got hard failure at " + localTime + " deleting obsolete document: " + reason + " Abandoning delete request.", e); 1.284 + } 1.285 + } 1.286 + 1.287 + @Override 1.288 + public void onSuccess(final long localTime, String id) { 1.289 + long next = localTime + getMinimumTimeBetweenDeletes(); 1.290 + tracker.removeObsoleteId(id); 1.291 + editor 1.292 + .setNextSubmission(next) 1.293 + .setLastDeleteSucceeded(localTime) 1.294 + .commit(); 1.295 + 1.296 + if (Logger.LOG_PERSONAL_INFORMATION) { 1.297 + Logger.pii(LOG_TAG, "Deleted an obsolete document with id " + id + " at " + localTime + "."); 1.298 + } else { 1.299 + Logger.info(LOG_TAG, "Deleted an obsolete document at " + localTime + "."); 1.300 + } 1.301 + } 1.302 + } 1.303 + 1.304 + public SharedPreferences getSharedPreferences() { 1.305 + return this.sharedPreferences; 1.306 + } 1.307 + 1.308 + public long getMinimumTimeBetweenUploads() { 1.309 + return getSharedPreferences().getLong(HealthReportConstants.PREF_MINIMUM_TIME_BETWEEN_UPLOADS, HealthReportConstants.DEFAULT_MINIMUM_TIME_BETWEEN_UPLOADS); 1.310 + } 1.311 + 1.312 + public long getMinimumTimeBeforeFirstSubmission() { 1.313 + return getSharedPreferences().getLong(HealthReportConstants.PREF_MINIMUM_TIME_BEFORE_FIRST_SUBMISSION, HealthReportConstants.DEFAULT_MINIMUM_TIME_BEFORE_FIRST_SUBMISSION); 1.314 + } 1.315 + 1.316 + public long getMinimumTimeAfterFailure() { 1.317 + return getSharedPreferences().getLong(HealthReportConstants.PREF_MINIMUM_TIME_AFTER_FAILURE, HealthReportConstants.DEFAULT_MINIMUM_TIME_AFTER_FAILURE); 1.318 + } 1.319 + 1.320 + public long getMaximumFailuresPerDay() { 1.321 + return getSharedPreferences().getLong(HealthReportConstants.PREF_MAXIMUM_FAILURES_PER_DAY, HealthReportConstants.DEFAULT_MAXIMUM_FAILURES_PER_DAY); 1.322 + } 1.323 + 1.324 + // Authoritative. 1.325 + public long getFirstRunLocalTime() { 1.326 + return getSharedPreferences().getLong(HealthReportConstants.PREF_FIRST_RUN, -1); 1.327 + } 1.328 + 1.329 + // Authoritative. 1.330 + public long getNextSubmission() { 1.331 + return getSharedPreferences().getLong(HealthReportConstants.PREF_NEXT_SUBMISSION, -1); 1.332 + } 1.333 + 1.334 + // Authoritative. 1.335 + public int getCurrentDayFailureCount() { 1.336 + return getSharedPreferences().getInt(HealthReportConstants.PREF_CURRENT_DAY_FAILURE_COUNT, 0); 1.337 + } 1.338 + 1.339 + // Authoritative. 1.340 + public long getCurrentDayResetTime() { 1.341 + return getSharedPreferences().getLong(HealthReportConstants.PREF_CURRENT_DAY_RESET_TIME, -1); 1.342 + } 1.343 + 1.344 + /** 1.345 + * To avoid writing to disk multiple times, we encapsulate writes in a 1.346 + * helper class. Be sure to call <code>commit</code> to flush to disk! 1.347 + */ 1.348 + protected Editor editor() { 1.349 + return new Editor(getSharedPreferences().edit()); 1.350 + } 1.351 + 1.352 + protected static class Editor { 1.353 + protected final SharedPreferences.Editor editor; 1.354 + 1.355 + public Editor(SharedPreferences.Editor editor) { 1.356 + this.editor = editor; 1.357 + } 1.358 + 1.359 + public void commit() { 1.360 + editor.commit(); 1.361 + } 1.362 + 1.363 + // Authoritative. 1.364 + public Editor setFirstRunLocalTime(long localTime) { 1.365 + editor.putLong(HealthReportConstants.PREF_FIRST_RUN, localTime); 1.366 + return this; 1.367 + } 1.368 + 1.369 + // Authoritative. 1.370 + public Editor setNextSubmission(long localTime) { 1.371 + editor.putLong(HealthReportConstants.PREF_NEXT_SUBMISSION, localTime); 1.372 + return this; 1.373 + } 1.374 + 1.375 + // Authoritative. 1.376 + public Editor setCurrentDayFailureCount(int failureCount) { 1.377 + editor.putInt(HealthReportConstants.PREF_CURRENT_DAY_FAILURE_COUNT, failureCount); 1.378 + return this; 1.379 + } 1.380 + 1.381 + // Authoritative. 1.382 + public Editor setCurrentDayResetTime(long resetTime) { 1.383 + editor.putLong(HealthReportConstants.PREF_CURRENT_DAY_RESET_TIME, resetTime); 1.384 + return this; 1.385 + } 1.386 + 1.387 + // Authoritative. 1.388 + public Editor clearCurrentDayResetTime() { 1.389 + editor.putLong(HealthReportConstants.PREF_CURRENT_DAY_RESET_TIME, -1); 1.390 + return this; 1.391 + } 1.392 + 1.393 + // Authoritative. 1.394 + public Editor setLastUploadRequested(long localTime) { 1.395 + editor.putLong(HealthReportConstants.PREF_LAST_UPLOAD_REQUESTED, localTime); 1.396 + return this; 1.397 + } 1.398 + 1.399 + // Forensics only. 1.400 + public Editor setLastUploadSucceeded(long localTime) { 1.401 + editor.putLong(HealthReportConstants.PREF_LAST_UPLOAD_SUCCEEDED, localTime); 1.402 + return this; 1.403 + } 1.404 + 1.405 + // Forensics only. 1.406 + public Editor setLastUploadFailed(long localTime) { 1.407 + editor.putLong(HealthReportConstants.PREF_LAST_UPLOAD_FAILED, localTime); 1.408 + return this; 1.409 + } 1.410 + 1.411 + // Forensics only. 1.412 + public Editor setLastDeleteRequested(long localTime) { 1.413 + editor.putLong(HealthReportConstants.PREF_LAST_DELETE_REQUESTED, localTime); 1.414 + return this; 1.415 + } 1.416 + 1.417 + // Forensics only. 1.418 + public Editor setLastDeleteSucceeded(long localTime) { 1.419 + editor.putLong(HealthReportConstants.PREF_LAST_DELETE_SUCCEEDED, localTime); 1.420 + return this; 1.421 + } 1.422 + 1.423 + // Forensics only. 1.424 + public Editor setLastDeleteFailed(long localTime) { 1.425 + editor.putLong(HealthReportConstants.PREF_LAST_DELETE_FAILED, localTime); 1.426 + return this; 1.427 + } 1.428 + } 1.429 + 1.430 + // Authoritative. 1.431 + public long getLastUploadRequested() { 1.432 + return getSharedPreferences().getLong(HealthReportConstants.PREF_LAST_UPLOAD_REQUESTED, -1); 1.433 + } 1.434 + 1.435 + // Forensics only. 1.436 + public long getLastUploadSucceeded() { 1.437 + return getSharedPreferences().getLong(HealthReportConstants.PREF_LAST_UPLOAD_SUCCEEDED, -1); 1.438 + } 1.439 + 1.440 + // Forensics only. 1.441 + public long getLastUploadFailed() { 1.442 + return getSharedPreferences().getLong(HealthReportConstants.PREF_LAST_UPLOAD_FAILED, -1); 1.443 + } 1.444 + 1.445 + // Forensics only. 1.446 + public long getLastDeleteRequested() { 1.447 + return getSharedPreferences().getLong(HealthReportConstants.PREF_LAST_DELETE_REQUESTED, -1); 1.448 + } 1.449 + 1.450 + // Forensics only. 1.451 + public long getLastDeleteSucceeded() { 1.452 + return getSharedPreferences().getLong(HealthReportConstants.PREF_LAST_DELETE_SUCCEEDED, -1); 1.453 + } 1.454 + 1.455 + // Forensics only. 1.456 + public long getLastDeleteFailed() { 1.457 + return getSharedPreferences().getLong(HealthReportConstants.PREF_LAST_DELETE_FAILED, -1); 1.458 + } 1.459 + 1.460 + public long getMinimumTimeBetweenDeletes() { 1.461 + return getSharedPreferences().getLong(HealthReportConstants.PREF_MINIMUM_TIME_BETWEEN_DELETES, HealthReportConstants.DEFAULT_MINIMUM_TIME_BETWEEN_DELETES); 1.462 + } 1.463 +}