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