/* This Source Code Form is subject to the terms of the Mozilla Public * License, v. 2.0. If a copy of the MPL was not distributed with this * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ package org.mozilla.goanna.background.healthreport; import java.util.HashSet; import java.util.Iterator; import java.util.Set; import org.json.JSONException; import org.json.JSONObject; import org.mozilla.goanna.background.common.DateUtils.DateFormatter; import org.mozilla.goanna.background.common.log.Logger; import org.mozilla.goanna.background.healthreport.EnvironmentBuilder.ConfigurationProvider; import org.mozilla.goanna.background.healthreport.HealthReportStorage.Field; import android.database.Cursor; import android.util.SparseArray; public class HealthReportGenerator { private static final int PAYLOAD_VERSION = 3; private static final String LOG_TAG = "GoannaHealthGen"; private final HealthReportStorage storage; private final DateFormatter dateFormatter; public HealthReportGenerator(HealthReportStorage storage) { this.storage = storage; this.dateFormatter = new DateFormatter(); } @SuppressWarnings("static-method") protected long now() { return System.currentTimeMillis(); } /** * Ensure that you have initialized the Locale to your satisfaction * prior to calling this method. * * @return null if no environment could be computed, or else the resulting document. * @throws JSONException if there was an error adding environment data to the resulting document. */ public JSONObject generateDocument(long since, long lastPingTime, String profilePath, ConfigurationProvider config) throws JSONException { Logger.info(LOG_TAG, "Generating FHR document from " + since + "; last ping " + lastPingTime); Logger.pii(LOG_TAG, "Generating for profile " + profilePath); ProfileInformationCache cache = new ProfileInformationCache(profilePath); if (!cache.restoreUnlessInitialized()) { Logger.warn(LOG_TAG, "Not enough profile information to compute current environment."); return null; } Environment current = EnvironmentBuilder.getCurrentEnvironment(cache, config); return generateDocument(since, lastPingTime, current); } /** * The document consists of: * * * * days is a map from date strings to {hash: {measurement: {_v: version, fields...}}}. * @throws JSONException if there was an error adding environment data to the resulting document. */ public JSONObject generateDocument(long since, long lastPingTime, Environment currentEnvironment) throws JSONException { final String currentHash = currentEnvironment.getHash(); Logger.debug(LOG_TAG, "Current environment hash: " + currentHash); if (currentHash == null) { Logger.warn(LOG_TAG, "Current hash is null; aborting."); return null; } // We want to map field IDs to some strings as we go. SparseArray envs = storage.getEnvironmentRecordsByID(); JSONObject document = new JSONObject(); if (lastPingTime >= HealthReportConstants.EARLIEST_LAST_PING) { document.put("lastPingDate", dateFormatter.getDateString(lastPingTime)); } document.put("thisPingDate", dateFormatter.getDateString(now())); document.put("version", PAYLOAD_VERSION); document.put("environments", getEnvironmentsJSON(currentEnvironment, envs)); document.put("data", getDataJSON(currentEnvironment, envs, since)); return document; } protected JSONObject getDataJSON(Environment currentEnvironment, SparseArray envs, long since) throws JSONException { SparseArray fields = storage.getFieldsByID(); JSONObject days = getDaysJSON(currentEnvironment, envs, fields, since); JSONObject last = new JSONObject(); JSONObject data = new JSONObject(); data.put("days", days); data.put("last", last); return data; } protected JSONObject getDaysJSON(Environment currentEnvironment, SparseArray envs, SparseArray fields, long since) throws JSONException { if (Logger.shouldLogVerbose(LOG_TAG)) { for (int i = 0; i < envs.size(); ++i) { Logger.trace(LOG_TAG, "Days environment " + envs.keyAt(i) + ": " + envs.get(envs.keyAt(i)).getHash()); } } JSONObject days = new JSONObject(); Cursor cursor = storage.getRawEventsSince(since); try { if (!cursor.moveToFirst()) { return days; } // A classic walking partition. // Columns are "date", "env", "field", "value". // Note that we care about the type (integer, string) and kind // (last/counter, discrete) of each field. // Each field will be accessed once for each date/env pair, so // Field memoizes these facts. // We also care about which measurement contains each field. int lastDate = -1; int lastEnv = -1; JSONObject dateObject = null; JSONObject envObject = null; while (!cursor.isAfterLast()) { int cEnv = cursor.getInt(1); if (cEnv == -1 || (cEnv != lastEnv && envs.indexOfKey(cEnv) < 0)) { Logger.warn(LOG_TAG, "Invalid environment " + cEnv + " in cursor. Skipping."); cursor.moveToNext(); continue; } int cDate = cursor.getInt(0); int cField = cursor.getInt(2); Logger.trace(LOG_TAG, "Event row: " + cDate + ", " + cEnv + ", " + cField); boolean dateChanged = cDate != lastDate; boolean envChanged = cEnv != lastEnv; if (dateChanged) { if (dateObject != null) { days.put(dateFormatter.getDateStringForDay(lastDate), dateObject); } dateObject = new JSONObject(); lastDate = cDate; } if (dateChanged || envChanged) { envObject = new JSONObject(); // This is safe because we checked above that cEnv is valid. dateObject.put(envs.get(cEnv).getHash(), envObject); lastEnv = cEnv; } final Field field = fields.get(cField); JSONObject measurement = envObject.optJSONObject(field.measurementName); if (measurement == null) { // We will never have more than one measurement version within a // single environment -- to do so involves changing the build ID. And // even if we did, we have no way to represent it. So just build the // output object once. measurement = new JSONObject(); measurement.put("_v", field.measurementVersion); envObject.put(field.measurementName, measurement); } // How we record depends on the type of the field, so we // break this out into a separate method for clarity. recordMeasurementFromCursor(field, measurement, cursor); cursor.moveToNext(); continue; } days.put(dateFormatter.getDateStringForDay(lastDate), dateObject); } finally { cursor.close(); } return days; } /** * Return the {@link JSONObject} parsed from the provided index of the given * cursor, or {@link JSONObject#NULL} if either SQL NULL or * string "null" is present at that index. */ private static Object getJSONAtIndex(Cursor cursor, int index) throws JSONException { if (cursor.isNull(index)) { return JSONObject.NULL; } final String value = cursor.getString(index); if ("null".equals(value)) { return JSONObject.NULL; } return new JSONObject(value); } protected static void recordMeasurementFromCursor(final Field field, JSONObject measurement, Cursor cursor) throws JSONException { if (field.isDiscreteField()) { // Discrete counted. Increment the named counter. if (field.isCountedField()) { if (!field.isStringField()) { throw new IllegalStateException("Unable to handle non-string counted types."); } HealthReportUtils.count(measurement, field.fieldName, cursor.getString(3)); return; } // Discrete string or integer. Append it. if (field.isStringField()) { HealthReportUtils.append(measurement, field.fieldName, cursor.getString(3)); return; } if (field.isJSONField()) { HealthReportUtils.append(measurement, field.fieldName, getJSONAtIndex(cursor, 3)); return; } if (field.isIntegerField()) { HealthReportUtils.append(measurement, field.fieldName, cursor.getLong(3)); return; } throw new IllegalStateException("Unknown field type: " + field.flags); } // Non-discrete -- must be LAST or COUNTER, so just accumulate the value. if (field.isStringField()) { measurement.put(field.fieldName, cursor.getString(3)); return; } if (field.isJSONField()) { measurement.put(field.fieldName, getJSONAtIndex(cursor, 3)); return; } measurement.put(field.fieldName, cursor.getLong(3)); } public static JSONObject getEnvironmentsJSON(Environment currentEnvironment, SparseArray envs) throws JSONException { JSONObject environments = new JSONObject(); // Always do this, even if it hasn't recorded anything in the DB. environments.put("current", jsonify(currentEnvironment, null)); String currentHash = currentEnvironment.getHash(); for (int i = 0; i < envs.size(); i++) { Environment e = envs.valueAt(i); if (currentHash.equals(e.getHash())) { continue; } environments.put(e.getHash(), jsonify(e, currentEnvironment)); } return environments; } public static JSONObject jsonify(Environment e, Environment current) throws JSONException { JSONObject age = getProfileAge(e, current); JSONObject sysinfo = getSysInfo(e, current); JSONObject goanna = getGoannaInfo(e, current); JSONObject appinfo = getAppInfo(e, current); JSONObject counts = getAddonCounts(e, current); JSONObject config = getDeviceConfig(e, current); JSONObject out = new JSONObject(); if (age != null) out.put("org.mozilla.profile.age", age); if (sysinfo != null) out.put("org.mozilla.sysinfo.sysinfo", sysinfo); if (goanna != null) out.put("goannaAppInfo", goanna); if (appinfo != null) out.put("org.mozilla.appInfo.appinfo", appinfo); if (counts != null) out.put("org.mozilla.addons.counts", counts); JSONObject active = getActiveAddons(e, current); if (active != null) out.put("org.mozilla.addons.active", active); if (config != null) out.put("org.mozilla.device.config", config); if (current == null) { out.put("hash", e.getHash()); } return out; } // v3 environment fields. private static JSONObject getDeviceConfig(Environment e, Environment current) throws JSONException { JSONObject config = new JSONObject(); int changes = 0; if (e.version < 3) { return null; } if (current != null && current.version < 3) { return getDeviceConfig(e, null); } if (current == null || current.hasHardwareKeyboard != e.hasHardwareKeyboard) { config.put("hasHardwareKeyboard", e.hasHardwareKeyboard); changes++; } if (current == null || current.screenLayout != e.screenLayout) { config.put("screenLayout", e.screenLayout); changes++; } if (current == null || current.screenXInMM != e.screenXInMM) { config.put("screenXInMM", e.screenXInMM); changes++; } if (current == null || current.screenYInMM != e.screenYInMM) { config.put("screenYInMM", e.screenYInMM); changes++; } if (current == null || current.uiType != e.uiType) { config.put("uiType", e.uiType.toString()); changes++; } if (current == null || current.uiMode != e.uiMode) { config.put("uiMode", e.uiMode); changes++; } if (current != null && changes == 0) { return null; } config.put("_v", 1); return config; } private static JSONObject getProfileAge(Environment e, Environment current) throws JSONException { JSONObject age = new JSONObject(); int changes = 0; if (current == null || current.profileCreation != e.profileCreation) { age.put("profileCreation", e.profileCreation); changes++; } if (current != null && changes == 0) { return null; } age.put("_v", 1); return age; } private static JSONObject getSysInfo(Environment e, Environment current) throws JSONException { JSONObject sysinfo = new JSONObject(); int changes = 0; if (current == null || current.cpuCount != e.cpuCount) { sysinfo.put("cpuCount", e.cpuCount); changes++; } if (current == null || current.memoryMB != e.memoryMB) { sysinfo.put("memoryMB", e.memoryMB); changes++; } if (current == null || !current.architecture.equals(e.architecture)) { sysinfo.put("architecture", e.architecture); changes++; } if (current == null || !current.sysName.equals(e.sysName)) { sysinfo.put("name", e.sysName); changes++; } if (current == null || !current.sysVersion.equals(e.sysVersion)) { sysinfo.put("version", e.sysVersion); changes++; } if (current != null && changes == 0) { return null; } sysinfo.put("_v", 1); return sysinfo; } private static JSONObject getGoannaInfo(Environment e, Environment current) throws JSONException { JSONObject goanna = new JSONObject(); int changes = 0; if (current == null || !current.vendor.equals(e.vendor)) { goanna.put("vendor", e.vendor); changes++; } if (current == null || !current.appName.equals(e.appName)) { goanna.put("name", e.appName); changes++; } if (current == null || !current.appID.equals(e.appID)) { goanna.put("id", e.appID); changes++; } if (current == null || !current.appVersion.equals(e.appVersion)) { goanna.put("version", e.appVersion); changes++; } if (current == null || !current.appBuildID.equals(e.appBuildID)) { goanna.put("appBuildID", e.appBuildID); changes++; } if (current == null || !current.platformVersion.equals(e.platformVersion)) { goanna.put("platformVersion", e.platformVersion); changes++; } if (current == null || !current.platformBuildID.equals(e.platformBuildID)) { goanna.put("platformBuildID", e.platformBuildID); changes++; } if (current == null || !current.os.equals(e.os)) { goanna.put("os", e.os); changes++; } if (current == null || !current.xpcomabi.equals(e.xpcomabi)) { goanna.put("xpcomabi", e.xpcomabi); changes++; } if (current == null || !current.updateChannel.equals(e.updateChannel)) { goanna.put("updateChannel", e.updateChannel); changes++; } if (current != null && changes == 0) { return null; } goanna.put("_v", 1); return goanna; } // Null-safe string comparison. private static boolean stringsDiffer(final String a, final String b) { if (a == null) { return b != null; } return !a.equals(b); } private static JSONObject getAppInfo(Environment e, Environment current) throws JSONException { JSONObject appinfo = new JSONObject(); Logger.debug(LOG_TAG, "Generating appinfo for v" + e.version + " env " + e.hash); // Is the environment in question newer than the diff target, or is // there no diff target? final boolean outdated = current == null || e.version > current.version; // Is the environment in question a different version (lower or higher), // or is there no diff target? final boolean differ = outdated || current.version > e.version; // Always produce an output object if there's a version mismatch or this // isn't a diff. Otherwise, track as we go if there's any difference. boolean changed = differ; switch (e.version) { // There's a straightforward correspondence between environment versions // and appinfo versions. case 3: case 2: appinfo.put("_v", 3); break; case 1: appinfo.put("_v", 2); break; default: Logger.warn(LOG_TAG, "Unknown environment version: " + e.version); return appinfo; } switch (e.version) { case 3: case 2: if (populateAppInfoV2(appinfo, e, current, outdated)) { changed = true; } // Fall through. case 1: // There is no older version than v1, so don't check outdated. if (populateAppInfoV1(e, current, appinfo)) { changed = true; } } if (!changed) { return null; } return appinfo; } private static boolean populateAppInfoV1(Environment e, Environment current, JSONObject appinfo) throws JSONException { boolean changes = false; if (current == null || current.isBlocklistEnabled != e.isBlocklistEnabled) { appinfo.put("isBlocklistEnabled", e.isBlocklistEnabled); changes = true; } if (current == null || current.isTelemetryEnabled != e.isTelemetryEnabled) { appinfo.put("isTelemetryEnabled", e.isTelemetryEnabled); changes = true; } return changes; } private static boolean populateAppInfoV2(JSONObject appinfo, Environment e, Environment current, final boolean outdated) throws JSONException { boolean changes = false; if (outdated || stringsDiffer(current.osLocale, e.osLocale)) { appinfo.put("osLocale", e.osLocale); changes = true; } if (outdated || stringsDiffer(current.appLocale, e.appLocale)) { appinfo.put("appLocale", e.appLocale); changes = true; } if (outdated || stringsDiffer(current.distribution, e.distribution)) { appinfo.put("distribution", e.distribution); changes = true; } if (outdated || current.acceptLangSet != e.acceptLangSet) { appinfo.put("acceptLangIsUserSet", e.acceptLangSet); changes = true; } return changes; } private static JSONObject getAddonCounts(Environment e, Environment current) throws JSONException { JSONObject counts = new JSONObject(); int changes = 0; if (current == null || current.extensionCount != e.extensionCount) { counts.put("extension", e.extensionCount); changes++; } if (current == null || current.pluginCount != e.pluginCount) { counts.put("plugin", e.pluginCount); changes++; } if (current == null || current.themeCount != e.themeCount) { counts.put("theme", e.themeCount); changes++; } if (current != null && changes == 0) { return null; } counts.put("_v", 1); return counts; } /** * Compute the *tree* difference set between the two objects. If the two * objects are identical, returns null. If from is * null, returns to. If to is * null, behaves as if to were an empty object. * * (Note that this method does not check for {@link JSONObject#NULL}, because * by definition it can't be provided as input to this method.) * * This behavior is intended to simplify life for callers: a missing object * can be viewed as (and behaves as) an empty map, to a useful extent, rather * than throwing an exception. * * @param from * a JSONObject. * @param to * a JSONObject. * @param includeNull * if true, keys present in from but not in * to are included as {@link JSONObject#NULL} in the * output. * * @return a JSONObject, or null if the two objects are identical. * @throws JSONException * should not occur, but... */ public static JSONObject diff(JSONObject from, JSONObject to, boolean includeNull) throws JSONException { if (from == null) { return to; } if (to == null) { return diff(from, new JSONObject(), includeNull); } JSONObject out = new JSONObject(); HashSet toKeys = includeNull ? new HashSet(to.length()) : null; @SuppressWarnings("unchecked") Iterator it = to.keys(); while (it.hasNext()) { String key = it.next(); // Track these as we go if we'll need them later. if (includeNull) { toKeys.add(key); } Object value = to.get(key); if (!from.has(key)) { // It must be new. out.put(key, value); continue; } // Not new? Then see if it changed. Object old = from.get(key); // Two JSONObjects should be diffed. if (old instanceof JSONObject && value instanceof JSONObject) { JSONObject innerDiff = diff(((JSONObject) old), ((JSONObject) value), includeNull); // No change? No output. if (innerDiff == null) { continue; } // Otherwise include the diff. out.put(key, innerDiff); continue; } // A regular value, or a type change. Only skip if they're the same. if (value.equals(old)) { continue; } out.put(key, value); } // Now -- if requested -- include any removed keys. if (includeNull) { Set fromKeys = HealthReportUtils.keySet(from); fromKeys.removeAll(toKeys); for (String notPresent : fromKeys) { out.put(notPresent, JSONObject.NULL); } } if (out.length() == 0) { return null; } return out; } private static JSONObject getActiveAddons(Environment e, Environment current) throws JSONException { // Just return the current add-on set, with a version annotation. // To do so requires copying. if (current == null) { JSONObject out = e.getNonIgnoredAddons(); if (out == null) { Logger.warn(LOG_TAG, "Null add-ons to return in FHR document. Returning {}."); out = new JSONObject(); // So that we always return something. } out.put("_v", 1); return out; } // Otherwise, return the diff. JSONObject diff = diff(current.getNonIgnoredAddons(), e.getNonIgnoredAddons(), true); if (diff == null) { return null; } if (diff == e.addons) { // Again, needs to copy. return getActiveAddons(e, null); } diff.put("_v", 1); return diff; } }