|
1 /* This Source Code Form is subject to the terms of the Mozilla Public |
|
2 * License, v. 2.0. If a copy of the MPL was not distributed with this |
|
3 * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ |
|
4 |
|
5 package org.mozilla.gecko.background.healthreport.upload; |
|
6 |
|
7 import java.net.MalformedURLException; |
|
8 import java.net.SocketException; |
|
9 import java.net.UnknownHostException; |
|
10 import java.util.Collection; |
|
11 |
|
12 import org.mozilla.gecko.background.common.log.Logger; |
|
13 import org.mozilla.gecko.background.healthreport.HealthReportConstants; |
|
14 import org.mozilla.gecko.background.healthreport.HealthReportUtils; |
|
15 import org.mozilla.gecko.background.healthreport.upload.SubmissionClient.Delegate; |
|
16 |
|
17 import android.content.SharedPreferences; |
|
18 |
|
19 /** |
|
20 * Manages scheduling of Firefox Health Report data submission. |
|
21 * |
|
22 * The rules of data submission are as follows: |
|
23 * |
|
24 * 1. Do not submit data more than once every 24 hours. |
|
25 * |
|
26 * 2. Try to submit as close to 24 hours apart as possible. |
|
27 * |
|
28 * 3. Do not submit too soon after application startup so as to not negatively |
|
29 * impact performance at startup. |
|
30 * |
|
31 * 4. Before first ever data submission, the user should be notified about data |
|
32 * collection practices. |
|
33 * |
|
34 * 5. User should have opportunity to react to this notification before data |
|
35 * submission. |
|
36 * |
|
37 * 6. Display of notification without any explicit user action constitutes |
|
38 * implicit consent after a certain duration of time. |
|
39 * |
|
40 * 7. If data submission fails, try at most 2 additional times before giving up |
|
41 * on that day's submission. |
|
42 * |
|
43 * On Android, items 4, 5, and 6 are addressed by displaying an Android |
|
44 * notification on first run. |
|
45 */ |
|
46 public class SubmissionPolicy { |
|
47 public static final String LOG_TAG = SubmissionPolicy.class.getSimpleName(); |
|
48 |
|
49 protected final SharedPreferences sharedPreferences; |
|
50 protected final SubmissionClient client; |
|
51 protected final boolean uploadEnabled; |
|
52 protected final ObsoleteDocumentTracker tracker; |
|
53 |
|
54 public SubmissionPolicy(final SharedPreferences sharedPreferences, |
|
55 final SubmissionClient client, |
|
56 final ObsoleteDocumentTracker tracker, |
|
57 boolean uploadEnabled) { |
|
58 if (sharedPreferences == null) { |
|
59 throw new IllegalArgumentException("sharedPreferences must not be null"); |
|
60 } |
|
61 this.sharedPreferences = sharedPreferences; |
|
62 this.client = client; |
|
63 this.tracker = tracker; |
|
64 this.uploadEnabled = uploadEnabled; |
|
65 } |
|
66 |
|
67 /** |
|
68 * Check what action must happen, advance counters and timestamps, and |
|
69 * possibly spawn a request to the server. |
|
70 * |
|
71 * @param localTime now. |
|
72 * @return true if a request was spawned; false otherwise. |
|
73 */ |
|
74 public boolean tick(final long localTime) { |
|
75 final long nextUpload = getNextSubmission(); |
|
76 |
|
77 // If the system clock were ever set to a time in the distant future, |
|
78 // it's possible our next schedule date is far out as well. We know |
|
79 // we shouldn't schedule for more than a day out, so we reset the next |
|
80 // scheduled date appropriately. 3 days was chosen to match desktop's |
|
81 // arbitrary choice. |
|
82 if (nextUpload >= localTime + 3 * getMinimumTimeBetweenUploads()) { |
|
83 Logger.warn(LOG_TAG, "Next upload scheduled far in the future; system clock reset? " + nextUpload + " > " + localTime); |
|
84 // Things are strange, we want to start again but we don't want to stampede. |
|
85 editor() |
|
86 .setNextSubmission(localTime + getMinimumTimeBetweenUploads()) |
|
87 .commit(); |
|
88 return false; |
|
89 } |
|
90 |
|
91 // Don't upload unless an interval has elapsed. |
|
92 if (localTime < nextUpload) { |
|
93 Logger.debug(LOG_TAG, "We uploaded less than an interval ago; skipping. " + nextUpload + " > " + localTime); |
|
94 return false; |
|
95 } |
|
96 |
|
97 if (!uploadEnabled) { |
|
98 // We only delete (rather than mark as obsolete during upload) when |
|
99 // uploading is disabled. We try to delete aggressively, since the volume |
|
100 // of deletes should be very low. But we don't want to send too many |
|
101 // delete requests at the same time, so we process these one at a time. In |
|
102 // the future (Bug 872756), we will be able to delete multiple documents |
|
103 // with one request. |
|
104 final String obsoleteId = tracker.getNextObsoleteId(); |
|
105 if (obsoleteId == null) { |
|
106 return false; |
|
107 } |
|
108 |
|
109 Editor editor = editor(); |
|
110 editor.setLastDeleteRequested(localTime); // Write committed by delegate. |
|
111 client.delete(localTime, obsoleteId, new DeleteDelegate(editor)); |
|
112 return true; |
|
113 } |
|
114 |
|
115 long firstRun = getFirstRunLocalTime(); |
|
116 if (firstRun < 0) { |
|
117 firstRun = localTime; |
|
118 // Make sure we start clean and as soon as possible. |
|
119 editor() |
|
120 .setFirstRunLocalTime(firstRun) |
|
121 .setNextSubmission(localTime + getMinimumTimeBeforeFirstSubmission()) |
|
122 .setCurrentDayFailureCount(0) |
|
123 .commit(); |
|
124 } |
|
125 |
|
126 // This case will occur if the nextSubmission time is not set (== -1) but firstRun is. |
|
127 if (localTime < firstRun + getMinimumTimeBeforeFirstSubmission()) { |
|
128 Logger.info(LOG_TAG, "Need to wait " + getMinimumTimeBeforeFirstSubmission() + " before first upload."); |
|
129 return false; |
|
130 } |
|
131 |
|
132 // The first upload attempt for a given document submission begins a 24-hour period in which |
|
133 // the upload will retry upon a soft failure. At the end of this period, the submission |
|
134 // failure count is reset, ensuring each day's first submission attempt has a zeroed failure |
|
135 // count. A period may also end on upload success or hard failure. |
|
136 if (localTime >= getCurrentDayResetTime()) { |
|
137 editor() |
|
138 .setCurrentDayResetTime(localTime + getMinimumTimeBetweenUploads()) |
|
139 .setCurrentDayFailureCount(0) |
|
140 .commit(); |
|
141 } |
|
142 |
|
143 String id = HealthReportUtils.generateDocumentId(); |
|
144 Collection<String> oldIds = tracker.getBatchOfObsoleteIds(); |
|
145 tracker.addObsoleteId(id); |
|
146 |
|
147 Editor editor = editor(); |
|
148 editor.setLastUploadRequested(localTime); // Write committed by delegate. |
|
149 client.upload(localTime, id, oldIds, new UploadDelegate(editor, oldIds)); |
|
150 return true; |
|
151 } |
|
152 |
|
153 /** |
|
154 * Return true if the upload that produced <code>e</code> definitely did not |
|
155 * produce a new record on the remote server. |
|
156 * |
|
157 * @param e |
|
158 * <code>Exception</code> that upload produced. |
|
159 * @return true if the server could not have a new record. |
|
160 */ |
|
161 protected boolean isLocalException(Exception e) { |
|
162 return (e instanceof MalformedURLException) || |
|
163 (e instanceof SocketException) || |
|
164 (e instanceof UnknownHostException); |
|
165 } |
|
166 |
|
167 protected class UploadDelegate implements Delegate { |
|
168 protected final Editor editor; |
|
169 protected final Collection<String> oldIds; |
|
170 |
|
171 public UploadDelegate(Editor editor, Collection<String> oldIds) { |
|
172 this.editor = editor; |
|
173 this.oldIds = oldIds; |
|
174 } |
|
175 |
|
176 @Override |
|
177 public void onSuccess(long localTime, String id) { |
|
178 long next = localTime + getMinimumTimeBetweenUploads(); |
|
179 tracker.markIdAsUploaded(id); |
|
180 tracker.purgeObsoleteIds(oldIds); |
|
181 editor |
|
182 .setNextSubmission(next) |
|
183 .setLastUploadSucceeded(localTime) |
|
184 .setCurrentDayFailureCount(0) |
|
185 .clearCurrentDayResetTime() // Set again on the next submission's first upload attempt. |
|
186 .commit(); |
|
187 if (Logger.LOG_PERSONAL_INFORMATION) { |
|
188 Logger.pii(LOG_TAG, "Successful upload with id " + id + " obsoleting " |
|
189 + oldIds.size() + " old records reported at " + localTime + "; next upload at " + next + "."); |
|
190 } else { |
|
191 Logger.info(LOG_TAG, "Successful upload obsoleting " + oldIds.size() |
|
192 + " old records reported at " + localTime + "; next upload at " + next + "."); |
|
193 } |
|
194 } |
|
195 |
|
196 @Override |
|
197 public void onHardFailure(long localTime, String id, String reason, Exception e) { |
|
198 long next = localTime + getMinimumTimeBetweenUploads(); |
|
199 if (isLocalException(e)) { |
|
200 Logger.info(LOG_TAG, "Hard failure caused by local exception; not tracking id and not decrementing attempts."); |
|
201 tracker.removeObsoleteId(id); |
|
202 } else { |
|
203 tracker.decrementObsoleteIdAttempts(oldIds); |
|
204 } |
|
205 editor |
|
206 .setNextSubmission(next) |
|
207 .setLastUploadFailed(localTime) |
|
208 .setCurrentDayFailureCount(0) |
|
209 .clearCurrentDayResetTime() // Set again on the next submission's first upload attempt. |
|
210 .commit(); |
|
211 Logger.warn(LOG_TAG, "Hard failure reported at " + localTime + ": " + reason + " Next upload at " + next + ".", e); |
|
212 } |
|
213 |
|
214 @Override |
|
215 public void onSoftFailure(long localTime, String id, String reason, Exception e) { |
|
216 int failuresToday = getCurrentDayFailureCount(); |
|
217 Logger.warn(LOG_TAG, "Soft failure reported at " + localTime + ": " + reason + " Previously failed " + failuresToday + " time(s) today."); |
|
218 |
|
219 if (failuresToday >= getMaximumFailuresPerDay()) { |
|
220 onHardFailure(localTime, id, "Reached the limit of daily upload attempts: " + failuresToday, e); |
|
221 return; |
|
222 } |
|
223 |
|
224 long next = localTime + getMinimumTimeAfterFailure(); |
|
225 if (isLocalException(e)) { |
|
226 Logger.info(LOG_TAG, "Soft failure caused by local exception; not tracking id and not decrementing attempts."); |
|
227 tracker.removeObsoleteId(id); |
|
228 } else { |
|
229 tracker.decrementObsoleteIdAttempts(oldIds); |
|
230 } |
|
231 editor |
|
232 .setNextSubmission(next) |
|
233 .setLastUploadFailed(localTime) |
|
234 .setCurrentDayFailureCount(failuresToday + 1) |
|
235 .commit(); |
|
236 Logger.info(LOG_TAG, "Retrying upload at " + next + "."); |
|
237 } |
|
238 } |
|
239 |
|
240 protected class DeleteDelegate implements Delegate { |
|
241 protected final Editor editor; |
|
242 |
|
243 public DeleteDelegate(Editor editor) { |
|
244 this.editor = editor; |
|
245 } |
|
246 |
|
247 @Override |
|
248 public void onSoftFailure(final long localTime, String id, String reason, Exception e) { |
|
249 long next = localTime + getMinimumTimeBetweenDeletes(); |
|
250 if (isLocalException(e)) { |
|
251 Logger.info(LOG_TAG, "Soft failure caused by local exception; not decrementing attempts."); |
|
252 } else { |
|
253 tracker.decrementObsoleteIdAttempts(id); |
|
254 } |
|
255 editor |
|
256 .setNextSubmission(next) |
|
257 .setLastDeleteFailed(localTime) |
|
258 .commit(); |
|
259 |
|
260 if (Logger.LOG_PERSONAL_INFORMATION) { |
|
261 Logger.info(LOG_TAG, "Got soft failure at " + localTime + " deleting obsolete document with id " + id + ": " + reason + " Trying again later."); |
|
262 } else { |
|
263 Logger.info(LOG_TAG, "Got soft failure at " + localTime + " deleting obsolete document: " + reason + " Trying again later."); |
|
264 } |
|
265 } |
|
266 |
|
267 @Override |
|
268 public void onHardFailure(final long localTime, String id, String reason, Exception e) { |
|
269 // We're never going to be able to delete this id, so don't keep trying. |
|
270 long next = localTime + getMinimumTimeBetweenDeletes(); |
|
271 tracker.removeObsoleteId(id); |
|
272 editor |
|
273 .setNextSubmission(next) |
|
274 .setLastDeleteFailed(localTime) |
|
275 .commit(); |
|
276 |
|
277 if (Logger.LOG_PERSONAL_INFORMATION) { |
|
278 Logger.warn(LOG_TAG, "Got hard failure at " + localTime + " deleting obsolete document with id " + id + ": " + reason + " Abandoning delete request.", e); |
|
279 } else { |
|
280 Logger.warn(LOG_TAG, "Got hard failure at " + localTime + " deleting obsolete document: " + reason + " Abandoning delete request.", e); |
|
281 } |
|
282 } |
|
283 |
|
284 @Override |
|
285 public void onSuccess(final long localTime, String id) { |
|
286 long next = localTime + getMinimumTimeBetweenDeletes(); |
|
287 tracker.removeObsoleteId(id); |
|
288 editor |
|
289 .setNextSubmission(next) |
|
290 .setLastDeleteSucceeded(localTime) |
|
291 .commit(); |
|
292 |
|
293 if (Logger.LOG_PERSONAL_INFORMATION) { |
|
294 Logger.pii(LOG_TAG, "Deleted an obsolete document with id " + id + " at " + localTime + "."); |
|
295 } else { |
|
296 Logger.info(LOG_TAG, "Deleted an obsolete document at " + localTime + "."); |
|
297 } |
|
298 } |
|
299 } |
|
300 |
|
301 public SharedPreferences getSharedPreferences() { |
|
302 return this.sharedPreferences; |
|
303 } |
|
304 |
|
305 public long getMinimumTimeBetweenUploads() { |
|
306 return getSharedPreferences().getLong(HealthReportConstants.PREF_MINIMUM_TIME_BETWEEN_UPLOADS, HealthReportConstants.DEFAULT_MINIMUM_TIME_BETWEEN_UPLOADS); |
|
307 } |
|
308 |
|
309 public long getMinimumTimeBeforeFirstSubmission() { |
|
310 return getSharedPreferences().getLong(HealthReportConstants.PREF_MINIMUM_TIME_BEFORE_FIRST_SUBMISSION, HealthReportConstants.DEFAULT_MINIMUM_TIME_BEFORE_FIRST_SUBMISSION); |
|
311 } |
|
312 |
|
313 public long getMinimumTimeAfterFailure() { |
|
314 return getSharedPreferences().getLong(HealthReportConstants.PREF_MINIMUM_TIME_AFTER_FAILURE, HealthReportConstants.DEFAULT_MINIMUM_TIME_AFTER_FAILURE); |
|
315 } |
|
316 |
|
317 public long getMaximumFailuresPerDay() { |
|
318 return getSharedPreferences().getLong(HealthReportConstants.PREF_MAXIMUM_FAILURES_PER_DAY, HealthReportConstants.DEFAULT_MAXIMUM_FAILURES_PER_DAY); |
|
319 } |
|
320 |
|
321 // Authoritative. |
|
322 public long getFirstRunLocalTime() { |
|
323 return getSharedPreferences().getLong(HealthReportConstants.PREF_FIRST_RUN, -1); |
|
324 } |
|
325 |
|
326 // Authoritative. |
|
327 public long getNextSubmission() { |
|
328 return getSharedPreferences().getLong(HealthReportConstants.PREF_NEXT_SUBMISSION, -1); |
|
329 } |
|
330 |
|
331 // Authoritative. |
|
332 public int getCurrentDayFailureCount() { |
|
333 return getSharedPreferences().getInt(HealthReportConstants.PREF_CURRENT_DAY_FAILURE_COUNT, 0); |
|
334 } |
|
335 |
|
336 // Authoritative. |
|
337 public long getCurrentDayResetTime() { |
|
338 return getSharedPreferences().getLong(HealthReportConstants.PREF_CURRENT_DAY_RESET_TIME, -1); |
|
339 } |
|
340 |
|
341 /** |
|
342 * To avoid writing to disk multiple times, we encapsulate writes in a |
|
343 * helper class. Be sure to call <code>commit</code> to flush to disk! |
|
344 */ |
|
345 protected Editor editor() { |
|
346 return new Editor(getSharedPreferences().edit()); |
|
347 } |
|
348 |
|
349 protected static class Editor { |
|
350 protected final SharedPreferences.Editor editor; |
|
351 |
|
352 public Editor(SharedPreferences.Editor editor) { |
|
353 this.editor = editor; |
|
354 } |
|
355 |
|
356 public void commit() { |
|
357 editor.commit(); |
|
358 } |
|
359 |
|
360 // Authoritative. |
|
361 public Editor setFirstRunLocalTime(long localTime) { |
|
362 editor.putLong(HealthReportConstants.PREF_FIRST_RUN, localTime); |
|
363 return this; |
|
364 } |
|
365 |
|
366 // Authoritative. |
|
367 public Editor setNextSubmission(long localTime) { |
|
368 editor.putLong(HealthReportConstants.PREF_NEXT_SUBMISSION, localTime); |
|
369 return this; |
|
370 } |
|
371 |
|
372 // Authoritative. |
|
373 public Editor setCurrentDayFailureCount(int failureCount) { |
|
374 editor.putInt(HealthReportConstants.PREF_CURRENT_DAY_FAILURE_COUNT, failureCount); |
|
375 return this; |
|
376 } |
|
377 |
|
378 // Authoritative. |
|
379 public Editor setCurrentDayResetTime(long resetTime) { |
|
380 editor.putLong(HealthReportConstants.PREF_CURRENT_DAY_RESET_TIME, resetTime); |
|
381 return this; |
|
382 } |
|
383 |
|
384 // Authoritative. |
|
385 public Editor clearCurrentDayResetTime() { |
|
386 editor.putLong(HealthReportConstants.PREF_CURRENT_DAY_RESET_TIME, -1); |
|
387 return this; |
|
388 } |
|
389 |
|
390 // Authoritative. |
|
391 public Editor setLastUploadRequested(long localTime) { |
|
392 editor.putLong(HealthReportConstants.PREF_LAST_UPLOAD_REQUESTED, localTime); |
|
393 return this; |
|
394 } |
|
395 |
|
396 // Forensics only. |
|
397 public Editor setLastUploadSucceeded(long localTime) { |
|
398 editor.putLong(HealthReportConstants.PREF_LAST_UPLOAD_SUCCEEDED, localTime); |
|
399 return this; |
|
400 } |
|
401 |
|
402 // Forensics only. |
|
403 public Editor setLastUploadFailed(long localTime) { |
|
404 editor.putLong(HealthReportConstants.PREF_LAST_UPLOAD_FAILED, localTime); |
|
405 return this; |
|
406 } |
|
407 |
|
408 // Forensics only. |
|
409 public Editor setLastDeleteRequested(long localTime) { |
|
410 editor.putLong(HealthReportConstants.PREF_LAST_DELETE_REQUESTED, localTime); |
|
411 return this; |
|
412 } |
|
413 |
|
414 // Forensics only. |
|
415 public Editor setLastDeleteSucceeded(long localTime) { |
|
416 editor.putLong(HealthReportConstants.PREF_LAST_DELETE_SUCCEEDED, localTime); |
|
417 return this; |
|
418 } |
|
419 |
|
420 // Forensics only. |
|
421 public Editor setLastDeleteFailed(long localTime) { |
|
422 editor.putLong(HealthReportConstants.PREF_LAST_DELETE_FAILED, localTime); |
|
423 return this; |
|
424 } |
|
425 } |
|
426 |
|
427 // Authoritative. |
|
428 public long getLastUploadRequested() { |
|
429 return getSharedPreferences().getLong(HealthReportConstants.PREF_LAST_UPLOAD_REQUESTED, -1); |
|
430 } |
|
431 |
|
432 // Forensics only. |
|
433 public long getLastUploadSucceeded() { |
|
434 return getSharedPreferences().getLong(HealthReportConstants.PREF_LAST_UPLOAD_SUCCEEDED, -1); |
|
435 } |
|
436 |
|
437 // Forensics only. |
|
438 public long getLastUploadFailed() { |
|
439 return getSharedPreferences().getLong(HealthReportConstants.PREF_LAST_UPLOAD_FAILED, -1); |
|
440 } |
|
441 |
|
442 // Forensics only. |
|
443 public long getLastDeleteRequested() { |
|
444 return getSharedPreferences().getLong(HealthReportConstants.PREF_LAST_DELETE_REQUESTED, -1); |
|
445 } |
|
446 |
|
447 // Forensics only. |
|
448 public long getLastDeleteSucceeded() { |
|
449 return getSharedPreferences().getLong(HealthReportConstants.PREF_LAST_DELETE_SUCCEEDED, -1); |
|
450 } |
|
451 |
|
452 // Forensics only. |
|
453 public long getLastDeleteFailed() { |
|
454 return getSharedPreferences().getLong(HealthReportConstants.PREF_LAST_DELETE_FAILED, -1); |
|
455 } |
|
456 |
|
457 public long getMinimumTimeBetweenDeletes() { |
|
458 return getSharedPreferences().getLong(HealthReportConstants.PREF_MINIMUM_TIME_BETWEEN_DELETES, HealthReportConstants.DEFAULT_MINIMUM_TIME_BETWEEN_DELETES); |
|
459 } |
|
460 } |