diff options
Diffstat (limited to 'mobile/android/base/java/org/mozilla/gecko/telemetry/TelemetryUploadService.java')
-rw-r--r-- | mobile/android/base/java/org/mozilla/gecko/telemetry/TelemetryUploadService.java | 347 |
1 files changed, 347 insertions, 0 deletions
diff --git a/mobile/android/base/java/org/mozilla/gecko/telemetry/TelemetryUploadService.java b/mobile/android/base/java/org/mozilla/gecko/telemetry/TelemetryUploadService.java new file mode 100644 index 000000000..543281174 --- /dev/null +++ b/mobile/android/base/java/org/mozilla/gecko/telemetry/TelemetryUploadService.java @@ -0,0 +1,347 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package org.mozilla.gecko.telemetry; + +import android.app.IntentService; +import android.content.Context; +import android.content.Intent; +import android.util.Log; +import ch.boye.httpclientandroidlib.HttpHeaders; +import ch.boye.httpclientandroidlib.HttpResponse; +import ch.boye.httpclientandroidlib.client.ClientProtocolException; +import ch.boye.httpclientandroidlib.client.methods.HttpRequestBase; +import ch.boye.httpclientandroidlib.impl.client.DefaultHttpClient; +import org.mozilla.gecko.GeckoProfile; +import org.mozilla.gecko.preferences.GeckoPreferences; +import org.mozilla.gecko.restrictions.Restrictable; +import org.mozilla.gecko.restrictions.Restrictions; +import org.mozilla.gecko.sync.ExtendedJSONObject; +import org.mozilla.gecko.sync.net.BaseResource; +import org.mozilla.gecko.sync.net.BaseResourceDelegate; +import org.mozilla.gecko.sync.net.Resource; +import org.mozilla.gecko.telemetry.stores.TelemetryPingStore; +import org.mozilla.gecko.util.DateUtil; +import org.mozilla.gecko.util.NetworkUtils; +import org.mozilla.gecko.util.StringUtils; + +import java.io.IOException; +import java.net.URISyntaxException; +import java.security.GeneralSecurityException; +import java.util.Calendar; +import java.util.HashSet; +import java.util.List; +import java.util.Set; +import java.util.concurrent.TimeUnit; + +/** + * The service that handles retrieving a list of telemetry pings to upload from the given + * {@link TelemetryPingStore}, uploading those payloads to the associated server, and reporting + * back to the Store which uploads were a success. + */ +public class TelemetryUploadService extends IntentService { + private static final String LOGTAG = StringUtils.safeSubstring("Gecko" + TelemetryUploadService.class.getSimpleName(), 0, 23); + private static final String WORKER_THREAD_NAME = LOGTAG + "Worker"; + + public static final String ACTION_UPLOAD = "upload"; + public static final String EXTRA_STORE = "store"; + + // TelemetryUploadService can run in a background thread so for future proofing, we set it volatile. + private static volatile boolean isDisabled = false; + + public static void setDisabled(final boolean isDisabled) { + TelemetryUploadService.isDisabled = isDisabled; + if (isDisabled) { + Log.d(LOGTAG, "Telemetry upload disabled (env var?"); + } + } + + public TelemetryUploadService() { + super(WORKER_THREAD_NAME); + + // Intent redelivery can fail hard (e.g. we OOM as we try to upload, the Intent gets redelivered, repeat) + // so for simplicity, we avoid it. We expect the upload service to eventually get called again by the caller. + setIntentRedelivery(false); + } + + /** + * Handles a ping with the mandatory extras: + * * EXTRA_STORE: A {@link TelemetryPingStore} where the pings to upload are located + */ + @Override + public void onHandleIntent(final Intent intent) { + Log.d(LOGTAG, "Service started"); + + if (!isReadyToUpload(this, intent)) { + return; + } + + final TelemetryPingStore store = intent.getParcelableExtra(EXTRA_STORE); + final boolean wereAllUploadsSuccessful = uploadPendingPingsFromStore(this, store); + store.maybePrunePings(); + Log.d(LOGTAG, "Service finished: upload and prune attempts completed"); + + if (!wereAllUploadsSuccessful) { + // If we had an upload failure, we should stop the IntentService and drop any + // pending Intents in the queue so we don't waste resources (e.g. battery) + // trying to upload when there's likely to be another connection failure. + Log.d(LOGTAG, "Clearing Intent queue due to connection failures"); + stopSelf(); + } + } + + /** + * @return true if all pings were uploaded successfully, false otherwise. + */ + private static boolean uploadPendingPingsFromStore(final Context context, final TelemetryPingStore store) { + final List<TelemetryPing> pingsToUpload = store.getAllPings(); + if (pingsToUpload.isEmpty()) { + return true; + } + + final String serverSchemeHostPort = TelemetryPreferences.getServerSchemeHostPort(context, store.getProfileName()); + final HashSet<String> successfulUploadIDs = new HashSet<>(pingsToUpload.size()); // used for side effects. + final PingResultDelegate delegate = new PingResultDelegate(successfulUploadIDs); + for (final TelemetryPing ping : pingsToUpload) { + // TODO: It'd be great to re-use the same HTTP connection for each upload request. + delegate.setDocID(ping.getDocID()); + final String url = serverSchemeHostPort + "/" + ping.getURLPath(); + uploadPayload(url, ping.getPayload(), delegate); + + // There are minimal gains in trying to upload if we already failed one attempt. + if (delegate.hadConnectionError()) { + break; + } + } + + final boolean wereAllUploadsSuccessful = !delegate.hadConnectionError(); + if (wereAllUploadsSuccessful) { + // We don't log individual successful uploads to avoid log spam. + Log.d(LOGTAG, "Telemetry upload success!"); + } + store.onUploadAttemptComplete(successfulUploadIDs); + return wereAllUploadsSuccessful; + } + + private static void uploadPayload(final String url, final ExtendedJSONObject payload, final ResultDelegate delegate) { + final BaseResource resource; + try { + resource = new BaseResource(url); + } catch (final URISyntaxException e) { + Log.w(LOGTAG, "URISyntaxException for server URL when creating BaseResource: returning."); + return; + } + + delegate.setResource(resource); + resource.delegate = delegate; + resource.setShouldCompressUploadedEntity(true); + resource.setShouldChunkUploadsHint(false); // Telemetry servers don't support chunking. + + // We're in a background thread so we don't have any reason to do this asynchronously. + // If we tried, onStartCommand would return and IntentService might stop itself before we finish. + resource.postBlocking(payload); + } + + private static boolean isReadyToUpload(final Context context, final Intent intent) { + // Sanity check: is upload enabled? Generally, the caller should check this before starting the service. + // Since we don't have the profile here, we rely on the caller to check the enabled state for the profile. + if (!isUploadEnabledByAppConfig(context)) { + Log.w(LOGTAG, "Upload is not available by configuration; returning"); + return false; + } + + if (!NetworkUtils.isConnected(context)) { + Log.w(LOGTAG, "Network is not connected; returning"); + return false; + } + + if (!isIntentValid(intent)) { + Log.w(LOGTAG, "Received invalid Intent; returning"); + return false; + } + + if (!ACTION_UPLOAD.equals(intent.getAction())) { + Log.w(LOGTAG, "Unknown action: " + intent.getAction() + ". Returning"); + return false; + } + + return true; + } + + /** + * Determines if the telemetry upload feature is enabled via the application configuration. Prefer to use + * {@link #isUploadEnabledByProfileConfig(Context, GeckoProfile)} if the profile is available as it takes into + * account more information. + * + * You may wish to also check if the network is connected when calling this method. + * + * Note that this method logs debug statements when upload is disabled. + */ + public static boolean isUploadEnabledByAppConfig(final Context context) { + if (!TelemetryConstants.UPLOAD_ENABLED) { + Log.d(LOGTAG, "Telemetry upload feature is compile-time disabled"); + return false; + } + + if (isDisabled) { + Log.d(LOGTAG, "Telemetry upload feature is disabled by intent (in testing?)"); + return false; + } + + if (!GeckoPreferences.getBooleanPref(context, GeckoPreferences.PREFS_HEALTHREPORT_UPLOAD_ENABLED, true)) { + Log.d(LOGTAG, "Telemetry upload opt-out"); + return false; + } + + if (Restrictions.isRestrictedProfile(context) && + !Restrictions.isAllowed(context, Restrictable.HEALTH_REPORT)) { + Log.d(LOGTAG, "Telemetry upload feature disabled by admin profile"); + return false; + } + + return true; + } + + /** + * Determines if the telemetry upload feature is enabled via profile & application level configurations. This is the + * preferred method. + * + * You may wish to also check if the network is connected when calling this method. + * + * Note that this method logs debug statements when upload is disabled. + */ + public static boolean isUploadEnabledByProfileConfig(final Context context, final GeckoProfile profile) { + if (profile.inGuestMode()) { + Log.d(LOGTAG, "Profile is in guest mode"); + return false; + } + + return isUploadEnabledByAppConfig(context); + } + + private static boolean isIntentValid(final Intent intent) { + // Intent can be null. Bug 1025937. + if (intent == null) { + Log.d(LOGTAG, "Received null intent"); + return false; + } + + if (intent.getParcelableExtra(EXTRA_STORE) == null) { + Log.d(LOGTAG, "Received invalid store in Intent"); + return false; + } + + return true; + } + + /** + * Logs on success & failure and appends the set ID to the given Set on success. + * + * Note: you *must* set the ping ID before attempting upload or we'll throw! + * + * We use mutation on the set ID and the successful upload array to avoid object allocation. + */ + private static class PingResultDelegate extends ResultDelegate { + // We persist pings and don't need to worry about losing data so we keep these + // durations short to save resources (e.g. battery). + private static final int SOCKET_TIMEOUT_MILLIS = (int) TimeUnit.SECONDS.toMillis(30); + private static final int CONNECTION_TIMEOUT_MILLIS = (int) TimeUnit.SECONDS.toMillis(30); + + /** The store ID of the ping currently being uploaded. Use {@link #getDocID()} to access it. */ + private String docID = null; + private final Set<String> successfulUploadIDs; + + private boolean hadConnectionError = false; + + public PingResultDelegate(final Set<String> successfulUploadIDs) { + super(); + this.successfulUploadIDs = successfulUploadIDs; + } + + @Override + public int socketTimeout() { + return SOCKET_TIMEOUT_MILLIS; + } + + @Override + public int connectionTimeout() { + return CONNECTION_TIMEOUT_MILLIS; + } + + private String getDocID() { + if (docID == null) { + throw new IllegalStateException("Expected ping ID to have been updated before retrieval"); + } + return docID; + } + + public void setDocID(final String id) { + docID = id; + } + + @Override + public String getUserAgent() { + return TelemetryConstants.USER_AGENT; + } + + @Override + public void handleHttpResponse(final HttpResponse response) { + final int status = response.getStatusLine().getStatusCode(); + switch (status) { + case 200: + case 201: + successfulUploadIDs.add(getDocID()); + break; + default: + Log.w(LOGTAG, "Telemetry upload failure. HTTP status: " + status); + hadConnectionError = true; + } + } + + @Override + public void handleHttpProtocolException(final ClientProtocolException e) { + // We don't log the exception to prevent leaking user data. + Log.w(LOGTAG, "HttpProtocolException when trying to upload telemetry"); + hadConnectionError = true; + } + + @Override + public void handleHttpIOException(final IOException e) { + // We don't log the exception to prevent leaking user data. + Log.w(LOGTAG, "HttpIOException when trying to upload telemetry"); + hadConnectionError = true; + } + + @Override + public void handleTransportException(final GeneralSecurityException e) { + // We don't log the exception to prevent leaking user data. + Log.w(LOGTAG, "Transport exception when trying to upload telemetry"); + hadConnectionError = true; + } + + private boolean hadConnectionError() { + return hadConnectionError; + } + + @Override + public void addHeaders(final HttpRequestBase request, final DefaultHttpClient client) { + super.addHeaders(request, client); + request.addHeader(HttpHeaders.DATE, DateUtil.getDateInHTTPFormat(Calendar.getInstance().getTime())); + } + } + + /** + * A hack because I want to set the resource after the Delegate is constructed. + * Be sure to call {@link #setResource(Resource)}! + */ + private static abstract class ResultDelegate extends BaseResourceDelegate { + public ResultDelegate() { + super(null); + } + + protected void setResource(final Resource resource) { + this.resource = resource; + } + } +} |