|
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 } |