michael@0: /* This Source Code Form is subject to the terms of the Mozilla Public michael@0: * License, v. 2.0. If a copy of the MPL was not distributed with this michael@0: * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ michael@0: michael@0: package org.mozilla.gecko.background.healthreport; michael@0: michael@0: import java.io.File; michael@0: import java.io.FileNotFoundException; michael@0: import java.io.FileOutputStream; michael@0: import java.io.IOException; michael@0: import java.io.OutputStreamWriter; michael@0: import java.nio.charset.Charset; michael@0: import java.util.Locale; michael@0: import java.util.Scanner; michael@0: michael@0: import org.json.JSONException; michael@0: import org.json.JSONObject; michael@0: import org.mozilla.gecko.background.common.log.Logger; michael@0: import org.mozilla.gecko.background.healthreport.EnvironmentBuilder.ProfileInformationProvider; michael@0: michael@0: /** michael@0: * There are some parts of the FHR environment that can't be readily computed michael@0: * without a running Gecko -- add-ons, for example. In order to make this michael@0: * information available without launching Gecko, we persist it on Fennec michael@0: * startup. This class is the notepad in which we write. michael@0: */ michael@0: public class ProfileInformationCache implements ProfileInformationProvider { michael@0: private static final String LOG_TAG = "GeckoProfileInfo"; michael@0: private static final String CACHE_FILE = "profile_info_cache.json"; michael@0: michael@0: /* michael@0: * FORMAT_VERSION history: michael@0: * -: No version number; implicit v1. michael@0: * 1: Add versioning (Bug 878670). michael@0: * 2: Bump to regenerate add-on set after landing Bug 900694 (Bug 901622). michael@0: * 3: Add distribution, osLocale, appLocale. michael@0: */ michael@0: public static final int FORMAT_VERSION = 3; michael@0: michael@0: protected boolean initialized = false; michael@0: protected boolean needsWrite = false; michael@0: michael@0: protected final File file; michael@0: michael@0: private volatile boolean blocklistEnabled = true; michael@0: private volatile boolean telemetryEnabled = false; michael@0: private volatile boolean isAcceptLangUserSet = false; michael@0: michael@0: private volatile long profileCreationTime = 0; michael@0: private volatile String distribution = ""; michael@0: michael@0: // There are really four kinds of locale in play: michael@0: // michael@0: // * The OS michael@0: // * The Android environment of the app (setDefault) michael@0: // * The Gecko locale michael@0: // * The requested content locale (Accept-Language). michael@0: // michael@0: // We track only the first two, assuming that the Gecko locale will typically michael@0: // be the same as the app locale. michael@0: // michael@0: // The app locale is fetched from the PIC because it can be modified at michael@0: // runtime -- it won't necessarily be what Locale.getDefaultLocale() returns michael@0: // in a fresh non-browser profile. michael@0: // michael@0: // We also track the OS locale here for the same reason -- we need to store michael@0: // the default (OS) value before the locale-switching code takes effect! michael@0: private volatile String osLocale = ""; michael@0: private volatile String appLocale = ""; michael@0: michael@0: private volatile JSONObject addons = null; michael@0: michael@0: public ProfileInformationCache(String profilePath) { michael@0: file = new File(profilePath + File.separator + CACHE_FILE); michael@0: Logger.pii(LOG_TAG, "Using " + file.getAbsolutePath() + " for profile information cache."); michael@0: } michael@0: michael@0: public synchronized void beginInitialization() { michael@0: initialized = false; michael@0: needsWrite = true; michael@0: } michael@0: michael@0: public JSONObject toJSON() { michael@0: JSONObject object = new JSONObject(); michael@0: try { michael@0: object.put("version", FORMAT_VERSION); michael@0: object.put("blocklist", blocklistEnabled); michael@0: object.put("telemetry", telemetryEnabled); michael@0: object.put("isAcceptLangUserSet", isAcceptLangUserSet); michael@0: object.put("profileCreated", profileCreationTime); michael@0: object.put("osLocale", osLocale); michael@0: object.put("appLocale", appLocale); michael@0: object.put("distribution", distribution); michael@0: object.put("addons", addons); michael@0: } catch (JSONException e) { michael@0: // There isn't much we can do about this. michael@0: // Let's just quietly muffle. michael@0: return null; michael@0: } michael@0: return object; michael@0: } michael@0: michael@0: /** michael@0: * Attempt to restore this object from a JSON blob. If there is a version mismatch, there has michael@0: * likely been an upgrade to the cache format. The cache can be reconstructed without data loss michael@0: * so rather than migrating, we invalidate the cache by refusing to store the given JSONObject michael@0: * and returning false. michael@0: * michael@0: * @return false if there's a version mismatch or an error, true on success. michael@0: */ michael@0: private boolean fromJSON(JSONObject object) throws JSONException { michael@0: int version = object.optInt("version", 1); michael@0: switch (version) { michael@0: case FORMAT_VERSION: michael@0: blocklistEnabled = object.getBoolean("blocklist"); michael@0: telemetryEnabled = object.getBoolean("telemetry"); michael@0: isAcceptLangUserSet = object.getBoolean("isAcceptLangUserSet"); michael@0: profileCreationTime = object.getLong("profileCreated"); michael@0: addons = object.getJSONObject("addons"); michael@0: distribution = object.getString("distribution"); michael@0: osLocale = object.getString("osLocale"); michael@0: appLocale = object.getString("appLocale"); michael@0: return true; michael@0: default: michael@0: Logger.warn(LOG_TAG, "Unable to restore from version " + version + " PIC file: expecting " + FORMAT_VERSION); michael@0: return false; michael@0: } michael@0: } michael@0: michael@0: protected JSONObject readFromFile() throws FileNotFoundException, JSONException { michael@0: Scanner scanner = null; michael@0: try { michael@0: scanner = new Scanner(file, "UTF-8"); michael@0: final String contents = scanner.useDelimiter("\\A").next(); michael@0: return new JSONObject(contents); michael@0: } finally { michael@0: if (scanner != null) { michael@0: scanner.close(); michael@0: } michael@0: } michael@0: } michael@0: michael@0: protected void writeToFile(JSONObject object) throws IOException { michael@0: Logger.debug(LOG_TAG, "Writing profile information."); michael@0: Logger.pii(LOG_TAG, "Writing to file: " + file.getAbsolutePath()); michael@0: FileOutputStream stream = new FileOutputStream(file); michael@0: OutputStreamWriter writer = new OutputStreamWriter(stream, Charset.forName("UTF-8")); michael@0: try { michael@0: writer.append(object.toString()); michael@0: needsWrite = false; michael@0: } finally { michael@0: writer.close(); michael@0: } michael@0: } michael@0: michael@0: /** michael@0: * Call this on a background thread when you're done adding things. michael@0: * @throws IOException if there was a problem serializing or writing the cache to disk. michael@0: */ michael@0: public synchronized void completeInitialization() throws IOException { michael@0: initialized = true; michael@0: if (!needsWrite) { michael@0: Logger.debug(LOG_TAG, "No write needed."); michael@0: return; michael@0: } michael@0: michael@0: JSONObject object = toJSON(); michael@0: if (object == null) { michael@0: throw new IOException("Couldn't serialize JSON."); michael@0: } michael@0: michael@0: writeToFile(object); michael@0: } michael@0: michael@0: /** michael@0: * Call this if you're interested in reading. michael@0: * michael@0: * You should be doing so on a background thread. michael@0: * michael@0: * @return true if this object was initialized correctly. michael@0: */ michael@0: public synchronized boolean restoreUnlessInitialized() { michael@0: if (initialized) { michael@0: return true; michael@0: } michael@0: michael@0: if (!file.exists()) { michael@0: return false; michael@0: } michael@0: michael@0: // One-liner for file reading in Java. So sorry. michael@0: Logger.info(LOG_TAG, "Restoring ProfileInformationCache from file."); michael@0: Logger.pii(LOG_TAG, "Restoring from file: " + file.getAbsolutePath()); michael@0: michael@0: try { michael@0: if (!fromJSON(readFromFile())) { michael@0: // No need to blow away the file; the caller can eventually overwrite it. michael@0: return false; michael@0: } michael@0: initialized = true; michael@0: needsWrite = false; michael@0: return true; michael@0: } catch (FileNotFoundException e) { michael@0: return false; michael@0: } catch (JSONException e) { michael@0: Logger.warn(LOG_TAG, "Malformed ProfileInformationCache. Not restoring."); michael@0: return false; michael@0: } michael@0: } michael@0: michael@0: private void ensureInitialized() { michael@0: if (!initialized) { michael@0: throw new IllegalStateException("Not initialized."); michael@0: } michael@0: } michael@0: michael@0: @Override michael@0: public boolean isBlocklistEnabled() { michael@0: ensureInitialized(); michael@0: return blocklistEnabled; michael@0: } michael@0: michael@0: public void setBlocklistEnabled(boolean value) { michael@0: Logger.debug(LOG_TAG, "Setting blocklist enabled: " + value); michael@0: blocklistEnabled = value; michael@0: needsWrite = true; michael@0: } michael@0: michael@0: @Override michael@0: public boolean isTelemetryEnabled() { michael@0: ensureInitialized(); michael@0: return telemetryEnabled; michael@0: } michael@0: michael@0: public void setTelemetryEnabled(boolean value) { michael@0: Logger.debug(LOG_TAG, "Setting telemetry enabled: " + value); michael@0: telemetryEnabled = value; michael@0: needsWrite = true; michael@0: } michael@0: michael@0: @Override michael@0: public boolean isAcceptLangUserSet() { michael@0: ensureInitialized(); michael@0: return isAcceptLangUserSet; michael@0: } michael@0: michael@0: public void setAcceptLangUserSet(boolean value) { michael@0: Logger.debug(LOG_TAG, "Setting accept-lang as user-set: " + value); michael@0: isAcceptLangUserSet = value; michael@0: needsWrite = true; michael@0: } michael@0: michael@0: @Override michael@0: public long getProfileCreationTime() { michael@0: ensureInitialized(); michael@0: return profileCreationTime; michael@0: } michael@0: michael@0: public void setProfileCreationTime(long value) { michael@0: Logger.debug(LOG_TAG, "Setting profile creation time: " + value); michael@0: profileCreationTime = value; michael@0: needsWrite = true; michael@0: } michael@0: michael@0: @Override michael@0: public String getDistributionString() { michael@0: ensureInitialized(); michael@0: return distribution; michael@0: } michael@0: michael@0: /** michael@0: * Ensure that your arguments are non-null. michael@0: */ michael@0: public void setDistributionString(String distributionID, String distributionVersion) { michael@0: Logger.debug(LOG_TAG, "Setting distribution: " + distributionID + ", " + distributionVersion); michael@0: distribution = distributionID + ":" + distributionVersion; michael@0: needsWrite = true; michael@0: } michael@0: michael@0: @Override michael@0: public String getAppLocale() { michael@0: ensureInitialized(); michael@0: return appLocale; michael@0: } michael@0: michael@0: public void setAppLocale(String value) { michael@0: if (value.equalsIgnoreCase(appLocale)) { michael@0: return; michael@0: } michael@0: Logger.debug(LOG_TAG, "Setting app locale: " + value); michael@0: appLocale = value.toLowerCase(Locale.US); michael@0: needsWrite = true; michael@0: } michael@0: michael@0: @Override michael@0: public String getOSLocale() { michael@0: ensureInitialized(); michael@0: return osLocale; michael@0: } michael@0: michael@0: public void setOSLocale(String value) { michael@0: if (value.equalsIgnoreCase(osLocale)) { michael@0: return; michael@0: } michael@0: Logger.debug(LOG_TAG, "Setting OS locale: " + value); michael@0: osLocale = value.toLowerCase(Locale.US); michael@0: needsWrite = true; michael@0: } michael@0: michael@0: /** michael@0: * Update the PIC, if necessary, to match the current locale environment. michael@0: * michael@0: * @return true if the PIC needed to be updated. michael@0: */ michael@0: public boolean updateLocales(String osLocale, String appLocale) { michael@0: if (this.osLocale.equalsIgnoreCase(osLocale) && michael@0: (appLocale == null || this.appLocale.equalsIgnoreCase(appLocale))) { michael@0: return false; michael@0: } michael@0: this.setOSLocale(osLocale); michael@0: if (appLocale != null) { michael@0: this.setAppLocale(appLocale); michael@0: } michael@0: return true; michael@0: } michael@0: michael@0: @Override michael@0: public JSONObject getAddonsJSON() { michael@0: ensureInitialized(); michael@0: return addons; michael@0: } michael@0: michael@0: public void updateJSONForAddon(String id, String json) throws Exception { michael@0: addons.put(id, new JSONObject(json)); michael@0: needsWrite = true; michael@0: } michael@0: michael@0: public void removeAddon(String id) { michael@0: if (null != addons.remove(id)) { michael@0: needsWrite = true; michael@0: } michael@0: } michael@0: michael@0: /** michael@0: * Will throw if you haven't done a full update at least once. michael@0: */ michael@0: public void updateJSONForAddon(String id, JSONObject json) { michael@0: if (addons == null) { michael@0: throw new IllegalStateException("Cannot incrementally update add-ons without first initializing."); michael@0: } michael@0: try { michael@0: addons.put(id, json); michael@0: needsWrite = true; michael@0: } catch (Exception e) { michael@0: // Why would this happen? michael@0: Logger.warn(LOG_TAG, "Unexpected failure updating JSON for add-on.", e); michael@0: } michael@0: } michael@0: michael@0: /** michael@0: * Update the cached set of add-ons. Throws on invalid input. michael@0: * michael@0: * @param json a valid add-ons JSON string. michael@0: */ michael@0: public void setJSONForAddons(String json) throws Exception { michael@0: addons = new JSONObject(json); michael@0: needsWrite = true; michael@0: } michael@0: michael@0: public void setJSONForAddons(JSONObject json) { michael@0: addons = json; michael@0: needsWrite = true; michael@0: } michael@0: }