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

changeset 0
6474c204b198
equal deleted inserted replaced
-1:000000000000 0:8019b7ccdea1
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.datareporting;
6
7 import java.io.BufferedOutputStream;
8 import java.io.File;
9 import java.io.FileOutputStream;
10 import java.io.IOException;
11 import java.io.OutputStream;
12 import java.io.UnsupportedEncodingException;
13 import java.security.MessageDigest;
14 import java.security.NoSuchAlgorithmException;
15
16 import org.json.JSONObject;
17 import org.mozilla.gecko.background.common.log.Logger;
18
19 import android.util.Base64;
20
21 /**
22 * Writes telemetry ping to file.
23 *
24 * Also creates and updates a SHA-256 checksum for the payload to be included in the ping
25 * file.
26 *
27 * A saved telemetry ping file consists of JSON in the following format,
28 * {
29 * "slug": "<uuid-string>",
30 * "payload": "<escaped-json-data-string>",
31 * "checksum": "<base64-sha-256-string>"
32 * }
33 *
34 * This class writes first to a temporary file and then, after finishing the contents of the ping,
35 * moves that to the file specified by the caller. This is to avoid uploads of partially written
36 * ping files.
37 *
38 * The API provided by this class:
39 * startPingFile() - opens stream to a tmp File in the Android cache directory and writes the slug header
40 * appendPayload(String payloadContent) - appends to the payload of the ping and updates the checksum
41 * finishPingFile() - writes the checksum to the tmp file and moves it to the File specified by the caller.
42 *
43 * In the case of errors, we try to close the stream and File.
44 */
45 public class TelemetryRecorder {
46 private final String LOG_TAG = "TelemetryRecorder";
47
48 private final File parentDir;
49 private final String filename;
50
51 private File tmpFile;
52 private File destFile;
53 private File cacheDir;
54
55 private OutputStream outputStream;
56 private MessageDigest checksum;
57 private String base64Checksum;
58
59 /**
60 * Charset to use for writing pings; default is us-ascii.
61 *
62 * When telemetry calculates the checksum for the ping file, it lossily
63 * converts utf-16 to ascii. Therefore we have to treat characters in the
64 * traces as ascii rather than say utf-8. Otherwise we will get a "wrong"
65 * checksum.
66 */
67 private String charset = "us-ascii";
68
69 /**
70 * Override blockSize in constructor if desired.
71 * Default block size is that of BufferedOutputStream.
72 */
73 private int blockSize = 0;
74
75 /**
76 * Constructs a TelemetryRecorder for writing a ping file. A temporary file will be written first,
77 * and then moved to the destination file location specified by the caller.
78 *
79 * The directory for writing the temporary file is highly suggested to be the Android internal cache directory,
80 * fetched by <code>context.getCacheDir()</code>
81 *
82 * If the destination file already exists, it will be deleted and overwritten.
83 *
84 * Default charset: "us-ascii"
85 * Default block size: uses constructor default of 8192 bytes (see javadocs for
86 * <code>BufferedOutputStream</code>
87 * @param parentPath
88 * path of parent directory of ping file to be written
89 * @param cacheDir
90 * path of cache directory for writing temporary files.
91 * @param filename
92 * name of ping file to be written
93 */
94 public TelemetryRecorder(File parentDir, File cacheDir, String filename) {
95 if (!parentDir.isDirectory()) {
96 throw new IllegalArgumentException("Expecting directory, got non-directory File instead.");
97 }
98 this.parentDir = parentDir;
99 this.filename = filename;
100 this.cacheDir = cacheDir;
101 }
102
103 public TelemetryRecorder(File parentDir, File cacheDir, String filename, String charset) {
104 this(parentDir, cacheDir, filename);
105 this.charset = charset;
106 }
107
108 public TelemetryRecorder(File parentDir, File cacheDir, String filename, String charset, int blockSize) {
109 this(parentDir, cacheDir, filename, charset);
110 this.blockSize = blockSize;
111 }
112
113 /**
114 * Start the temporary ping file and write the "slug" header and payload key, of the
115 * format:
116 *
117 * { "slug": "< filename >", "payload":
118 *
119 * @throws Exception
120 * Checked exceptions <code>NoSuchAlgorithmException</code>,
121 * <code>UnsupportedEncodingException</code>, or
122 * <code>IOException</code> and unchecked exception that
123 * are rethrown to caller
124 */
125 public void startPingFile() throws Exception {
126
127 // Open stream to temporary file for writing.
128 try {
129 tmpFile = File.createTempFile(filename, "tmp", cacheDir);
130 } catch (IOException e) {
131 // Try to create the temporary file in the ping directory.
132 tmpFile = new File(parentDir, filename + ".tmp");
133 try {
134 tmpFile.createNewFile();
135 } catch (IOException e1) {
136 cleanUpAndRethrow("Failed to create tmp file in temp directory or ping directory.", e1);
137 }
138 }
139
140 try {
141 if (blockSize > 0) {
142 outputStream = new BufferedOutputStream(new FileOutputStream(tmpFile), blockSize);
143 } else {
144 outputStream = new BufferedOutputStream(new FileOutputStream(tmpFile));
145 }
146
147 // Create checksum for ping.
148 checksum = MessageDigest.getInstance("SHA-256");
149
150 // Write ping header.
151 byte[] header = makePingHeader(filename);
152 outputStream.write(header);
153 Logger.debug(LOG_TAG, "Wrote " + header.length + " header bytes.");
154
155 } catch (NoSuchAlgorithmException e) {
156 cleanUpAndRethrow("Error creating checksum digest", e);
157 } catch (UnsupportedEncodingException e) {
158 cleanUpAndRethrow("Error writing header", e);
159 } catch (IOException e) {
160 cleanUpAndRethrow("Error writing to stream", e);
161 }
162 }
163
164 private byte[] makePingHeader(String slug)
165 throws UnsupportedEncodingException {
166 return ("{\"slug\":" + JSONObject.quote(slug) + "," + "\"payload\":\"")
167 .getBytes(charset);
168 }
169
170 /**
171 * Append payloadContent to ping file and update the checksum.
172 *
173 * @param payloadContent
174 * String content to be written
175 * @return number of bytes written, or -1 if writing failed
176 * @throws Exception
177 * Checked exceptions <code>UnsupportedEncodingException</code> or
178 * <code>IOException</code> and unchecked exception that
179 * are rethrown to caller
180 */
181 public int appendPayload(String payloadContent) throws Exception {
182 if (payloadContent == null) {
183 cleanUpAndRethrow("Payload is null", new Exception());
184 return -1;
185 }
186
187 try {
188 byte[] payloadBytes = payloadContent.getBytes(charset);
189 // If we run into an error, we'll throw and abort, so checksum won't be stale.
190 checksum.update(payloadBytes);
191
192 byte[] quotedPayloadBytes = JSONObject.quote(payloadContent).getBytes(charset);
193
194 // First and last bytes are quotes inserted by JSONObject.quote; discard
195 // them.
196 int numBytes = quotedPayloadBytes.length - 2;
197 outputStream.write(quotedPayloadBytes, 1, numBytes);
198 return numBytes;
199
200 } catch (UnsupportedEncodingException e) {
201 cleanUpAndRethrow("Error encoding payload", e);
202 return -1;
203 } catch (IOException e) {
204 cleanUpAndRethrow("Error writing to stream", e);
205 return -1;
206 }
207 }
208
209 /**
210 * Add the checksum of the payload to the ping file and close the stream.
211 *
212 * @throws Exception
213 * Checked exceptions <code>UnsupportedEncodingException</code> or
214 * <code>IOException</code> and unchecked exception that
215 * are rethrown to caller
216 */
217 public void finishPingFile() throws Exception {
218 try {
219 byte[] footer = makePingFooter(checksum);
220 outputStream.write(footer);
221 // We're done writing, so force the stream to flush the buffer.
222 outputStream.flush();
223 Logger.debug(LOG_TAG, "Wrote " + footer.length + " footer bytes.");
224 } catch (UnsupportedEncodingException e) {
225 cleanUpAndRethrow("Checksum encoding exception", e);
226 } catch (IOException e) {
227 cleanUpAndRethrow("Error writing footer to stream", e);
228 } finally {
229 try {
230 outputStream.close();
231 } catch (IOException e) {
232 // Failed to close, nothing we can do except discard the reference to the stream.
233 outputStream = null;
234 }
235 }
236
237 // Move temp file to destination specified by caller.
238 try {
239 File destFile = new File(parentDir, filename);
240 // Delete file if it exists - docs state that rename may fail if the File already exists.
241 if (destFile.exists()) {
242 destFile.delete();
243 }
244 boolean result = tmpFile.renameTo(destFile);
245 if (!result) {
246 throw new IOException("Could not move tmp file to destination.");
247 }
248 } finally {
249 cleanUp();
250 }
251 }
252
253 private byte[] makePingFooter(MessageDigest checksum)
254 throws UnsupportedEncodingException {
255 base64Checksum = Base64.encodeToString(checksum.digest(), Base64.NO_WRAP);
256 return ("\",\"checksum\":" + JSONObject.quote(base64Checksum) + "}")
257 .getBytes(charset);
258 }
259
260 /**
261 * Get final digested Base64 checksum.
262 *
263 * @return String checksum if it has been calculated, null otherwise.
264 */
265 protected String getFinalChecksum() {
266 return base64Checksum;
267 }
268
269 public String getCharset() {
270 return this.charset;
271 }
272
273 /**
274 * Clean up checksum and delete the temporary file.
275 */
276 private void cleanUp() {
277 // Discard checksum.
278 checksum.reset();
279
280 // Clean up files.
281 if (tmpFile != null && tmpFile.exists()) {
282 tmpFile.delete();
283 }
284 tmpFile = null;
285 }
286
287 /**
288 * Log message and error and clean up, then rethrow exception to caller.
289 *
290 * @param message
291 * Error message
292 * @param e
293 * Exception
294 *
295 * @throws Exception
296 * Exception to be rethrown to caller
297 */
298 private void cleanUpAndRethrow(String message, Exception e) throws Exception {
299 Logger.error(LOG_TAG, message, e);
300 cleanUp();
301
302 if (outputStream != null) {
303 try {
304 outputStream.close();
305 } catch (IOException exception) {
306 // Failed to close stream; nothing we can do, and we're aborting anyways.
307 }
308 }
309
310 if (destFile != null && destFile.exists()) {
311 destFile.delete();
312 }
313 // Rethrow the exception.
314 throw e;
315 }
316 }

mercurial