|
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.healthreport; |
|
6 |
|
7 import java.io.File; |
|
8 import java.util.Collection; |
|
9 import java.util.HashMap; |
|
10 import java.util.concurrent.ConcurrentHashMap; |
|
11 import java.util.concurrent.Executor; |
|
12 import java.util.concurrent.Executors; |
|
13 |
|
14 import org.json.JSONObject; |
|
15 import org.mozilla.gecko.background.common.DateUtils; |
|
16 import org.mozilla.gecko.background.common.log.Logger; |
|
17 import org.mozilla.gecko.background.healthreport.HealthReportStorage.MeasurementFields.FieldSpec; |
|
18 |
|
19 import android.content.ContentValues; |
|
20 import android.content.Context; |
|
21 import android.content.ContextWrapper; |
|
22 import android.database.Cursor; |
|
23 import android.database.SQLException; |
|
24 import android.database.sqlite.SQLiteConstraintException; |
|
25 import android.database.sqlite.SQLiteDatabase; |
|
26 import android.database.sqlite.SQLiteOpenHelper; |
|
27 import android.os.Build; |
|
28 import android.util.SparseArray; |
|
29 |
|
30 /** |
|
31 * <code>HealthReportDatabaseStorage</code> provides an interface on top of |
|
32 * SQLite storage for Health Report data. It exposes methods for management of |
|
33 * environments, measurements, fields, and values, and a cursor-based API for |
|
34 * querying. |
|
35 * |
|
36 * Health Report data is structured as follows. |
|
37 * |
|
38 * Records are primarily broken down by date, at day granularity. Each day's data |
|
39 * is then split according to environment. An environment is a collection of |
|
40 * constant attributes, such as version and processor; if one of these attributes |
|
41 * changes, a new environment becomes active. |
|
42 * |
|
43 * Environments are identified by a stable hash of their attributes. |
|
44 * |
|
45 * The database includes a persistent numeric identifier for each environment. Create |
|
46 * or fetch this identifier via: |
|
47 * |
|
48 * <pre> |
|
49 * final Environment environment = storage.getEnvironment(); |
|
50 * // Init the environment now. |
|
51 * String envHash = environment.getHash(); |
|
52 * int env = environment.register(); |
|
53 * </pre> |
|
54 * |
|
55 * You can safely cache this environment identifier for the life of the database. |
|
56 * |
|
57 * Orthogonal to environments are measurements. Each measurement is a named and |
|
58 * versioned scope for a collection of fields. It is assumed that if a measurement |
|
59 * with the given name and version is known to the database, then all of its fields |
|
60 * are also known; if you change the collection of fields in the measurement, the |
|
61 * measurement's version must be incremented, too. |
|
62 * |
|
63 * As with environments, measurements have an internal numeric identifier. |
|
64 * |
|
65 * Calling code should initialize its measurements as follows: |
|
66 * |
|
67 * <pre> |
|
68 * public static class FooFieldsV1 implements MeasurementFields { |
|
69 * {@literal @}Override |
|
70 * public Iterable<String> getFields() { |
|
71 * ArrayList<String> fields = new ArrayList<String>(); |
|
72 * fields.add("bar"); |
|
73 * fields.add("baz"); |
|
74 * return fields; |
|
75 * } |
|
76 * } |
|
77 * |
|
78 * storage.beginInitialization(); |
|
79 * |
|
80 * try { |
|
81 * storage.ensureMeasurementInitialized("org.mozilla.fooProvider.fooMeasurement", |
|
82 * 1, new FooFieldsV1()); |
|
83 * storage.finishInitialization(); |
|
84 * } catch (Exception e) { |
|
85 * storage.abortInitialization(); |
|
86 * } |
|
87 * </pre> |
|
88 * |
|
89 * Measurements have fields. Fields can conceptually be divided into "daily last" |
|
90 * (we only care about the last value), "daily counter" (increments per day), |
|
91 * "daily discrete" (multiple records per day). Simply call the correct method for each. |
|
92 * |
|
93 * To do so you need a field ID, to avoid constant costly string lookups. You can get |
|
94 * this value from storage: |
|
95 * |
|
96 * <pre> |
|
97 * Field field = storage.getField("org.mozilla.fooProvider.fooMeasurement", 1, "bar"); |
|
98 * int fieldID = field.getID(); |
|
99 * </pre> |
|
100 * |
|
101 * This lookup is cached, and so is relatively inexpensive. |
|
102 * |
|
103 * You can then do something like the following: |
|
104 * |
|
105 * <pre> |
|
106 * storage.recordDailyLast(storage.getDay(), env, fieldID, "last value"); |
|
107 * </pre> |
|
108 * |
|
109 * or equivalently for numeric values, discrete or counters, etc. |
|
110 * |
|
111 * To retrieve values, use {@link #getRawEventsSince(long)}. |
|
112 * |
|
113 * For safety, perform operations on the storage executor thread: |
|
114 * |
|
115 * <pre> |
|
116 * storage.enqueueOperation(runnable); |
|
117 * </pre> |
|
118 */ |
|
119 public class HealthReportDatabaseStorage implements HealthReportStorage { |
|
120 |
|
121 private static final String WHERE_DATE_AND_ENV_AND_FIELD = "date = ? AND env = ? AND field = ?"; |
|
122 |
|
123 public static final String[] COLUMNS_HASH = new String[] {"hash"}; |
|
124 public static final String[] COLUMNS_DATE_ENV_FIELD_VALUE = new String[] {"date", "env", "field", "value"}; |
|
125 public static final String[] COLUMNS_DATE_ENVSTR_M_MV_F_VALUE = new String[] { |
|
126 "date", "environment", "measurement_name", "measurement_version", |
|
127 "field_name", "field_flags", "value" |
|
128 }; |
|
129 |
|
130 private static final String[] COLUMNS_ENVIRONMENT_DETAILS = new String[] { |
|
131 "id", "version", "hash", |
|
132 "profileCreation", "cpuCount", "memoryMB", |
|
133 |
|
134 "isBlocklistEnabled", "isTelemetryEnabled", "extensionCount", |
|
135 "pluginCount", "themeCount", |
|
136 |
|
137 "architecture", "sysName", "sysVersion", "vendor", "appName", "appID", |
|
138 "appVersion", "appBuildID", "platformVersion", "platformBuildID", "os", |
|
139 "xpcomabi", "updateChannel", |
|
140 |
|
141 "distribution", "osLocale", "appLocale", "acceptLangSet", |
|
142 |
|
143 // Joined to the add-ons table. |
|
144 "addonsBody" |
|
145 }; |
|
146 |
|
147 public static final String[] COLUMNS_MEASUREMENT_DETAILS = new String[] {"id", "name", "version"}; |
|
148 public static final String[] COLUMNS_MEASUREMENT_AND_FIELD_DETAILS = |
|
149 new String[] {"measurement_name", "measurement_id", "measurement_version", |
|
150 "field_name", "field_id", "field_flags"}; |
|
151 |
|
152 private static final String[] COLUMNS_VALUE = new String[] {"value"}; |
|
153 private static final String[] COLUMNS_ID = new String[] {"id"}; |
|
154 |
|
155 private static final String EVENTS_TEXTUAL = "events_textual"; |
|
156 private static final String EVENTS_INTEGER = "events_integer"; |
|
157 |
|
158 protected static final String DB_NAME = "health.db"; |
|
159 |
|
160 private static final String LOG_TAG = "HealthReportStorage"; |
|
161 |
|
162 private final Executor executor = Executors.newSingleThreadExecutor(); |
|
163 |
|
164 @Override |
|
165 public void enqueueOperation(Runnable runnable) { |
|
166 executor.execute(runnable); |
|
167 } |
|
168 |
|
169 public HealthReportDatabaseStorage(final Context context, |
|
170 final File profileDirectory) { |
|
171 this.helper = new HealthReportSQLiteOpenHelper(context, profileDirectory, |
|
172 DB_NAME); |
|
173 executor.execute(new Runnable() { |
|
174 @Override |
|
175 public void run() { |
|
176 Logger.setThreadLogTag(HealthReportConstants.GLOBAL_LOG_TAG); |
|
177 Logger.debug(LOG_TAG, "Creating HealthReportDatabaseStorage."); |
|
178 } |
|
179 }); |
|
180 } |
|
181 |
|
182 @Override |
|
183 public void close() { |
|
184 this.helper.close(); |
|
185 this.fields.clear(); |
|
186 this.envs.clear(); |
|
187 this.measurementVersions.clear(); |
|
188 } |
|
189 |
|
190 protected final HealthReportSQLiteOpenHelper helper; |
|
191 |
|
192 public static class HealthReportSQLiteOpenHelper extends SQLiteOpenHelper { |
|
193 public static final int CURRENT_VERSION = 6; |
|
194 public static final String LOG_TAG = "HealthReportSQL"; |
|
195 |
|
196 /** |
|
197 * A little helper to avoid SQLiteOpenHelper misbehaving on Android 2.1. |
|
198 * Partly cribbed from |
|
199 * <http://stackoverflow.com/questions/5332328/sqliteopenhelper-problem-with-fully-qualified-db-path-name>. |
|
200 */ |
|
201 public static class AbsolutePathContext extends ContextWrapper { |
|
202 private final File parent; |
|
203 |
|
204 public AbsolutePathContext(Context base, File parent) { |
|
205 super(base); |
|
206 this.parent = parent; |
|
207 } |
|
208 |
|
209 @Override |
|
210 public File getDatabasePath(String name) { |
|
211 return new File(getAbsolutePath(parent, name)); |
|
212 } |
|
213 |
|
214 // Won't be called after API v11, but we can't override the version that |
|
215 // *is* called and still support v8. |
|
216 // Instead we check the version code in the HealthReportSQLiteOpenHelper |
|
217 // constructor, and only use this workaround if we need to. |
|
218 @Override |
|
219 public SQLiteDatabase openOrCreateDatabase(String name, |
|
220 int mode, |
|
221 SQLiteDatabase.CursorFactory factory) { |
|
222 final File path = getDatabasePath(name); |
|
223 Logger.pii(LOG_TAG, "Opening database through absolute path " + path.getAbsolutePath()); |
|
224 return SQLiteDatabase.openOrCreateDatabase(path, null); |
|
225 } |
|
226 } |
|
227 |
|
228 public static String getAbsolutePath(File parent, String name) { |
|
229 return parent.getAbsolutePath() + File.separator + name; |
|
230 } |
|
231 |
|
232 public static boolean CAN_USE_ABSOLUTE_DB_PATH = (Build.VERSION.SDK_INT >= Build.VERSION_CODES.FROYO); |
|
233 public HealthReportSQLiteOpenHelper(Context context, File profileDirectory, String name) { |
|
234 this(context, profileDirectory, name, CURRENT_VERSION); |
|
235 } |
|
236 |
|
237 // For testing DBs of different versions. |
|
238 public HealthReportSQLiteOpenHelper(Context context, File profileDirectory, String name, int version) { |
|
239 super( |
|
240 (CAN_USE_ABSOLUTE_DB_PATH ? context : new AbsolutePathContext(context, profileDirectory)), |
|
241 (CAN_USE_ABSOLUTE_DB_PATH ? getAbsolutePath(profileDirectory, name) : name), |
|
242 null, |
|
243 version); |
|
244 |
|
245 if (CAN_USE_ABSOLUTE_DB_PATH) { |
|
246 Logger.pii(LOG_TAG, "Opening: " + getAbsolutePath(profileDirectory, name)); |
|
247 } |
|
248 } |
|
249 |
|
250 @Override |
|
251 public void onCreate(SQLiteDatabase db) { |
|
252 db.execSQL("CREATE TABLE addons (id INTEGER PRIMARY KEY AUTOINCREMENT, " + |
|
253 " body TEXT, " + |
|
254 " UNIQUE (body) " + |
|
255 ")"); |
|
256 |
|
257 // N.B., hash collisions can occur across versions. In that case, the system |
|
258 // is likely to persist the original environment version. |
|
259 db.execSQL("CREATE TABLE environments (id INTEGER PRIMARY KEY AUTOINCREMENT, " + |
|
260 " version INTEGER, " + |
|
261 " hash TEXT, " + |
|
262 " profileCreation INTEGER, " + |
|
263 " cpuCount INTEGER, " + |
|
264 " memoryMB INTEGER, " + |
|
265 " isBlocklistEnabled INTEGER, " + |
|
266 " isTelemetryEnabled INTEGER, " + |
|
267 " extensionCount INTEGER, " + |
|
268 " pluginCount INTEGER, " + |
|
269 " themeCount INTEGER, " + |
|
270 " architecture TEXT, " + |
|
271 " sysName TEXT, " + |
|
272 " sysVersion TEXT, " + |
|
273 " vendor TEXT, " + |
|
274 " appName TEXT, " + |
|
275 " appID TEXT, " + |
|
276 " appVersion TEXT, " + |
|
277 " appBuildID TEXT, " + |
|
278 " platformVersion TEXT, " + |
|
279 " platformBuildID TEXT, " + |
|
280 " os TEXT, " + |
|
281 " xpcomabi TEXT, " + |
|
282 " updateChannel TEXT, " + |
|
283 |
|
284 " distribution TEXT, " + |
|
285 " osLocale TEXT, " + |
|
286 " appLocale TEXT, " + |
|
287 " acceptLangSet INTEGER, " + |
|
288 |
|
289 " addonsID INTEGER, " + |
|
290 " FOREIGN KEY (addonsID) REFERENCES addons(id) ON DELETE RESTRICT, " + |
|
291 " UNIQUE (hash) " + |
|
292 ")"); |
|
293 |
|
294 db.execSQL("CREATE TABLE measurements (id INTEGER PRIMARY KEY AUTOINCREMENT, " + |
|
295 " name TEXT, " + |
|
296 " version INTEGER, " + |
|
297 " UNIQUE (name, version) " + |
|
298 ")"); |
|
299 |
|
300 db.execSQL("CREATE TABLE fields (id INTEGER PRIMARY KEY AUTOINCREMENT, " + |
|
301 " measurement INTEGER, " + |
|
302 " name TEXT, " + |
|
303 " flags INTEGER, " + |
|
304 " FOREIGN KEY (measurement) REFERENCES measurements(id) ON DELETE CASCADE, " + |
|
305 " UNIQUE (measurement, name)" + |
|
306 ")"); |
|
307 |
|
308 db.execSQL("CREATE TABLE " + EVENTS_INTEGER + "(" + |
|
309 " date INTEGER, " + |
|
310 " env INTEGER, " + |
|
311 " field INTEGER, " + |
|
312 " value INTEGER, " + |
|
313 " FOREIGN KEY (field) REFERENCES fields(id) ON DELETE CASCADE, " + |
|
314 " FOREIGN KEY (env) REFERENCES environments(id) ON DELETE CASCADE" + |
|
315 ")"); |
|
316 |
|
317 db.execSQL("CREATE TABLE " + EVENTS_TEXTUAL + "(" + |
|
318 " date INTEGER, " + |
|
319 " env INTEGER, " + |
|
320 " field INTEGER, " + |
|
321 " value TEXT, " + |
|
322 " FOREIGN KEY (field) REFERENCES fields(id) ON DELETE CASCADE, " + |
|
323 " FOREIGN KEY (env) REFERENCES environments(id) ON DELETE CASCADE" + |
|
324 ")"); |
|
325 |
|
326 db.execSQL("CREATE INDEX idx_events_integer_date_env_field ON events_integer (date, env, field)"); |
|
327 db.execSQL("CREATE INDEX idx_events_textual_date_env_field ON events_textual (date, env, field)"); |
|
328 |
|
329 db.execSQL("CREATE VIEW events AS " + |
|
330 "SELECT date, env, field, value FROM " + EVENTS_INTEGER + " " + |
|
331 "UNION ALL " + |
|
332 "SELECT date, env, field, value FROM " + EVENTS_TEXTUAL); |
|
333 |
|
334 db.execSQL("CREATE VIEW named_events AS " + |
|
335 "SELECT date, " + |
|
336 " environments.hash AS environment, " + |
|
337 " measurements.name AS measurement_name, " + |
|
338 " measurements.version AS measurement_version, " + |
|
339 " fields.name AS field_name, " + |
|
340 " fields.flags AS field_flags, " + |
|
341 " value FROM " + |
|
342 "events JOIN environments ON events.env = environments.id " + |
|
343 " JOIN fields ON events.field = fields.id " + |
|
344 " JOIN measurements ON fields.measurement = measurements.id"); |
|
345 |
|
346 db.execSQL("CREATE VIEW named_fields AS " + |
|
347 "SELECT measurements.name AS measurement_name, " + |
|
348 " measurements.id AS measurement_id, " + |
|
349 " measurements.version AS measurement_version, " + |
|
350 " fields.name AS field_name, " + |
|
351 " fields.id AS field_id, " + |
|
352 " fields.flags AS field_flags " + |
|
353 "FROM fields JOIN measurements ON fields.measurement = measurements.id"); |
|
354 |
|
355 db.execSQL("CREATE VIEW current_measurements AS " + |
|
356 "SELECT name, MAX(version) AS version FROM measurements GROUP BY name"); |
|
357 |
|
358 createAddonsEnvironmentsView(db); |
|
359 } |
|
360 |
|
361 @Override |
|
362 public void onOpen(SQLiteDatabase db) { |
|
363 if (!db.isReadOnly()) { |
|
364 db.execSQL("PRAGMA foreign_keys=ON;"); |
|
365 } |
|
366 } |
|
367 |
|
368 private void createAddonsEnvironmentsView(SQLiteDatabase db) { |
|
369 db.execSQL("CREATE VIEW environments_with_addons AS " + |
|
370 "SELECT e.id AS id, " + |
|
371 " e.version AS version, " + |
|
372 " e.hash AS hash, " + |
|
373 " e.profileCreation AS profileCreation, " + |
|
374 " e.cpuCount AS cpuCount, " + |
|
375 " e.memoryMB AS memoryMB, " + |
|
376 " e.isBlocklistEnabled AS isBlocklistEnabled, " + |
|
377 " e.isTelemetryEnabled AS isTelemetryEnabled, " + |
|
378 " e.extensionCount AS extensionCount, " + |
|
379 " e.pluginCount AS pluginCount, " + |
|
380 " e.themeCount AS themeCount, " + |
|
381 " e.architecture AS architecture, " + |
|
382 " e.sysName AS sysName, " + |
|
383 " e.sysVersion AS sysVersion, " + |
|
384 " e.vendor AS vendor, " + |
|
385 " e.appName AS appName, " + |
|
386 " e.appID AS appID, " + |
|
387 " e.appVersion AS appVersion, " + |
|
388 " e.appBuildID AS appBuildID, " + |
|
389 " e.platformVersion AS platformVersion, " + |
|
390 " e.platformBuildID AS platformBuildID, " + |
|
391 " e.os AS os, " + |
|
392 " e.xpcomabi AS xpcomabi, " + |
|
393 " e.updateChannel AS updateChannel, " + |
|
394 " e.distribution AS distribution, " + |
|
395 " e.osLocale AS osLocale, " + |
|
396 " e.appLocale AS appLocale, " + |
|
397 " e.acceptLangSet AS acceptLangSet, " + |
|
398 " addons.body AS addonsBody " + |
|
399 "FROM environments AS e, addons " + |
|
400 "WHERE e.addonsID = addons.id"); |
|
401 } |
|
402 |
|
403 private void upgradeDatabaseFrom2To3(SQLiteDatabase db) { |
|
404 db.execSQL("CREATE TABLE addons (id INTEGER PRIMARY KEY AUTOINCREMENT, " + |
|
405 " body TEXT, " + |
|
406 " UNIQUE (body) " + |
|
407 ")"); |
|
408 |
|
409 db.execSQL("ALTER TABLE environments ADD COLUMN addonsID INTEGER REFERENCES addons(id) ON DELETE RESTRICT"); |
|
410 |
|
411 createAddonsEnvironmentsView(db); |
|
412 } |
|
413 |
|
414 private void upgradeDatabaseFrom3To4(SQLiteDatabase db) { |
|
415 // Update search measurements to use a different type. |
|
416 db.execSQL("UPDATE OR IGNORE fields SET flags = " + Field.TYPE_COUNTED_STRING_DISCRETE + |
|
417 " WHERE measurement IN (SELECT id FROM measurements WHERE name = 'org.mozilla.searches.counts')"); |
|
418 } |
|
419 |
|
420 private void upgradeDatabaseFrom4to5(SQLiteDatabase db) { |
|
421 // Delete NULL in addons.body, which appeared as a result of Bug 886156. Note that the |
|
422 // foreign key constraint, "ON DELETE RESTRICT", may be violated, but since onOpen() is |
|
423 // called after this method, foreign keys are not yet enabled and constraints can be broken. |
|
424 db.delete("addons", "body IS NULL", null); |
|
425 |
|
426 // Purge any data inconsistent with foreign key references (which may have appeared before |
|
427 // foreign keys were enabled in Bug 900289). |
|
428 db.delete("fields", "measurement NOT IN (SELECT id FROM measurements)", null); |
|
429 db.delete("environments", "addonsID NOT IN (SELECT id from addons)", null); |
|
430 db.delete(EVENTS_INTEGER, "env NOT IN (SELECT id FROM environments)", null); |
|
431 db.delete(EVENTS_TEXTUAL, "env NOT IN (SELECT id FROM environments)", null); |
|
432 db.delete(EVENTS_INTEGER, "field NOT IN (SELECT id FROM fields)", null); |
|
433 db.delete(EVENTS_TEXTUAL, "field NOT IN (SELECT id FROM fields)", null); |
|
434 } |
|
435 |
|
436 private void upgradeDatabaseFrom5to6(SQLiteDatabase db) { |
|
437 db.execSQL("DROP VIEW environments_with_addons"); |
|
438 |
|
439 // Add version to environment (default to 1). |
|
440 db.execSQL("ALTER TABLE environments ADD COLUMN version INTEGER DEFAULT 1"); |
|
441 |
|
442 // Add fields to environment (default to empty string). |
|
443 db.execSQL("ALTER TABLE environments ADD COLUMN distribution TEXT DEFAULT ''"); |
|
444 db.execSQL("ALTER TABLE environments ADD COLUMN osLocale TEXT DEFAULT ''"); |
|
445 db.execSQL("ALTER TABLE environments ADD COLUMN appLocale TEXT DEFAULT ''"); |
|
446 db.execSQL("ALTER TABLE environments ADD COLUMN acceptLangSet INTEGER DEFAULT 0"); |
|
447 |
|
448 // Recreate view. |
|
449 createAddonsEnvironmentsView(db); |
|
450 } |
|
451 |
|
452 @Override |
|
453 public void onUpgrade(SQLiteDatabase db, int oldVersion, int newVersion) { |
|
454 if (oldVersion >= newVersion) { |
|
455 return; |
|
456 } |
|
457 |
|
458 Logger.info(LOG_TAG, "onUpgrade: from " + oldVersion + " to " + newVersion + "."); |
|
459 try { |
|
460 switch (oldVersion) { |
|
461 case 2: |
|
462 upgradeDatabaseFrom2To3(db); |
|
463 case 3: |
|
464 upgradeDatabaseFrom3To4(db); |
|
465 case 4: |
|
466 upgradeDatabaseFrom4to5(db); |
|
467 case 5: |
|
468 upgradeDatabaseFrom5to6(db); |
|
469 } |
|
470 } catch (Exception e) { |
|
471 Logger.error(LOG_TAG, "Failure in onUpgrade.", e); |
|
472 throw new RuntimeException(e); |
|
473 } |
|
474 } |
|
475 |
|
476 public void deleteEverything() { |
|
477 final SQLiteDatabase db = this.getWritableDatabase(); |
|
478 |
|
479 Logger.info(LOG_TAG, "Deleting everything."); |
|
480 db.beginTransaction(); |
|
481 try { |
|
482 // Cascade will clear the rest. |
|
483 db.delete("measurements", null, null); |
|
484 db.delete("environments", null, null); |
|
485 db.delete("addons", null, null); |
|
486 db.setTransactionSuccessful(); |
|
487 Logger.info(LOG_TAG, "Deletion successful."); |
|
488 } finally { |
|
489 db.endTransaction(); |
|
490 } |
|
491 } |
|
492 } |
|
493 |
|
494 public class DatabaseField extends Field { |
|
495 public DatabaseField(String mName, int mVersion, String fieldName) { |
|
496 this(mName, mVersion, fieldName, UNKNOWN_TYPE_OR_FIELD_ID, UNKNOWN_TYPE_OR_FIELD_ID); |
|
497 } |
|
498 |
|
499 public DatabaseField(String mName, int mVersion, String fieldName, int flags) { |
|
500 this(mName, mVersion, fieldName, UNKNOWN_TYPE_OR_FIELD_ID, flags); |
|
501 } |
|
502 |
|
503 public DatabaseField(String mName, int mVersion, String fieldName, int fieldID, int flags) { |
|
504 super(mName, mVersion, fieldName, flags); |
|
505 this.fieldID = fieldID; |
|
506 } |
|
507 |
|
508 private void loadFlags() { |
|
509 if (this.flags == UNKNOWN_TYPE_OR_FIELD_ID) { |
|
510 if (this.fieldID == UNKNOWN_TYPE_OR_FIELD_ID) { |
|
511 this.getID(); |
|
512 } |
|
513 this.flags = integerQuery("fields", "flags", "id = ?", new String[] { Integer.toString(this.fieldID, 10) }, -1); |
|
514 } |
|
515 } |
|
516 |
|
517 @Override |
|
518 public synchronized boolean isIntegerField() { |
|
519 loadFlags(); |
|
520 return super.isIntegerField(); |
|
521 } |
|
522 |
|
523 @Override |
|
524 public synchronized boolean isStringField() { |
|
525 loadFlags(); |
|
526 return super.isStringField(); |
|
527 } |
|
528 |
|
529 @Override |
|
530 public synchronized boolean isDiscreteField() { |
|
531 loadFlags(); |
|
532 return super.isDiscreteField(); |
|
533 } |
|
534 |
|
535 @Override |
|
536 public synchronized int getID() throws IllegalStateException { |
|
537 if (this.fieldID == UNKNOWN_TYPE_OR_FIELD_ID) { |
|
538 this.fieldID = integerQuery("named_fields", "field_id", |
|
539 "measurement_name = ? AND measurement_version = ? AND field_name = ?", |
|
540 new String[] {measurementName, measurementVersion, fieldName}, |
|
541 UNKNOWN_TYPE_OR_FIELD_ID); |
|
542 if (this.fieldID == UNKNOWN_TYPE_OR_FIELD_ID) { |
|
543 throw new IllegalStateException("No field with name " + fieldName + |
|
544 " (" + measurementName + ", " + measurementVersion + ")"); |
|
545 } |
|
546 } |
|
547 return this.fieldID; |
|
548 } |
|
549 } |
|
550 |
|
551 // `envs` and `fields` look similar, but they are touched differently and |
|
552 // store differently stable kinds of data, hence type difference. |
|
553 // Note that we don't pre-populate the environment cache. We'll typically only |
|
554 // handle one per session. |
|
555 // |
|
556 // protected for testing purposes only. |
|
557 protected final ConcurrentHashMap<String, Integer> envs = new ConcurrentHashMap<String, Integer>(); |
|
558 |
|
559 /** |
|
560 * An {@link Environment} that knows how to persist to and from our database. |
|
561 */ |
|
562 public static class DatabaseEnvironment extends Environment { |
|
563 protected final HealthReportDatabaseStorage storage; |
|
564 |
|
565 @Override |
|
566 public int register() { |
|
567 final String h = getHash(); |
|
568 if (storage.envs.containsKey(h)) { |
|
569 this.id = storage.envs.get(h); |
|
570 return this.id; |
|
571 } |
|
572 |
|
573 // Otherwise, add data and hash to the DB. |
|
574 ContentValues v = new ContentValues(); |
|
575 v.put("version", version); |
|
576 v.put("hash", h); |
|
577 v.put("profileCreation", profileCreation); |
|
578 v.put("cpuCount", cpuCount); |
|
579 v.put("memoryMB", memoryMB); |
|
580 v.put("isBlocklistEnabled", isBlocklistEnabled); |
|
581 v.put("isTelemetryEnabled", isTelemetryEnabled); |
|
582 v.put("extensionCount", extensionCount); |
|
583 v.put("pluginCount", pluginCount); |
|
584 v.put("themeCount", themeCount); |
|
585 v.put("architecture", architecture); |
|
586 v.put("sysName", sysName); |
|
587 v.put("sysVersion", sysVersion); |
|
588 v.put("vendor", vendor); |
|
589 v.put("appName", appName); |
|
590 v.put("appID", appID); |
|
591 v.put("appVersion", appVersion); |
|
592 v.put("appBuildID", appBuildID); |
|
593 v.put("platformVersion", platformVersion); |
|
594 v.put("platformBuildID", platformBuildID); |
|
595 v.put("os", os); |
|
596 v.put("xpcomabi", xpcomabi); |
|
597 v.put("updateChannel", updateChannel); |
|
598 v.put("distribution", distribution); |
|
599 v.put("osLocale", osLocale); |
|
600 v.put("appLocale", appLocale); |
|
601 v.put("acceptLangSet", acceptLangSet); |
|
602 |
|
603 final SQLiteDatabase db = storage.helper.getWritableDatabase(); |
|
604 |
|
605 // If we're not already, we want all of our inserts to be in a transaction. |
|
606 boolean newTransaction = !db.inTransaction(); |
|
607 |
|
608 // Insert, with a little error handling to populate the cache in case of |
|
609 // omission and consequent collision. |
|
610 // |
|
611 // We would like to hang a trigger off a view here, and just use that for |
|
612 // inserts. But triggers don't seem to correctly update the last inserted |
|
613 // ID, so Android's insertOrThrow method returns -1. |
|
614 // |
|
615 // Instead, we go without the trigger, simply running the inserts ourselves. |
|
616 // |
|
617 // insertWithOnConflict doesn't work as documented: <http://stackoverflow.com/questions/11328877/android-sqllite-on-conflict-ignore-is-ignored-in-ics/11424150>. |
|
618 // So we do this the hard way. |
|
619 // We presume that almost every get will hit the cache (except for the first, obviously), so we |
|
620 // bias here towards inserts for the environments. |
|
621 // For add-ons we assume that different environments will share add-ons, so we query first. |
|
622 |
|
623 final String addonsJSON = getNormalizedAddonsJSON(); |
|
624 if (newTransaction) { |
|
625 db.beginTransaction(); |
|
626 } |
|
627 |
|
628 try { |
|
629 int addonsID = ensureAddons(db, addonsJSON); |
|
630 v.put("addonsID", addonsID); |
|
631 |
|
632 try { |
|
633 int inserted = (int) db.insertOrThrow("environments", null, v); |
|
634 Logger.debug(LOG_TAG, "Inserted ID: " + inserted + " for hash " + h); |
|
635 if (inserted == -1) { |
|
636 throw new SQLException("Insert returned -1!"); |
|
637 } |
|
638 this.id = inserted; |
|
639 storage.envs.put(h, this.id); |
|
640 if (newTransaction) { |
|
641 db.setTransactionSuccessful(); |
|
642 } |
|
643 return inserted; |
|
644 } catch (SQLException e) { |
|
645 // The inserter should take care of updating `envs`. But if it |
|
646 // doesn't... |
|
647 Cursor c = db.query("environments", COLUMNS_ID, "hash = ?", |
|
648 new String[] { h }, null, null, null); |
|
649 try { |
|
650 if (!c.moveToFirst()) { |
|
651 throw e; |
|
652 } |
|
653 this.id = (int) c.getLong(0); |
|
654 Logger.debug(LOG_TAG, "Found " + this.id + " for hash " + h); |
|
655 storage.envs.put(h, this.id); |
|
656 if (newTransaction) { |
|
657 db.setTransactionSuccessful(); |
|
658 } |
|
659 return this.id; |
|
660 } finally { |
|
661 c.close(); |
|
662 } |
|
663 } |
|
664 } finally { |
|
665 if (newTransaction) { |
|
666 db.endTransaction(); |
|
667 } |
|
668 } |
|
669 } |
|
670 |
|
671 protected static int ensureAddons(SQLiteDatabase db, String json) { |
|
672 Cursor c = db.query("addons", COLUMNS_ID, "body = ?", |
|
673 new String[] { (json == null) ? "null" : json }, null, null, null); |
|
674 try { |
|
675 if (c.moveToFirst()) { |
|
676 return c.getInt(0); |
|
677 } |
|
678 ContentValues values = new ContentValues(); |
|
679 values.put("body", json); |
|
680 return (int) db.insert("addons", null, values); |
|
681 } finally { |
|
682 c.close(); |
|
683 } |
|
684 } |
|
685 |
|
686 public void init(ContentValues v) { |
|
687 version = v.containsKey("version") ? v.getAsInteger("version") : Environment.CURRENT_VERSION; |
|
688 profileCreation = v.getAsInteger("profileCreation"); |
|
689 cpuCount = v.getAsInteger("cpuCount"); |
|
690 memoryMB = v.getAsInteger("memoryMB"); |
|
691 |
|
692 isBlocklistEnabled = v.getAsInteger("isBlocklistEnabled"); |
|
693 isTelemetryEnabled = v.getAsInteger("isTelemetryEnabled"); |
|
694 extensionCount = v.getAsInteger("extensionCount"); |
|
695 pluginCount = v.getAsInteger("pluginCount"); |
|
696 themeCount = v.getAsInteger("themeCount"); |
|
697 |
|
698 architecture = v.getAsString("architecture"); |
|
699 sysName = v.getAsString("sysName"); |
|
700 sysVersion = v.getAsString("sysVersion"); |
|
701 vendor = v.getAsString("vendor"); |
|
702 appName = v.getAsString("appName"); |
|
703 appID = v.getAsString("appID"); |
|
704 appVersion = v.getAsString("appVersion"); |
|
705 appBuildID = v.getAsString("appBuildID"); |
|
706 platformVersion = v.getAsString("platformVersion"); |
|
707 platformBuildID = v.getAsString("platformBuildID"); |
|
708 os = v.getAsString("os"); |
|
709 xpcomabi = v.getAsString("xpcomabi"); |
|
710 updateChannel = v.getAsString("updateChannel"); |
|
711 |
|
712 distribution = v.getAsString("distribution"); |
|
713 osLocale = v.getAsString("osLocale"); |
|
714 appLocale = v.getAsString("appLocale"); |
|
715 acceptLangSet = v.getAsInteger("acceptLangSet"); |
|
716 |
|
717 try { |
|
718 setJSONForAddons(v.getAsString("addonsBody")); |
|
719 } catch (Exception e) { |
|
720 // Nothing we can do. |
|
721 } |
|
722 |
|
723 this.hash = null; |
|
724 this.id = -1; |
|
725 } |
|
726 |
|
727 /** |
|
728 * Fill ourselves with data from the DB, then advance the cursor. |
|
729 * |
|
730 * @param cursor a {@link Cursor} pointing at a record to load. |
|
731 * @return true if the cursor was successfully advanced. |
|
732 */ |
|
733 public boolean init(Cursor cursor) { |
|
734 int i = 0; |
|
735 this.id = cursor.getInt(i++); |
|
736 this.version = cursor.getInt(i++); |
|
737 this.hash = cursor.getString(i++); |
|
738 |
|
739 profileCreation = cursor.getInt(i++); |
|
740 cpuCount = cursor.getInt(i++); |
|
741 memoryMB = cursor.getInt(i++); |
|
742 |
|
743 isBlocklistEnabled = cursor.getInt(i++); |
|
744 isTelemetryEnabled = cursor.getInt(i++); |
|
745 extensionCount = cursor.getInt(i++); |
|
746 pluginCount = cursor.getInt(i++); |
|
747 themeCount = cursor.getInt(i++); |
|
748 |
|
749 architecture = cursor.getString(i++); |
|
750 sysName = cursor.getString(i++); |
|
751 sysVersion = cursor.getString(i++); |
|
752 vendor = cursor.getString(i++); |
|
753 appName = cursor.getString(i++); |
|
754 appID = cursor.getString(i++); |
|
755 appVersion = cursor.getString(i++); |
|
756 appBuildID = cursor.getString(i++); |
|
757 platformVersion = cursor.getString(i++); |
|
758 platformBuildID = cursor.getString(i++); |
|
759 os = cursor.getString(i++); |
|
760 xpcomabi = cursor.getString(i++); |
|
761 updateChannel = cursor.getString(i++); |
|
762 |
|
763 distribution = cursor.getString(i++); |
|
764 osLocale = cursor.getString(i++); |
|
765 appLocale = cursor.getString(i++); |
|
766 acceptLangSet = cursor.getInt(i++); |
|
767 |
|
768 try { |
|
769 setJSONForAddons(cursor.getBlob(i++)); |
|
770 } catch (Exception e) { |
|
771 // Nothing we can do. |
|
772 } |
|
773 |
|
774 return cursor.moveToNext(); |
|
775 } |
|
776 |
|
777 public DatabaseEnvironment(HealthReportDatabaseStorage storage, Class<? extends EnvironmentAppender> appender) { |
|
778 super(appender); |
|
779 this.storage = storage; |
|
780 } |
|
781 |
|
782 public DatabaseEnvironment(HealthReportDatabaseStorage storage) { |
|
783 this.storage = storage; |
|
784 } |
|
785 } |
|
786 |
|
787 /** |
|
788 * Factory method. Returns a new {@link Environment} that callers can |
|
789 * populate and then register. |
|
790 */ |
|
791 @Override |
|
792 public DatabaseEnvironment getEnvironment() { |
|
793 return new DatabaseEnvironment(this); |
|
794 } |
|
795 |
|
796 @Override |
|
797 public SparseArray<Environment> getEnvironmentRecordsByID() { |
|
798 final SQLiteDatabase db = this.helper.getReadableDatabase(); |
|
799 Cursor c = db.query("environments_with_addons", COLUMNS_ENVIRONMENT_DETAILS, null, null, null, null, null); |
|
800 try { |
|
801 SparseArray<Environment> results = new SparseArray<Environment>(); |
|
802 if (!c.moveToFirst()) { |
|
803 return results; |
|
804 } |
|
805 |
|
806 DatabaseEnvironment e = getEnvironment(); |
|
807 while (e.init(c)) { |
|
808 results.put(e.id, e); |
|
809 e = getEnvironment(); |
|
810 } |
|
811 results.put(e.id, e); |
|
812 return results; |
|
813 } finally { |
|
814 c.close(); |
|
815 } |
|
816 } |
|
817 |
|
818 /** |
|
819 * Reverse lookup for an env. Only really used for tests: document generation |
|
820 * fetches all environments at once, and insertion only uses the integer key |
|
821 * that's returned during insertion. |
|
822 * |
|
823 * @param id |
|
824 * the identifier for the environment. |
|
825 * @return a cursor over its details. |
|
826 */ |
|
827 @Override |
|
828 public Cursor getEnvironmentRecordForID(int id) { |
|
829 final SQLiteDatabase db = this.helper.getReadableDatabase(); |
|
830 return db.query("environments_with_addons", COLUMNS_ENVIRONMENT_DETAILS, "id = " + id, null, null, null, null); |
|
831 } |
|
832 |
|
833 @Override |
|
834 public SparseArray<String> getEnvironmentHashesByID() { |
|
835 final SQLiteDatabase db = this.helper.getReadableDatabase(); |
|
836 Cursor c = db.query("environments", new String[] {"id", "hash"}, null, null, null, null, null); |
|
837 try { |
|
838 SparseArray<String> results = new SparseArray<String>(); |
|
839 if (!c.moveToFirst()) { |
|
840 return results; |
|
841 } |
|
842 |
|
843 while (!c.isAfterLast()) { |
|
844 results.put(c.getInt(0), c.getString(1)); |
|
845 c.moveToNext(); |
|
846 } |
|
847 return results; |
|
848 } finally { |
|
849 c.close(); |
|
850 } |
|
851 } |
|
852 |
|
853 /** |
|
854 * Cache the lookup from measurement and field specifier to field instance. |
|
855 * This allows us to memoize the field ID lookup, too. |
|
856 */ |
|
857 private HashMap<String, Field> fields = new HashMap<String, Field>(); |
|
858 private boolean fieldsCacheUpdated = false; |
|
859 |
|
860 private void invalidateFieldsCache() { |
|
861 synchronized (this.fields) { |
|
862 fieldsCacheUpdated = false; |
|
863 } |
|
864 } |
|
865 |
|
866 private String getFieldKey(String mName, int mVersion, String fieldName) { |
|
867 return mVersion + "." + mName + "/" + fieldName; |
|
868 } |
|
869 |
|
870 @Override |
|
871 public Field getField(String mName, int mVersion, String fieldName) { |
|
872 final String key = getFieldKey(mName, mVersion, fieldName); |
|
873 synchronized (fields) { |
|
874 if (fields.containsKey(key)) { |
|
875 return fields.get(key); |
|
876 } |
|
877 Field f = new DatabaseField(mName, mVersion, fieldName); |
|
878 fields.put(key, f); |
|
879 return f; |
|
880 } |
|
881 } |
|
882 |
|
883 private void populateFieldCache() { |
|
884 synchronized (fields) { |
|
885 if (fieldsCacheUpdated) { |
|
886 return; |
|
887 } |
|
888 |
|
889 fields.clear(); |
|
890 Cursor c = getFieldVersions(); |
|
891 try { |
|
892 if (!c.moveToFirst()) { |
|
893 return; |
|
894 } |
|
895 do { |
|
896 // We don't use the measurement ID here, so column 1 is unused. |
|
897 final String mName = c.getString(0); |
|
898 final int mVersion = c.getInt(2); |
|
899 final String fieldName = c.getString(3); |
|
900 final int fieldID = c.getInt(4); |
|
901 final int flags = c.getInt(5); |
|
902 final String key = getFieldKey(mName, mVersion, fieldName); |
|
903 |
|
904 Field f = new DatabaseField(mName, mVersion, fieldName, fieldID, flags); |
|
905 fields.put(key, f); |
|
906 } while (c.moveToNext()); |
|
907 fieldsCacheUpdated = true; |
|
908 } finally { |
|
909 c.close(); |
|
910 } |
|
911 } |
|
912 } |
|
913 |
|
914 /** |
|
915 * Return mappings from field ID to Field instance. Do so by looking in the DB. |
|
916 */ |
|
917 @Override |
|
918 public SparseArray<Field> getFieldsByID() { |
|
919 final SparseArray<Field> out = new SparseArray<Field>(); |
|
920 synchronized (fields) { |
|
921 populateFieldCache(); |
|
922 Collection<Field> values = fields.values(); |
|
923 for (Field field : values) { |
|
924 // Cache is up-to-date at this point, so we don't need to hit the DB. |
|
925 out.put(field.getID(), field); |
|
926 } |
|
927 } |
|
928 return out; |
|
929 } |
|
930 |
|
931 private final HashMap<String, Integer> measurementVersions = new HashMap<String, Integer>(); |
|
932 |
|
933 private void populateMeasurementVersionsCache(SQLiteDatabase db) { |
|
934 HashMap<String, Integer> results = getIntegers(db, "current_measurements", "name", "version"); |
|
935 if (results == null) { |
|
936 measurementVersions.clear(); |
|
937 return; |
|
938 } |
|
939 synchronized (measurementVersions) { |
|
940 measurementVersions.clear(); |
|
941 measurementVersions.putAll(results); |
|
942 } |
|
943 } |
|
944 |
|
945 /** |
|
946 * Return the version of the measurement for which the DB is currently configured, or |
|
947 * 0 if unknown. |
|
948 * @param measurement String measurement identifier. |
|
949 * @return Current version. |
|
950 */ |
|
951 private int getMeasurementVersion(String measurement) { |
|
952 synchronized (measurementVersions) { |
|
953 if (measurementVersions.containsKey(measurement)) { |
|
954 return measurementVersions.get(measurement); |
|
955 } |
|
956 |
|
957 // This should never be necessary, unless the measurement does not exist. |
|
958 int value = integerQuery("measurements", "version", "name = ?", new String[] {measurement}, 0); |
|
959 measurementVersions.put(measurement, value); |
|
960 return value; |
|
961 } |
|
962 } |
|
963 |
|
964 /** |
|
965 * Inform the storage layer that fields for the given measurement have been updated |
|
966 * to this version. |
|
967 * |
|
968 * This should be one of the final calls in a configuration transaction. |
|
969 * Always call this inside a transaction. |
|
970 */ |
|
971 private void notifyMeasurementVersionUpdated(String measurement, int version) { |
|
972 Logger.info(LOG_TAG, "Measurement " + measurement + " now at " + version); |
|
973 |
|
974 final SQLiteDatabase db = this.helper.getWritableDatabase(); |
|
975 final ContentValues values = new ContentValues(); |
|
976 values.put("name", measurement); |
|
977 values.put("version", version); |
|
978 |
|
979 synchronized (measurementVersions) { |
|
980 measurementVersions.put(measurement, version); |
|
981 } |
|
982 |
|
983 db.insertWithOnConflict("measurements", null, values, SQLiteDatabase.CONFLICT_IGNORE); |
|
984 } |
|
985 |
|
986 /** |
|
987 * Call in a transaction. |
|
988 * This method could race with other accesses, but (a) it's within a transaction, |
|
989 * (b) configuration should be single-threaded, (c) we initialize the cache up-front. |
|
990 */ |
|
991 @Override |
|
992 public void ensureMeasurementInitialized(String measurement, int version, MeasurementFields fields) { |
|
993 final int currentVersion = getMeasurementVersion(measurement); |
|
994 Logger.info(LOG_TAG, "Initializing measurement " + measurement + " to " + |
|
995 version + " (current " + currentVersion + ")"); |
|
996 |
|
997 if (currentVersion == version) { |
|
998 Logger.info(LOG_TAG, "Measurement " + measurement + " already at v" + version); |
|
999 return; |
|
1000 } |
|
1001 |
|
1002 final SQLiteDatabase db = this.helper.getWritableDatabase(); |
|
1003 if (!db.inTransaction()) { |
|
1004 Logger.warn(LOG_TAG, "ensureMeasurementInitialized should be called within a transaction."); |
|
1005 } |
|
1006 |
|
1007 final ContentValues mv = new ContentValues(); |
|
1008 mv.put("name", measurement); |
|
1009 mv.put("version", version); |
|
1010 |
|
1011 final int measurementID = (int) db.insert("measurements", null, mv); |
|
1012 |
|
1013 final ContentValues v = new ContentValues(); |
|
1014 v.put("measurement", measurementID); |
|
1015 for (FieldSpec field : fields.getFields()) { |
|
1016 v.put("name", field.name); |
|
1017 v.put("flags", field.type); |
|
1018 Logger.debug(LOG_TAG, "M: " + measurementID + " F: " + field.name + " (" + field.type + ")"); |
|
1019 db.insert("fields", null, v); |
|
1020 } |
|
1021 |
|
1022 notifyMeasurementVersionUpdated(measurement, version); |
|
1023 |
|
1024 // Let's be easy for now. |
|
1025 invalidateFieldsCache(); |
|
1026 } |
|
1027 |
|
1028 /** |
|
1029 * Return a cursor over the measurements and fields in the DB. |
|
1030 * Columns are {@link HealthReportDatabaseStorage#COLUMNS_MEASUREMENT_AND_FIELD_DETAILS}. |
|
1031 */ |
|
1032 @Override |
|
1033 public Cursor getFieldVersions() { |
|
1034 final SQLiteDatabase db = this.helper.getReadableDatabase(); |
|
1035 return db.query("named_fields", COLUMNS_MEASUREMENT_AND_FIELD_DETAILS, |
|
1036 null, null, null, null, "measurement_name, measurement_version, field_name"); |
|
1037 } |
|
1038 |
|
1039 @Override |
|
1040 public Cursor getFieldVersions(String measurement, int measurementVersion) { |
|
1041 final SQLiteDatabase db = this.helper.getReadableDatabase(); |
|
1042 return db.query("named_fields", COLUMNS_MEASUREMENT_AND_FIELD_DETAILS, |
|
1043 "measurement_name = ? AND measurement_version = ?", |
|
1044 new String[] {measurement, Integer.toString(measurementVersion)}, |
|
1045 null, null, "field_name"); |
|
1046 } |
|
1047 |
|
1048 @Override |
|
1049 public Cursor getMeasurementVersions() { |
|
1050 final SQLiteDatabase db = this.helper.getReadableDatabase(); |
|
1051 return db.query("measurements", COLUMNS_MEASUREMENT_DETAILS, |
|
1052 null, null, null, null, "name, version"); |
|
1053 } |
|
1054 |
|
1055 /** |
|
1056 * A thin wrapper around the database transactional semantics. Clients can |
|
1057 * use this to more efficiently ensure that measurements are initialized. |
|
1058 * |
|
1059 * Note that caches are also initialized here. |
|
1060 */ |
|
1061 public void beginInitialization() { |
|
1062 SQLiteDatabase db = this.helper.getWritableDatabase(); |
|
1063 db.beginTransaction(); |
|
1064 populateMeasurementVersionsCache(db); |
|
1065 } |
|
1066 |
|
1067 public void finishInitialization() { |
|
1068 SQLiteDatabase db = this.helper.getWritableDatabase(); |
|
1069 db.setTransactionSuccessful(); |
|
1070 db.endTransaction(); |
|
1071 } |
|
1072 |
|
1073 public void abortInitialization() { |
|
1074 this.helper.getWritableDatabase().endTransaction(); |
|
1075 } |
|
1076 |
|
1077 protected int getIntFromQuery(final String sql, final String[] selectionArgs) { |
|
1078 final SQLiteDatabase db = this.helper.getReadableDatabase(); |
|
1079 final Cursor c = db.rawQuery(sql, selectionArgs); |
|
1080 try { |
|
1081 if (!c.moveToFirst()) { |
|
1082 throw new IllegalStateException("Cursor is empty."); |
|
1083 } |
|
1084 return c.getInt(0); |
|
1085 } finally { |
|
1086 c.close(); |
|
1087 } |
|
1088 } |
|
1089 |
|
1090 @Override |
|
1091 public int getDay(long time) { |
|
1092 return DateUtils.getDay(time); |
|
1093 } |
|
1094 |
|
1095 @Override |
|
1096 public int getDay() { |
|
1097 return this.getDay(System.currentTimeMillis()); |
|
1098 } |
|
1099 |
|
1100 private void recordDailyLast(int env, int day, int field, Object value, String table) { |
|
1101 if (env == -1) { |
|
1102 Logger.warn(LOG_TAG, "Refusing to record with environment = -1."); |
|
1103 return; |
|
1104 } |
|
1105 |
|
1106 final SQLiteDatabase db = this.helper.getWritableDatabase(); |
|
1107 |
|
1108 final String envString = Integer.toString(env); |
|
1109 final String fieldIDString = Integer.toString(field, 10); |
|
1110 final String dayString = Integer.toString(day, 10); |
|
1111 |
|
1112 // Java, your capacity for abstraction leaves me wanting. |
|
1113 final ContentValues v = new ContentValues(); |
|
1114 putValue(v, value); |
|
1115 |
|
1116 // If we used a separate table, such that we could have a |
|
1117 // UNIQUE(env, field, day) constraint for daily-last values, then we could |
|
1118 // use INSERT OR REPLACE. |
|
1119 final int updated = db.update(table, v, WHERE_DATE_AND_ENV_AND_FIELD, |
|
1120 new String[] {dayString, envString, fieldIDString}); |
|
1121 if (0 == updated) { |
|
1122 v.put("env", env); |
|
1123 v.put("field", field); |
|
1124 v.put("date", day); |
|
1125 try { |
|
1126 db.insertOrThrow(table, null, v); |
|
1127 } catch (SQLiteConstraintException e) { |
|
1128 throw new IllegalStateException("Event did not reference existing an environment or field.", e); |
|
1129 } |
|
1130 } |
|
1131 } |
|
1132 |
|
1133 @Override |
|
1134 public void recordDailyLast(int env, int day, int field, JSONObject value) { |
|
1135 this.recordDailyLast(env, day, field, value == null ? "null" : value.toString(), EVENTS_TEXTUAL); |
|
1136 } |
|
1137 |
|
1138 @Override |
|
1139 public void recordDailyLast(int env, int day, int field, String value) { |
|
1140 this.recordDailyLast(env, day, field, value, EVENTS_TEXTUAL); |
|
1141 } |
|
1142 |
|
1143 @Override |
|
1144 public void recordDailyLast(int env, int day, int field, int value) { |
|
1145 this.recordDailyLast(env, day, field, Integer.valueOf(value), EVENTS_INTEGER); |
|
1146 } |
|
1147 |
|
1148 private void recordDailyDiscrete(int env, int day, int field, Object value, String table) { |
|
1149 if (env == -1) { |
|
1150 Logger.warn(LOG_TAG, "Refusing to record with environment = -1."); |
|
1151 return; |
|
1152 } |
|
1153 |
|
1154 final ContentValues v = new ContentValues(); |
|
1155 v.put("env", env); |
|
1156 v.put("field", field); |
|
1157 v.put("date", day); |
|
1158 |
|
1159 final SQLiteDatabase db = this.helper.getWritableDatabase(); |
|
1160 putValue(v, value); |
|
1161 |
|
1162 // Using SQLiteDatabase.insertOrThrow throws SQLiteConstraintException we cannot catch for |
|
1163 // unknown reasons (bug 961526 comment 13). We believe these are thrown because we attempt to |
|
1164 // record events using environment IDs removed from the database by the prune service. We |
|
1165 // invalidate the currentEnvironment ID after pruning, preventing further propagation, |
|
1166 // however, any event recording waiting for the prune service to complete on the background |
|
1167 // thread may carry an invalid ID: we expect an insertion failure and drop these events here. |
|
1168 final long res = db.insert(table, null, v); |
|
1169 if (res == -1) { |
|
1170 Logger.error(LOG_TAG, "Unable to record daily discrete event. Ignoring."); |
|
1171 } |
|
1172 } |
|
1173 |
|
1174 @Override |
|
1175 public void recordDailyDiscrete(int env, int day, int field, JSONObject value) { |
|
1176 this.recordDailyDiscrete(env, day, field, value == null ? "null" : value.toString(), EVENTS_TEXTUAL); |
|
1177 } |
|
1178 |
|
1179 @Override |
|
1180 public void recordDailyDiscrete(int env, int day, int field, String value) { |
|
1181 this.recordDailyDiscrete(env, day, field, value, EVENTS_TEXTUAL); |
|
1182 } |
|
1183 |
|
1184 @Override |
|
1185 public void recordDailyDiscrete(int env, int day, int field, int value) { |
|
1186 this.recordDailyDiscrete(env, day, field, value, EVENTS_INTEGER); |
|
1187 } |
|
1188 |
|
1189 /** |
|
1190 * Increment the specified field value by the specified amount. Counts start |
|
1191 * at zero. |
|
1192 * |
|
1193 * Note that this method can misbehave or throw if not executed within a |
|
1194 * transaction, because correct behavior involves querying then |
|
1195 * insert-or-update, and a race condition can otherwise occur. |
|
1196 * |
|
1197 * @param env the environment ID |
|
1198 * @param day the current day, in days since epoch |
|
1199 * @param field the field ID |
|
1200 * @param by how much to increment the counter. |
|
1201 */ |
|
1202 @Override |
|
1203 public void incrementDailyCount(int env, int day, int field, int by) { |
|
1204 if (env == -1) { |
|
1205 Logger.warn(LOG_TAG, "Refusing to record with environment = -1."); |
|
1206 return; |
|
1207 } |
|
1208 |
|
1209 final SQLiteDatabase db = this.helper.getWritableDatabase(); |
|
1210 final String envString = Integer.toString(env); |
|
1211 final String fieldIDString = Integer.toString(field, 10); |
|
1212 final String dayString = Integer.toString(day, 10); |
|
1213 |
|
1214 // Can't run a complex UPDATE and get the number of changed rows, so we'll |
|
1215 // do this the hard way. |
|
1216 // This relies on being called within a transaction. |
|
1217 final String[] args = new String[] {dayString, envString, fieldIDString}; |
|
1218 final Cursor c = db.query(EVENTS_INTEGER, |
|
1219 COLUMNS_VALUE, |
|
1220 WHERE_DATE_AND_ENV_AND_FIELD, |
|
1221 args, null, null, null, "1"); |
|
1222 |
|
1223 boolean present = false; |
|
1224 try { |
|
1225 present = c.moveToFirst(); |
|
1226 } finally { |
|
1227 c.close(); |
|
1228 } |
|
1229 |
|
1230 if (present) { |
|
1231 // It's an int, so safe to concatenate. Avoids us having to mess with args. |
|
1232 db.execSQL("UPDATE " + EVENTS_INTEGER + " SET value = value + " + by + " WHERE " + |
|
1233 WHERE_DATE_AND_ENV_AND_FIELD, |
|
1234 args); |
|
1235 } else { |
|
1236 final ContentValues v = new ContentValues(); |
|
1237 v.put("env", env); |
|
1238 v.put("value", by); |
|
1239 v.put("field", field); |
|
1240 v.put("date", day); |
|
1241 try { |
|
1242 db.insertOrThrow(EVENTS_INTEGER, null, v); |
|
1243 } catch (SQLiteConstraintException e) { |
|
1244 throw new IllegalStateException("Event did not reference existing an environment or field.", e); |
|
1245 } |
|
1246 } |
|
1247 } |
|
1248 |
|
1249 @Override |
|
1250 public void incrementDailyCount(int env, int day, int field) { |
|
1251 this.incrementDailyCount(env, day, field, 1); |
|
1252 } |
|
1253 |
|
1254 /** |
|
1255 * Are there events recorded on or after <code>time</code>? |
|
1256 * |
|
1257 * @param time milliseconds since epoch. Will be converted by {@link #getDay(long)}. |
|
1258 * @return true if such events exist, false otherwise. |
|
1259 */ |
|
1260 @Override |
|
1261 public boolean hasEventSince(long time) { |
|
1262 final int start = this.getDay(time); |
|
1263 final SQLiteDatabase db = this.helper.getReadableDatabase(); |
|
1264 final String dayString = Integer.toString(start, 10); |
|
1265 Cursor cur = db.query("events", COLUMNS_DATE_ENV_FIELD_VALUE, |
|
1266 "date >= ?", new String[] {dayString}, null, null, null, "1"); |
|
1267 if (cur == null) { |
|
1268 // Something is horribly wrong; let the caller who actually reads the |
|
1269 // events deal with it. |
|
1270 return true; |
|
1271 } |
|
1272 try { |
|
1273 return cur.getCount() > 0; |
|
1274 } finally { |
|
1275 cur.close(); |
|
1276 } |
|
1277 } |
|
1278 |
|
1279 /** |
|
1280 * Returns a cursor over field events in the database. The results will be |
|
1281 * strictly ordered first by date, then by environment, and finally by field. |
|
1282 * |
|
1283 * Each row includes columns in {@link #COLUMNS_DATE_ENV_FIELD_VALUE}: |
|
1284 * "date", "env", "field", "value". |
|
1285 * |
|
1286 * @param time milliseconds since epoch. Will be converted by {@link #getDay(long)}. |
|
1287 * @return a cursor. The caller is responsible for closing this. |
|
1288 */ |
|
1289 @Override |
|
1290 public Cursor getRawEventsSince(long time) { |
|
1291 final int start = this.getDay(time); |
|
1292 final SQLiteDatabase db = this.helper.getReadableDatabase(); |
|
1293 final String dayString = Integer.toString(start, 10); |
|
1294 return db.query("events", COLUMNS_DATE_ENV_FIELD_VALUE, |
|
1295 "date >= ?", new String[] {dayString}, null, null, "date, env, field"); |
|
1296 } |
|
1297 |
|
1298 /** |
|
1299 * Returns a cursor over field events in the database. The results will be |
|
1300 * strictly ordered first by date, then by environment, and finally by field. |
|
1301 * |
|
1302 * Each row includes columns in {@link #COLUMNS_DATE_ENVSTR_M_MV_F_VALUE}: |
|
1303 * "date", "environment" (as a String), "measurement_name", "measurement_version", |
|
1304 * "field_name", "field_flags", "value". |
|
1305 * |
|
1306 * @param time milliseconds since epoch. Will be converted by {@link #getDay(long)}. |
|
1307 * @return a cursor. The caller is responsible for closing this. |
|
1308 */ |
|
1309 @Override |
|
1310 public Cursor getEventsSince(long time) { |
|
1311 final int start = this.getDay(time); |
|
1312 final SQLiteDatabase db = this.helper.getReadableDatabase(); |
|
1313 final String dayString = Integer.toString(start, 10); |
|
1314 return db.query("named_events", COLUMNS_DATE_ENVSTR_M_MV_F_VALUE, |
|
1315 "date >= ?", new String[] {dayString}, null, null, |
|
1316 "date, environment, measurement_name, measurement_version, field_name"); |
|
1317 } |
|
1318 |
|
1319 public int getEventCount() { |
|
1320 return getRowCount("events"); |
|
1321 } |
|
1322 |
|
1323 public int getEnvironmentCount() { |
|
1324 return getRowCount("environments"); |
|
1325 } |
|
1326 |
|
1327 private int getRowCount(String table) { |
|
1328 // table should be parameterized, but SQL throws a compilation error if the table in unknown |
|
1329 // in advance. |
|
1330 return getIntFromQuery("SELECT COUNT(*) from " + table, null); |
|
1331 } |
|
1332 |
|
1333 /** |
|
1334 * Deletes all environments, addons, and events from the database before the given time. If this |
|
1335 * data does not have recorded dates but are no longer referenced by other fields, they are also |
|
1336 * removed (with exception to the current environment). |
|
1337 * |
|
1338 * @param time milliseconds since epoch. Will be converted by {@link #getDay(long)}. |
|
1339 * @param curEnv The ID of the current environment. |
|
1340 * @return The number of environments and addon entries deleted. |
|
1341 */ |
|
1342 public int deleteDataBefore(final long time, final int curEnv) { |
|
1343 final SQLiteDatabase db = this.helper.getWritableDatabase(); |
|
1344 db.beginTransaction(); |
|
1345 int numRowsDeleted = 0; |
|
1346 try { |
|
1347 numRowsDeleted += deleteEnvAndEventsBefore(db, time, curEnv); |
|
1348 numRowsDeleted += deleteOrphanedAddons(db); |
|
1349 db.setTransactionSuccessful(); |
|
1350 } finally { |
|
1351 db.endTransaction(); |
|
1352 } |
|
1353 return numRowsDeleted; |
|
1354 } |
|
1355 |
|
1356 /** |
|
1357 * Deletes environments and their referring events recorded before the given time. Environments |
|
1358 * referenced by no events are deleted, except for the current environment. |
|
1359 * |
|
1360 * @param time milliseconds since epoch. Will be converted by {@link #getDay(long)}. |
|
1361 * @param curEnv The ID of the current environment. |
|
1362 * @return The number of environments (not events) deleted. |
|
1363 */ |
|
1364 protected int deleteEnvAndEventsBefore(final long time, final int curEnv) { |
|
1365 final SQLiteDatabase db = this.helper.getWritableDatabase(); |
|
1366 return deleteEnvAndEventsBefore(db, time, curEnv); |
|
1367 } |
|
1368 |
|
1369 // Called internally only to ensure the same db instance is used. |
|
1370 protected int deleteEnvAndEventsBefore(final SQLiteDatabase db, final long time, final int curEnv) { |
|
1371 // Delete environments only referenced by events occuring before the given time. Cascade |
|
1372 // delete these events. |
|
1373 String whereClause = |
|
1374 "(SELECT COUNT(*) FROM events WHERE date >= ? " + |
|
1375 " AND events.env = environments.id) = 0 " + |
|
1376 "AND id IN (SELECT DISTINCT env FROM events WHERE date < ?)"; |
|
1377 final int day = this.getDay(time); |
|
1378 final String dayString = Integer.toString(day, 10); |
|
1379 String[] whereArgs = new String[] {dayString, dayString}; |
|
1380 |
|
1381 int numEnvDeleted = 0; |
|
1382 db.beginTransaction(); |
|
1383 try { |
|
1384 numEnvDeleted += db.delete("environments", whereClause, whereArgs); |
|
1385 numEnvDeleted += deleteOrphanedEnv(db, curEnv); |
|
1386 // We can't get the number of events deleted through cascading deletions so we do not record |
|
1387 // the number of events deleted here. |
|
1388 deleteEventsBefore(db, dayString); |
|
1389 db.setTransactionSuccessful(); |
|
1390 } finally { |
|
1391 db.endTransaction(); |
|
1392 } |
|
1393 return numEnvDeleted; |
|
1394 } |
|
1395 |
|
1396 /** |
|
1397 * Deletes environments not referenced by any events except for the given current environment. |
|
1398 */ |
|
1399 protected int deleteOrphanedEnv(final int curEnv) { |
|
1400 final SQLiteDatabase db = this.helper.getWritableDatabase(); |
|
1401 return deleteOrphanedEnv(db, curEnv); |
|
1402 } |
|
1403 |
|
1404 // Called internally only to ensure the same db instance is used. |
|
1405 @SuppressWarnings("static-method") |
|
1406 protected int deleteOrphanedEnv(final SQLiteDatabase db, final int curEnv) { |
|
1407 final String whereClause = |
|
1408 "id != ? AND " + |
|
1409 "id NOT IN (SELECT env FROM events)"; |
|
1410 final String[] whereArgs = new String[] {Integer.toString(curEnv)}; |
|
1411 return db.delete("environments", whereClause, whereArgs); |
|
1412 } |
|
1413 |
|
1414 protected int deleteEventsBefore(final String dayString) { |
|
1415 final SQLiteDatabase db = this.helper.getWritableDatabase(); |
|
1416 return deleteEventsBefore(db, dayString); |
|
1417 } |
|
1418 |
|
1419 // Called internally only to ensure the same db instance is used. |
|
1420 @SuppressWarnings("static-method") |
|
1421 protected int deleteEventsBefore(final SQLiteDatabase db, final String dayString) { |
|
1422 final String whereClause = "date < ?"; |
|
1423 final String[] whereArgs = new String[] {dayString}; |
|
1424 int numEventsDeleted = 0; |
|
1425 db.beginTransaction(); |
|
1426 try { |
|
1427 numEventsDeleted += db.delete("events_integer", whereClause, whereArgs); |
|
1428 numEventsDeleted += db.delete("events_textual", whereClause, whereArgs); |
|
1429 db.setTransactionSuccessful(); |
|
1430 } finally { |
|
1431 db.endTransaction(); |
|
1432 } |
|
1433 return numEventsDeleted; |
|
1434 } |
|
1435 |
|
1436 /** |
|
1437 * Deletes addons not referenced by any environments. |
|
1438 */ |
|
1439 protected int deleteOrphanedAddons() { |
|
1440 final SQLiteDatabase db = this.helper.getWritableDatabase(); |
|
1441 return deleteOrphanedAddons(db); |
|
1442 } |
|
1443 |
|
1444 // Called internally only to ensure the same db instance is used. |
|
1445 @SuppressWarnings("static-method") |
|
1446 protected int deleteOrphanedAddons(final SQLiteDatabase db) { |
|
1447 final String whereClause = "id NOT IN (SELECT addonsID FROM environments)"; |
|
1448 return db.delete("addons", whereClause, null); |
|
1449 } |
|
1450 |
|
1451 /** |
|
1452 * Retrieve a mapping from a table. Keys should be unique; only one key-value |
|
1453 * pair will be returned for each key. |
|
1454 */ |
|
1455 private static HashMap<String, Integer> getIntegers(SQLiteDatabase db, String table, String columnA, String columnB) { |
|
1456 Cursor c = db.query(table, new String[] {columnA, columnB}, null, null, null, null, null); |
|
1457 try { |
|
1458 if (!c.moveToFirst()) { |
|
1459 return null; |
|
1460 } |
|
1461 |
|
1462 HashMap<String, Integer> results = new HashMap<String, Integer>(); |
|
1463 while (!c.isAfterLast()) { |
|
1464 results.put(c.getString(0), c.getInt(1)); |
|
1465 c.moveToNext(); |
|
1466 } |
|
1467 return results; |
|
1468 } finally { |
|
1469 c.close(); |
|
1470 } |
|
1471 } |
|
1472 |
|
1473 /** |
|
1474 * Retrieve a single value from a mapping table. |
|
1475 */ |
|
1476 private int integerQuery(String table, String column, String where, String[] args, int defaultValue) { |
|
1477 final SQLiteDatabase db = this.helper.getReadableDatabase(); |
|
1478 Cursor c = db.query(table, new String[] {column}, where, args, null, null, column + " DESC", "1"); |
|
1479 try { |
|
1480 if (!c.moveToFirst()) { |
|
1481 return defaultValue; |
|
1482 } |
|
1483 return c.getInt(0); |
|
1484 } finally { |
|
1485 c.close(); |
|
1486 } |
|
1487 } |
|
1488 |
|
1489 /** |
|
1490 * Helper to allow us to avoid excessive code duplication. |
|
1491 * |
|
1492 * @param v |
|
1493 * the destination <code>ContentValues</code>. |
|
1494 * @param value |
|
1495 * either a <code>String</code> or an <code>Integer</code>. No type |
|
1496 * checking is performed. |
|
1497 */ |
|
1498 private static final void putValue(final ContentValues v, Object value) { |
|
1499 if (value instanceof String) { |
|
1500 v.put("value", (String) value); |
|
1501 } else { |
|
1502 v.put("value", (Integer) value); |
|
1503 } |
|
1504 } |
|
1505 |
|
1506 @Override |
|
1507 public void deleteEverything() { |
|
1508 this.helper.deleteEverything(); |
|
1509 } |
|
1510 |
|
1511 @Override |
|
1512 public void deleteEnvironments() { |
|
1513 final SQLiteDatabase db = this.helper.getWritableDatabase(); |
|
1514 db.beginTransaction(); |
|
1515 try { |
|
1516 // Cascade will clear the rest. |
|
1517 db.delete("environments", null, null); |
|
1518 db.setTransactionSuccessful(); |
|
1519 } finally { |
|
1520 db.endTransaction(); |
|
1521 } |
|
1522 } |
|
1523 |
|
1524 @Override |
|
1525 public void deleteMeasurements() { |
|
1526 final SQLiteDatabase db = this.helper.getWritableDatabase(); |
|
1527 db.beginTransaction(); |
|
1528 try { |
|
1529 // Cascade will clear the rest. |
|
1530 db.delete("measurements", null, null); |
|
1531 |
|
1532 // Clear measurements and fields cache, because some of their IDs are now invalid. |
|
1533 invalidateFieldsCache(); // Let it repopulate on its own. |
|
1534 populateMeasurementVersionsCache(db); // Performed at Storage init so repopulate now. |
|
1535 |
|
1536 db.setTransactionSuccessful(); |
|
1537 } finally { |
|
1538 db.endTransaction(); |
|
1539 } |
|
1540 } |
|
1541 |
|
1542 /** |
|
1543 * Prunes the given number of least-recently used environments. Note that orphaned environments |
|
1544 * are not removed and the environment cache is cleared. |
|
1545 */ |
|
1546 public void pruneEnvironments(final int numToPrune) { |
|
1547 final SQLiteDatabase db = this.helper.getWritableDatabase(); |
|
1548 db.beginTransaction(); |
|
1549 try { |
|
1550 db.delete("environments", |
|
1551 "id in (SELECT env " + |
|
1552 " FROM events " + |
|
1553 " GROUP BY env " + |
|
1554 " ORDER BY MAX(date), env " + |
|
1555 " LIMIT " + numToPrune + ")", |
|
1556 null); |
|
1557 db.setTransactionSuccessful(); |
|
1558 |
|
1559 // Clear environment cache, because some of their IDs are now invalid. |
|
1560 this.envs.clear(); |
|
1561 } finally { |
|
1562 db.endTransaction(); |
|
1563 } |
|
1564 } |
|
1565 |
|
1566 /** |
|
1567 * Prunes up to a maximum of the given number of the oldest events. While it is more correct to |
|
1568 * prune the exact given amount, there is no unique identifier among events so we cannot be so |
|
1569 * precise. Instead, we prune on date, deleting all events up to the day before our count of |
|
1570 * events reaches the given maximum. Note that this technicality means this method cannot be |
|
1571 * used to delete all events. |
|
1572 */ |
|
1573 public void pruneEvents(final int maxNumToPrune) { |
|
1574 final SQLiteDatabase db = this.helper.getWritableDatabase(); |
|
1575 |
|
1576 final Cursor c = db.rawQuery( |
|
1577 "SELECT MAX(date) " + |
|
1578 "FROM (SELECT date " + |
|
1579 " FROM events " + |
|
1580 " ORDER BY date " + |
|
1581 " LIMIT " + maxNumToPrune + ")", |
|
1582 null); |
|
1583 long pruneDate = -1; |
|
1584 try { |
|
1585 if (!c.moveToFirst()) { |
|
1586 Logger.debug(LOG_TAG, "No max date found in events: table is likely empty. Not pruning " + |
|
1587 "events."); |
|
1588 return; |
|
1589 } |
|
1590 pruneDate = c.getLong(0); |
|
1591 } finally { |
|
1592 c.close(); |
|
1593 } |
|
1594 |
|
1595 final String selection = "date < " + pruneDate; |
|
1596 db.beginTransaction(); |
|
1597 try { |
|
1598 db.delete(EVENTS_INTEGER, selection, null); |
|
1599 db.delete(EVENTS_TEXTUAL, selection, null); |
|
1600 db.setTransactionSuccessful(); |
|
1601 } finally { |
|
1602 db.endTransaction(); |
|
1603 } |
|
1604 } |
|
1605 |
|
1606 public void vacuum() { |
|
1607 final SQLiteDatabase db = this.helper.getWritableDatabase(); |
|
1608 db.execSQL("vacuum"); |
|
1609 } |
|
1610 |
|
1611 /** |
|
1612 * Disables auto_vacuuming. Changes may only take effect after a "vacuum" command. |
|
1613 */ |
|
1614 public void disableAutoVacuuming() { |
|
1615 final SQLiteDatabase db = this.helper.getWritableDatabase(); |
|
1616 db.execSQL("PRAGMA auto_vacuum=0"); |
|
1617 } |
|
1618 } |