/* 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 pingsToUpload = store.getAllPings(); if (pingsToUpload.isEmpty()) { return true; } final String serverSchemeHostPort = TelemetryPreferences.getServerSchemeHostPort(context, store.getProfileName()); final HashSet 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 successfulUploadIDs; private boolean hadConnectionError = false; public PingResultDelegate(final Set 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; } } }