mobile/android/base/background/datareporting/TelemetryRecorder.java

changeset 0
6474c204b198
     1.1 --- /dev/null	Thu Jan 01 00:00:00 1970 +0000
     1.2 +++ b/mobile/android/base/background/datareporting/TelemetryRecorder.java	Wed Dec 31 06:09:35 2014 +0100
     1.3 @@ -0,0 +1,316 @@
     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.datareporting;
     1.9 +
    1.10 +import java.io.BufferedOutputStream;
    1.11 +import java.io.File;
    1.12 +import java.io.FileOutputStream;
    1.13 +import java.io.IOException;
    1.14 +import java.io.OutputStream;
    1.15 +import java.io.UnsupportedEncodingException;
    1.16 +import java.security.MessageDigest;
    1.17 +import java.security.NoSuchAlgorithmException;
    1.18 +
    1.19 +import org.json.JSONObject;
    1.20 +import org.mozilla.gecko.background.common.log.Logger;
    1.21 +
    1.22 +import android.util.Base64;
    1.23 +
    1.24 +/**
    1.25 + * Writes telemetry ping to file.
    1.26 + *
    1.27 + * Also creates and updates a SHA-256 checksum for the payload to be included in the ping
    1.28 + * file.
    1.29 + *
    1.30 + * A saved telemetry ping file consists of JSON in the following format,
    1.31 + *   {
    1.32 + *     "slug": "<uuid-string>",
    1.33 + *     "payload": "<escaped-json-data-string>",
    1.34 + *     "checksum": "<base64-sha-256-string>"
    1.35 + *   }
    1.36 + *
    1.37 + * This class writes first to a temporary file and then, after finishing the contents of the ping,
    1.38 + * moves that to the file specified by the caller. This is to avoid uploads of partially written
    1.39 + * ping files.
    1.40 + *
    1.41 + * The API provided by this class:
    1.42 + * startPingFile() - opens stream to a tmp File in the Android cache directory and writes the slug header
    1.43 + * appendPayload(String payloadContent) - appends to the payload of the ping and updates the checksum
    1.44 + * finishPingFile() - writes the checksum to the tmp file and moves it to the File specified by the caller.
    1.45 + *
    1.46 + * In the case of errors, we try to close the stream and File.
    1.47 + */
    1.48 +public class TelemetryRecorder {
    1.49 +  private final String LOG_TAG = "TelemetryRecorder";
    1.50 +
    1.51 +  private final File parentDir;
    1.52 +  private final String filename;
    1.53 +
    1.54 +  private File tmpFile;
    1.55 +  private File destFile;
    1.56 +  private File cacheDir;
    1.57 +
    1.58 +  private OutputStream  outputStream;
    1.59 +  private MessageDigest checksum;
    1.60 +  private String        base64Checksum;
    1.61 +
    1.62 +  /**
    1.63 +   * Charset to use for writing pings; default is us-ascii.
    1.64 +   *
    1.65 +   * When telemetry calculates the checksum for the ping file, it lossily
    1.66 +   * converts utf-16 to ascii. Therefore we have to treat characters in the
    1.67 +   * traces as ascii rather than say utf-8. Otherwise we will get a "wrong"
    1.68 +   * checksum.
    1.69 +   */
    1.70 +  private String charset = "us-ascii";
    1.71 +
    1.72 +  /**
    1.73 +   * Override blockSize in constructor if desired.
    1.74 +   * Default block size is that of BufferedOutputStream.
    1.75 +   */
    1.76 +  private int blockSize = 0;
    1.77 +
    1.78 +  /**
    1.79 +   * Constructs a TelemetryRecorder for writing a ping file. A temporary file will be written first,
    1.80 +   * and then moved to the destination file location specified by the caller.
    1.81 +   *
    1.82 +   * The directory for writing the temporary file is highly suggested to be the Android internal cache directory,
    1.83 +   * fetched by <code>context.getCacheDir()</code>
    1.84 +   *
    1.85 +   * If the destination file already exists, it will be deleted and overwritten.
    1.86 +   *
    1.87 +   * Default charset: "us-ascii"
    1.88 +   * Default block size: uses constructor default of 8192 bytes (see javadocs for
    1.89 +   *                     <code>BufferedOutputStream</code>
    1.90 +   * @param parentPath
    1.91 +   *        path of parent directory of ping file to be written
    1.92 +   * @param cacheDir
    1.93 +   *        path of cache directory for writing temporary files.
    1.94 +   * @param filename
    1.95 +   *        name of ping file to be written
    1.96 +   */
    1.97 +  public TelemetryRecorder(File parentDir, File cacheDir, String filename) {
    1.98 +    if (!parentDir.isDirectory()) {
    1.99 +      throw new IllegalArgumentException("Expecting directory, got non-directory File instead.");
   1.100 +    }
   1.101 +    this.parentDir = parentDir;
   1.102 +    this.filename = filename;
   1.103 +    this.cacheDir = cacheDir;
   1.104 +  }
   1.105 +
   1.106 +  public TelemetryRecorder(File parentDir, File cacheDir, String filename, String charset) {
   1.107 +    this(parentDir, cacheDir, filename);
   1.108 +    this.charset = charset;
   1.109 +  }
   1.110 +
   1.111 +  public TelemetryRecorder(File parentDir, File cacheDir, String filename, String charset, int blockSize) {
   1.112 +    this(parentDir, cacheDir, filename, charset);
   1.113 +    this.blockSize = blockSize;
   1.114 +  }
   1.115 +
   1.116 +  /**
   1.117 +   * Start the temporary ping file and write the "slug" header and payload key, of the
   1.118 +   * format:
   1.119 +   *
   1.120 +   *     { "slug": "< filename >", "payload":
   1.121 +   *
   1.122 +   * @throws Exception
   1.123 +   *           Checked exceptions <code>NoSuchAlgorithmException</code>,
   1.124 +   *           <code>UnsupportedEncodingException</code>, or
   1.125 +   *           <code>IOException</code> and unchecked exception that
   1.126 +   *           are rethrown to caller
   1.127 +   */
   1.128 +  public void startPingFile() throws Exception {
   1.129 +
   1.130 +    // Open stream to temporary file for writing.
   1.131 +    try {
   1.132 +      tmpFile = File.createTempFile(filename, "tmp", cacheDir);
   1.133 +    } catch (IOException e) {
   1.134 +      // Try to create the temporary file in the ping directory.
   1.135 +      tmpFile = new File(parentDir, filename + ".tmp");
   1.136 +      try {
   1.137 +        tmpFile.createNewFile();
   1.138 +      } catch (IOException e1) {
   1.139 +        cleanUpAndRethrow("Failed to create tmp file in temp directory or ping directory.", e1);
   1.140 +      }
   1.141 +    }
   1.142 +
   1.143 +    try {
   1.144 +      if (blockSize > 0) {
   1.145 +        outputStream = new BufferedOutputStream(new FileOutputStream(tmpFile), blockSize);
   1.146 +      } else {
   1.147 +        outputStream = new BufferedOutputStream(new FileOutputStream(tmpFile));
   1.148 +      }
   1.149 +
   1.150 +      // Create checksum for ping.
   1.151 +      checksum = MessageDigest.getInstance("SHA-256");
   1.152 +
   1.153 +      // Write ping header.
   1.154 +      byte[] header = makePingHeader(filename);
   1.155 +      outputStream.write(header);
   1.156 +      Logger.debug(LOG_TAG, "Wrote " + header.length + " header bytes.");
   1.157 +
   1.158 +    } catch (NoSuchAlgorithmException e) {
   1.159 +      cleanUpAndRethrow("Error creating checksum digest", e);
   1.160 +    } catch (UnsupportedEncodingException e) {
   1.161 +      cleanUpAndRethrow("Error writing header", e);
   1.162 +    } catch (IOException e) {
   1.163 +      cleanUpAndRethrow("Error writing to stream", e);
   1.164 +    }
   1.165 +  }
   1.166 +
   1.167 +  private byte[] makePingHeader(String slug)
   1.168 +      throws UnsupportedEncodingException {
   1.169 +    return ("{\"slug\":" + JSONObject.quote(slug) + "," + "\"payload\":\"")
   1.170 +        .getBytes(charset);
   1.171 +  }
   1.172 +
   1.173 +  /**
   1.174 +   * Append payloadContent to ping file and update the checksum.
   1.175 +   *
   1.176 +   * @param payloadContent
   1.177 +   *          String content to be written
   1.178 +   * @return number of bytes written, or -1 if writing failed
   1.179 +   * @throws Exception
   1.180 +   *           Checked exceptions <code>UnsupportedEncodingException</code> or
   1.181 +   *           <code>IOException</code> and unchecked exception that
   1.182 +   *           are rethrown to caller
   1.183 +   */
   1.184 +  public int appendPayload(String payloadContent) throws Exception {
   1.185 +    if (payloadContent == null) {
   1.186 +      cleanUpAndRethrow("Payload is null", new Exception());
   1.187 +      return -1;
   1.188 +    }
   1.189 +
   1.190 +    try {
   1.191 +      byte[] payloadBytes = payloadContent.getBytes(charset);
   1.192 +      // If we run into an error, we'll throw and abort, so checksum won't be stale.
   1.193 +      checksum.update(payloadBytes);
   1.194 +
   1.195 +      byte[] quotedPayloadBytes = JSONObject.quote(payloadContent).getBytes(charset);
   1.196 +
   1.197 +      // First and last bytes are quotes inserted by JSONObject.quote; discard
   1.198 +      // them.
   1.199 +      int numBytes = quotedPayloadBytes.length - 2;
   1.200 +      outputStream.write(quotedPayloadBytes, 1, numBytes);
   1.201 +      return numBytes;
   1.202 +
   1.203 +    } catch (UnsupportedEncodingException e) {
   1.204 +      cleanUpAndRethrow("Error encoding payload", e);
   1.205 +      return -1;
   1.206 +    } catch (IOException e) {
   1.207 +      cleanUpAndRethrow("Error writing to stream", e);
   1.208 +      return -1;
   1.209 +    }
   1.210 +  }
   1.211 +
   1.212 +  /**
   1.213 +   * Add the checksum of the payload to the ping file and close the stream.
   1.214 +   *
   1.215 +   * @throws Exception
   1.216 +   *          Checked exceptions <code>UnsupportedEncodingException</code> or
   1.217 +   *          <code>IOException</code> and unchecked exception that
   1.218 +   *          are rethrown to caller
   1.219 +   */
   1.220 +  public void finishPingFile() throws Exception {
   1.221 +    try {
   1.222 +      byte[] footer = makePingFooter(checksum);
   1.223 +      outputStream.write(footer);
   1.224 +      // We're done writing, so force the stream to flush the buffer.
   1.225 +      outputStream.flush();
   1.226 +      Logger.debug(LOG_TAG, "Wrote " + footer.length + " footer bytes.");
   1.227 +    } catch (UnsupportedEncodingException e) {
   1.228 +      cleanUpAndRethrow("Checksum encoding exception", e);
   1.229 +    } catch (IOException e) {
   1.230 +      cleanUpAndRethrow("Error writing footer to stream", e);
   1.231 +    } finally {
   1.232 +      try {
   1.233 +        outputStream.close();
   1.234 +      } catch (IOException e) {
   1.235 +        // Failed to close, nothing we can do except discard the reference to the stream.
   1.236 +        outputStream = null;
   1.237 +      }
   1.238 +    }
   1.239 +
   1.240 +    // Move temp file to destination specified by caller.
   1.241 +    try {
   1.242 +      File destFile = new File(parentDir, filename);
   1.243 +      // Delete file if it exists - docs state that rename may fail if the File already exists.
   1.244 +      if (destFile.exists()) {
   1.245 +        destFile.delete();
   1.246 +      }
   1.247 +      boolean result = tmpFile.renameTo(destFile);
   1.248 +      if (!result) {
   1.249 +        throw new IOException("Could not move tmp file to destination.");
   1.250 +      }
   1.251 +    } finally {
   1.252 +      cleanUp();
   1.253 +    }
   1.254 +  }
   1.255 +
   1.256 +  private byte[] makePingFooter(MessageDigest checksum)
   1.257 +      throws UnsupportedEncodingException {
   1.258 +    base64Checksum = Base64.encodeToString(checksum.digest(), Base64.NO_WRAP);
   1.259 +    return ("\",\"checksum\":" + JSONObject.quote(base64Checksum) + "}")
   1.260 +        .getBytes(charset);
   1.261 +  }
   1.262 +
   1.263 +  /**
   1.264 +   * Get final digested Base64 checksum.
   1.265 +   *
   1.266 +   * @return String checksum if it has been calculated, null otherwise.
   1.267 +   */
   1.268 +  protected String getFinalChecksum() {
   1.269 +    return base64Checksum;
   1.270 +  }
   1.271 +
   1.272 +  public String getCharset() {
   1.273 +    return this.charset;
   1.274 +  }
   1.275 +
   1.276 +  /**
   1.277 +   * Clean up checksum and delete the temporary file.
   1.278 +   */
   1.279 +  private void cleanUp() {
   1.280 +    // Discard checksum.
   1.281 +    checksum.reset();
   1.282 +
   1.283 +    // Clean up files.
   1.284 +    if (tmpFile != null && tmpFile.exists()) {
   1.285 +      tmpFile.delete();
   1.286 +    }
   1.287 +    tmpFile = null;
   1.288 +  }
   1.289 +
   1.290 +  /**
   1.291 +   * Log message and error and clean up, then rethrow exception to caller.
   1.292 +   *
   1.293 +   * @param message
   1.294 +   *          Error message
   1.295 +   * @param e
   1.296 +   *          Exception
   1.297 +   *
   1.298 +   * @throws Exception
   1.299 +   *           Exception to be rethrown to caller
   1.300 +   */
   1.301 +  private void cleanUpAndRethrow(String message, Exception e) throws Exception {
   1.302 +    Logger.error(LOG_TAG, message, e);
   1.303 +    cleanUp();
   1.304 +
   1.305 +    if (outputStream != null) {
   1.306 +      try {
   1.307 +        outputStream.close();
   1.308 +      } catch (IOException exception) {
   1.309 +        // Failed to close stream; nothing we can do, and we're aborting anyways.
   1.310 +      }
   1.311 +    }
   1.312 +
   1.313 +    if (destFile != null && destFile.exists()) {
   1.314 +      destFile.delete();
   1.315 +    }
   1.316 +    // Rethrow the exception.
   1.317 +    throw e;
   1.318 +  }
   1.319 +}

mercurial