summaryrefslogtreecommitdiffstats
path: root/mobile/android/geckoview/src/main/java/org/mozilla/gecko/GeckoProfile.java
diff options
context:
space:
mode:
Diffstat (limited to 'mobile/android/geckoview/src/main/java/org/mozilla/gecko/GeckoProfile.java')
-rw-r--r--mobile/android/geckoview/src/main/java/org/mozilla/gecko/GeckoProfile.java1002
1 files changed, 1002 insertions, 0 deletions
diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/gecko/GeckoProfile.java b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/GeckoProfile.java
new file mode 100644
index 000000000..27ec4f1dd
--- /dev/null
+++ b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/GeckoProfile.java
@@ -0,0 +1,1002 @@
+/* -*- Mode: Java; c-basic-offset: 4; tab-width: 20; indent-tabs-mode: nil; -*-
+ * 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.gecko;
+
+import android.app.Activity;
+import android.content.Context;
+import android.content.SharedPreferences;
+import android.os.Bundle;
+import android.support.annotation.WorkerThread;
+import android.text.TextUtils;
+import android.util.Log;
+
+import org.json.JSONArray;
+import org.json.JSONException;
+import org.json.JSONObject;
+import org.mozilla.gecko.GeckoProfileDirectories.NoMozillaDirectoryException;
+import org.mozilla.gecko.GeckoProfileDirectories.NoSuchProfileException;
+import org.mozilla.gecko.annotation.RobocopTarget;
+import org.mozilla.gecko.util.FileUtils;
+import org.mozilla.gecko.util.INIParser;
+import org.mozilla.gecko.util.INISection;
+import org.mozilla.gecko.util.IntentUtils;
+
+import java.io.BufferedWriter;
+import java.io.File;
+import java.io.FileOutputStream;
+import java.io.FileReader;
+import java.io.FileWriter;
+import java.io.IOException;
+import java.io.OutputStreamWriter;
+import java.nio.charset.Charset;
+import java.util.Enumeration;
+import java.util.Hashtable;
+import java.util.UUID;
+import java.util.concurrent.ConcurrentHashMap;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+
+public final class GeckoProfile {
+ private static final String LOGTAG = "GeckoProfile";
+
+ // The path in the profile to the file containing the client ID.
+ private static final String CLIENT_ID_FILE_PATH = "datareporting/state.json";
+ private static final String FHR_CLIENT_ID_FILE_PATH = "healthreport/state.json";
+ // In the client ID file, the attribute title in the JSON object containing the client ID value.
+ private static final String CLIENT_ID_JSON_ATTR = "clientID";
+
+ private static final String TIMES_PATH = "times.json";
+ private static final String PROFILE_CREATION_DATE_JSON_ATTR = "created";
+
+ // Only tests should need to do this.
+ // We can default this to AppConstants.RELEASE_OR_BETA once we fix Bug 1069687.
+ private static volatile boolean sAcceptDirectoryChanges = true;
+
+ @RobocopTarget
+ public static void enableDirectoryChanges() {
+ Log.w(LOGTAG, "Directory changes should only be enabled for tests. And even then it's a bad idea.");
+ sAcceptDirectoryChanges = true;
+ }
+
+ public static final String DEFAULT_PROFILE = "default";
+ // Profile is using a custom directory outside of the Mozilla directory.
+ public static final String CUSTOM_PROFILE = "";
+
+ public static final String GUEST_PROFILE_DIR = "guest";
+ public static final String GUEST_MODE_PREF = "guestMode";
+
+ // Session store
+ private static final String SESSION_FILE = "sessionstore.js";
+ private static final String SESSION_FILE_BACKUP = "sessionstore.bak";
+ private static final String SESSION_FILE_PREVIOUS = "sessionstore.old";
+ private static final long MAX_PREVIOUS_FILE_AGE = 1000 * 3600 * 24; // 24 hours
+
+ private boolean mOldSessionDataProcessed = false;
+
+ private static final ConcurrentHashMap<String, GeckoProfile> sProfileCache =
+ new ConcurrentHashMap<String, GeckoProfile>(
+ /* capacity */ 4, /* load factor */ 0.75f, /* concurrency */ 2);
+ private static String sDefaultProfileName;
+
+ private final String mName;
+ private final File mMozillaDir;
+ private final Context mApplicationContext;
+
+ private Object mData;
+
+ /**
+ * Access to this member should be synchronized to avoid
+ * races during creation -- particularly between getDir and GeckoView#init.
+ *
+ * Not final because this is lazily computed.
+ */
+ private File mProfileDir;
+
+ private Boolean mInGuestMode;
+
+ public static boolean shouldUseGuestMode(final Context context) {
+ return GeckoSharedPrefs.forApp(context).getBoolean(GUEST_MODE_PREF, false);
+ }
+
+ public static void enterGuestMode(final Context context) {
+ GeckoSharedPrefs.forApp(context).edit().putBoolean(GUEST_MODE_PREF, true).commit();
+ }
+
+ public static void leaveGuestMode(final Context context) {
+ GeckoSharedPrefs.forApp(context).edit().putBoolean(GUEST_MODE_PREF, false).commit();
+ }
+
+ public static GeckoProfile initFromArgs(final Context context, final String args) {
+ if (shouldUseGuestMode(context)) {
+ final GeckoProfile guestProfile = getGuestProfile(context);
+ if (guestProfile != null) {
+ return guestProfile;
+ }
+ // Failed to create guest profile; leave guest mode.
+ leaveGuestMode(context);
+ }
+
+ // We never want to use the guest mode profile concurrently with a normal profile
+ // -- no syncing to it, no dual-profile usage, nothing. GeckoThread startup with
+ // a conventional GeckoProfile will cause the guest profile to be deleted and
+ // guest mode to reset.
+ if (getGuestDir(context).isDirectory()) {
+ final GeckoProfile guestProfile = getGuestProfile(context);
+ if (guestProfile != null) {
+ removeProfile(context, guestProfile);
+ }
+ }
+
+ String profileName = null;
+ String profilePath = null;
+
+ if (args != null && args.contains("-P")) {
+ final Pattern p = Pattern.compile("(?:-P\\s*)(\\w*)(\\s*)");
+ final Matcher m = p.matcher(args);
+ if (m.find()) {
+ profileName = m.group(1);
+ }
+ }
+
+ if (args != null && args.contains("-profile")) {
+ final Pattern p = Pattern.compile("(?:-profile\\s*)(\\S*)(\\s*)");
+ final Matcher m = p.matcher(args);
+ if (m.find()) {
+ profilePath = m.group(1);
+ }
+ }
+
+ if (profileName == null && profilePath == null) {
+ // Get the default profile for the Activity.
+ return getDefaultProfile(context);
+ }
+
+ return GeckoProfile.get(context, profileName, profilePath);
+ }
+
+ private static GeckoProfile getDefaultProfile(Context context) {
+ try {
+ return get(context, getDefaultProfileName(context));
+
+ } catch (final NoMozillaDirectoryException e) {
+ // If this failed, we're screwed.
+ Log.wtf(LOGTAG, "Unable to get default profile name.", e);
+ throw new RuntimeException(e);
+ }
+ }
+
+ public static GeckoProfile get(Context context) {
+ return get(context, null, (File) null);
+ }
+
+ public static GeckoProfile get(Context context, String profileName) {
+ if (profileName != null) {
+ GeckoProfile profile = sProfileCache.get(profileName);
+ if (profile != null)
+ return profile;
+ }
+ return get(context, profileName, (File)null);
+ }
+
+ @RobocopTarget
+ public static GeckoProfile get(Context context, String profileName, String profilePath) {
+ File dir = null;
+ if (!TextUtils.isEmpty(profilePath)) {
+ dir = new File(profilePath);
+ if (!dir.exists() || !dir.isDirectory()) {
+ Log.w(LOGTAG, "requested profile directory missing: " + profilePath);
+ }
+ }
+ return get(context, profileName, dir);
+ }
+
+ // Note that the profile cache respects only the profile name!
+ // If the directory changes, the returned GeckoProfile instance will be mutated.
+ @RobocopTarget
+ public static GeckoProfile get(Context context, String profileName, File profileDir) {
+ if (context == null) {
+ throw new IllegalArgumentException("context must be non-null");
+ }
+
+ // Null name? | Null dir? | Returned profile
+ // ------------------------------------------
+ // Yes | Yes | Active profile or default profile.
+ // No | Yes | Profile with specified name at default dir.
+ // Yes | No | Custom (anonymous) profile with specified dir.
+ // No | No | Profile with specified name at specified dir.
+
+ if (profileName == null && profileDir == null) {
+ // If no profile info was passed in, look for the active profile or a default profile.
+ final GeckoProfile profile = GeckoThread.getActiveProfile();
+ if (profile != null) {
+ return profile;
+ }
+
+ final String args;
+ if (context instanceof Activity) {
+ args = IntentUtils.getStringExtraSafe(((Activity) context).getIntent(), "args");
+ } else {
+ args = null;
+ }
+
+ return GeckoProfile.initFromArgs(context, args);
+
+ } else if (profileName == null) {
+ // If only profile dir was passed in, use custom (anonymous) profile.
+ profileName = CUSTOM_PROFILE;
+
+ } else if (AppConstants.DEBUG_BUILD) {
+ Log.v(LOGTAG, "Fetching profile: '" + profileName + "', '" + profileDir + "'");
+ }
+
+ // We require the profile dir to exist if specified, so create it here if needed.
+ final boolean init = profileDir != null && profileDir.mkdirs();
+
+ // Actually try to look up the profile.
+ GeckoProfile profile = sProfileCache.get(profileName);
+ GeckoProfile newProfile = null;
+
+ if (profile == null) {
+ try {
+ newProfile = new GeckoProfile(context, profileName, profileDir);
+ } catch (NoMozillaDirectoryException e) {
+ // We're unable to do anything sane here.
+ throw new RuntimeException(e);
+ }
+
+ profile = sProfileCache.putIfAbsent(profileName, newProfile);
+ }
+
+ if (profile == null) {
+ profile = newProfile;
+
+ } else if (profileDir != null) {
+ // We have an existing profile but was given an alternate directory.
+ boolean consistent = false;
+ try {
+ consistent = profile.mProfileDir != null &&
+ profile.mProfileDir.getCanonicalPath().equals(profileDir.getCanonicalPath());
+ } catch (final IOException e) {
+ }
+
+ if (!consistent) {
+ if (!sAcceptDirectoryChanges || !profileDir.isDirectory()) {
+ throw new IllegalStateException(
+ "Refusing to reuse profile with a different directory.");
+ }
+
+ if (AppConstants.RELEASE_OR_BETA) {
+ Log.e(LOGTAG, "Release build trying to switch out profile dir. " +
+ "This is an error, but let's do what we can.");
+ }
+ profile.setDir(profileDir);
+ }
+ }
+
+ if (init) {
+ // Initialize the profile directory if we had to create it.
+ profile.enqueueInitialization(profileDir);
+ }
+
+ return profile;
+ }
+
+ // Currently unused outside of testing.
+ @RobocopTarget
+ public static boolean removeProfile(final Context context, final GeckoProfile profile) {
+ final boolean success = profile.remove();
+
+ if (success) {
+ // Clear all shared prefs for the given profile.
+ GeckoSharedPrefs.forProfileName(context, profile.getName())
+ .edit().clear().apply();
+ }
+
+ return success;
+ }
+
+ private static File getGuestDir(final Context context) {
+ return context.getFileStreamPath(GUEST_PROFILE_DIR);
+ }
+
+ @RobocopTarget
+ public static GeckoProfile getGuestProfile(final Context context) {
+ return get(context, CUSTOM_PROFILE, getGuestDir(context));
+ }
+
+ public static boolean isGuestProfile(final Context context, final String profileName,
+ final File profileDir) {
+ // Guest profile is just a custom profile with a special path.
+ if (profileDir == null || !CUSTOM_PROFILE.equals(profileName)) {
+ return false;
+ }
+
+ try {
+ return profileDir.getCanonicalPath().equals(getGuestDir(context).getCanonicalPath());
+ } catch (final IOException e) {
+ return false;
+ }
+ }
+
+ private GeckoProfile(Context context, String profileName, File profileDir) throws NoMozillaDirectoryException {
+ if (profileName == null) {
+ throw new IllegalArgumentException("Unable to create GeckoProfile for empty profile name.");
+ } else if (CUSTOM_PROFILE.equals(profileName) && profileDir == null) {
+ throw new IllegalArgumentException("Custom profile must have a directory");
+ }
+
+ mApplicationContext = context.getApplicationContext();
+ mName = profileName;
+ mMozillaDir = GeckoProfileDirectories.getMozillaDirectory(context);
+
+ mProfileDir = profileDir;
+ if (profileDir != null && !profileDir.isDirectory()) {
+ throw new IllegalArgumentException("Profile directory must exist if specified.");
+ }
+ }
+
+ /**
+ * Return the custom data object associated with this profile, which was set by the
+ * previous {@link #setData(Object)} call. This association is valid for the duration
+ * of the process lifetime. The caller must ensure proper synchronization, typically
+ * by synchronizing on the object returned by {@link #getLock()}.
+ *
+ * The data object is usually a database object that stores per-profile data such as
+ * page history. However, it can be any other object that needs to maintain
+ * profile-specific state.
+ *
+ * @return Associated data object
+ */
+ public Object getData() {
+ return mData;
+ }
+
+ /**
+ * Associate this profile with a custom data object, which can be retrieved by
+ * subsequent {@link #getData()} calls. The caller must ensure proper
+ * synchronization, typically by synchronizing on the object returned by {@link
+ * #getLock()}.
+ *
+ * @param data Custom data object
+ */
+ public void setData(final Object data) {
+ mData = data;
+ }
+
+ private void setDir(File dir) {
+ if (dir != null && dir.exists() && dir.isDirectory()) {
+ synchronized (this) {
+ mProfileDir = dir;
+ mInGuestMode = null;
+ }
+ }
+ }
+
+ @RobocopTarget
+ public String getName() {
+ return mName;
+ }
+
+ public boolean isCustomProfile() {
+ return CUSTOM_PROFILE.equals(mName);
+ }
+
+ @RobocopTarget
+ public boolean inGuestMode() {
+ if (mInGuestMode == null) {
+ mInGuestMode = isGuestProfile(GeckoAppShell.getApplicationContext(),
+ mName, mProfileDir);
+ }
+ return mInGuestMode;
+ }
+
+ /**
+ * Return an Object that can be used with a synchronized statement to allow
+ * exclusive access to the profile.
+ */
+ public Object getLock() {
+ return this;
+ }
+
+ /**
+ * Retrieves the directory backing the profile. This method acts
+ * as a lazy initializer for the GeckoProfile instance.
+ */
+ @RobocopTarget
+ public synchronized File getDir() {
+ forceCreateLocked();
+ return mProfileDir;
+ }
+
+ /**
+ * Forces profile creation. Consider using {@link #getDir()} to initialize the profile instead - it is the
+ * lazy initializer and, for our code reasoning abilities, we should initialize the profile in one place.
+ */
+ private void forceCreateLocked() {
+ if (mProfileDir != null) {
+ return;
+ }
+
+ try {
+ // Check if a profile with this name already exists.
+ try {
+ mProfileDir = findProfileDir();
+ Log.d(LOGTAG, "Found profile dir.");
+ } catch (NoSuchProfileException noSuchProfile) {
+ // If it doesn't exist, create it.
+ mProfileDir = createProfileDir();
+ }
+ } catch (IOException ioe) {
+ Log.e(LOGTAG, "Error getting profile dir", ioe);
+ }
+ }
+
+ public File getFile(String aFile) {
+ File f = getDir();
+ if (f == null)
+ return null;
+
+ return new File(f, aFile);
+ }
+
+ /**
+ * Retrieves the Gecko client ID from the filesystem. If the client ID does not exist, we attempt to migrate and
+ * persist it from FHR and, if that fails, we attempt to create a new one ourselves.
+ *
+ * This method assumes the client ID is located in a file at a hard-coded path within the profile. The format of
+ * this file is a JSONObject which at the bottom level contains a String -> String mapping containing the client ID.
+ *
+ * WARNING: the platform provides a JSM to retrieve the client ID [1] and this would be a
+ * robust way to access it. However, we don't want to rely on Gecko running in order to get
+ * the client ID so instead we access the file this module accesses directly. However, it's
+ * possible the format of this file (and the access calls in the jsm) will change, leaving
+ * this code to fail. There are tests in TestGeckoProfile to verify the file format but be
+ * warned: THIS IS NOT FOOLPROOF.
+ *
+ * [1]: https://dxr.mozilla.org/mozilla-central/source/toolkit/modules/ClientID.jsm
+ *
+ * @throws IOException if the client ID could not be retrieved.
+ */
+ // Mimics ClientID.jsm – _doLoadClientID.
+ @WorkerThread
+ public String getClientId() throws IOException {
+ try {
+ return getValidClientIdFromDisk(CLIENT_ID_FILE_PATH);
+ } catch (final IOException e) {
+ // Avoid log spam: don't log the full Exception w/ the stack trace.
+ Log.d(LOGTAG, "Could not get client ID - attempting to migrate ID from FHR: " + e.getLocalizedMessage());
+ }
+
+ String clientIdToWrite;
+ try {
+ clientIdToWrite = getValidClientIdFromDisk(FHR_CLIENT_ID_FILE_PATH);
+ } catch (final IOException e) {
+ // Avoid log spam: don't log the full Exception w/ the stack trace.
+ Log.d(LOGTAG, "Could not migrate client ID from FHR – creating a new one: " + e.getLocalizedMessage());
+ clientIdToWrite = generateNewClientId();
+ }
+
+ // There is a possibility Gecko is running and the Gecko telemetry implementation decided it's time to generate
+ // the client ID, writing client ID underneath us. Since it's highly unlikely (e.g. we run in onStart before
+ // Gecko is started), we don't handle that possibility besides writing the ID and then reading from the file
+ // again (rather than just returning the value we generated before writing).
+ //
+ // In the event it does happen, any discrepancy will be resolved after a restart. In the mean time, both this
+ // implementation and the Gecko implementation could upload documents with inconsistent IDs.
+ //
+ // In any case, if we get an exception, intentionally throw - there's nothing more to do here.
+ persistClientId(clientIdToWrite);
+ return getValidClientIdFromDisk(CLIENT_ID_FILE_PATH);
+ }
+
+ protected static String generateNewClientId() {
+ return UUID.randomUUID().toString();
+ }
+
+ /**
+ * @return a valid client ID
+ * @throws IOException if a valid client ID could not be retrieved
+ */
+ @WorkerThread
+ private String getValidClientIdFromDisk(final String filePath) throws IOException {
+ final JSONObject obj = readJSONObjectFromFile(filePath);
+ final String clientId = obj.optString(CLIENT_ID_JSON_ATTR);
+ if (isClientIdValid(clientId)) {
+ return clientId;
+ }
+ throw new IOException("Received client ID is invalid: " + clientId);
+ }
+
+ /**
+ * Persists the given client ID to disk. This will overwrite any existing files.
+ */
+ @WorkerThread
+ private void persistClientId(final String clientId) throws IOException {
+ if (!ensureParentDirs(CLIENT_ID_FILE_PATH)) {
+ throw new IOException("Could not create client ID parent directories");
+ }
+
+ final JSONObject obj = new JSONObject();
+ try {
+ obj.put(CLIENT_ID_JSON_ATTR, clientId);
+ } catch (final JSONException e) {
+ throw new IOException("Could not create client ID JSON object", e);
+ }
+
+ // ClientID.jsm overwrites the file to store the client ID so it's okay if we do it too.
+ Log.d(LOGTAG, "Attempting to write new client ID");
+ writeFile(CLIENT_ID_FILE_PATH, obj.toString()); // Logs errors within function: ideally we'd throw.
+ }
+
+ // From ClientID.jsm - isValidClientID.
+ public static boolean isClientIdValid(final String clientId) {
+ // We could use UUID.fromString but, for consistency, we take the implementation from ClientID.jsm.
+ if (TextUtils.isEmpty(clientId)) {
+ return false;
+ }
+ return clientId.matches("(?i:[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})");
+ }
+
+ /**
+ * Gets the profile creation date and persists it if it had to be generated.
+ *
+ * To get this value, we first look in times.json. If that could not be accessed, we
+ * return the package's first install date. This is not a perfect solution because a
+ * user may have large gap between install time and first use.
+ *
+ * A more correct algorithm could be the one performed by the JS code in ProfileAge.jsm
+ * getOldestProfileTimestamp: walk the tree and return the oldest timestamp on the files
+ * within the profile. However, since times.json will only not exist for the small
+ * number of really old profiles, we're okay with the package install date compromise for
+ * simplicity.
+ *
+ * @return the profile creation date in the format returned by {@link System#currentTimeMillis()}
+ * or -1 if the value could not be persisted.
+ */
+ @WorkerThread
+ public long getAndPersistProfileCreationDate(final Context context) {
+ try {
+ return getProfileCreationDateFromTimesFile();
+ } catch (final IOException e) {
+ Log.d(LOGTAG, "Unable to retrieve profile creation date from times.json. Getting from system...");
+ final long packageInstallMillis = org.mozilla.gecko.util.ContextUtils.getCurrentPackageInfo(context).firstInstallTime;
+ try {
+ persistProfileCreationDateToTimesFile(packageInstallMillis);
+ } catch (final IOException ioEx) {
+ // We return -1 to ensure the profileCreationDate
+ // will either be an error (-1) or a consistent value.
+ Log.w(LOGTAG, "Unable to persist profile creation date - returning -1");
+ return -1;
+ }
+
+ return packageInstallMillis;
+ }
+ }
+
+ @WorkerThread
+ private long getProfileCreationDateFromTimesFile() throws IOException {
+ final JSONObject obj = readJSONObjectFromFile(TIMES_PATH);
+ try {
+ return obj.getLong(PROFILE_CREATION_DATE_JSON_ATTR);
+ } catch (final JSONException e) {
+ // Don't log to avoid leaking data in JSONObject.
+ throw new IOException("Profile creation does not exist in JSONObject");
+ }
+ }
+
+ @WorkerThread
+ private void persistProfileCreationDateToTimesFile(final long profileCreationMillis) throws IOException {
+ final JSONObject obj = new JSONObject();
+ try {
+ obj.put(PROFILE_CREATION_DATE_JSON_ATTR, profileCreationMillis);
+ } catch (final JSONException e) {
+ // Don't log to avoid leaking data in JSONObject.
+ throw new IOException("Unable to persist profile creation date to times file");
+ }
+ Log.d(LOGTAG, "Attempting to write new profile creation date");
+ writeFile(TIMES_PATH, obj.toString()); // Ideally we'd throw here too.
+ }
+
+ /**
+ * Updates the state of the old session data file.
+ *
+ * sessionstore.js should hold the current session, and sessionstore.old should
+ * hold the previous session (where it is used to read the "tabs from last time").
+ * If we're not restoring tabs automatically, sessionstore.js needs to be moved to
+ * sessionstore.old, so we can display the correct "tabs from last time".
+ * If we *are* restoring tabs, we need to delete outdated copies of sessionstore.old,
+ * so we don't continue showing stale "tabs from last time" indefinitely.
+ *
+ * @param shouldRestore Pass true if we are automatically restoring last session's tabs.
+ */
+ public void updateSessionFile(boolean shouldRestore) {
+ File sessionFilePrevious = getFile(SESSION_FILE_PREVIOUS);
+ if (!shouldRestore) {
+ File sessionFile = getFile(SESSION_FILE);
+ if (sessionFile != null && sessionFile.exists()) {
+ sessionFile.renameTo(sessionFilePrevious);
+ }
+ } else {
+ if (sessionFilePrevious != null && sessionFilePrevious.exists() &&
+ System.currentTimeMillis() - sessionFilePrevious.lastModified() > MAX_PREVIOUS_FILE_AGE) {
+ sessionFilePrevious.delete();
+ }
+ }
+ synchronized (this) {
+ mOldSessionDataProcessed = true;
+ notifyAll();
+ }
+ }
+
+ public void waitForOldSessionDataProcessing() {
+ synchronized (this) {
+ while (!mOldSessionDataProcessed) {
+ try {
+ wait();
+ } catch (final InterruptedException e) {
+ // Ignore and wait again.
+ }
+ }
+ }
+ }
+
+ /**
+ * Get the string from a session file.
+ *
+ * The session can either be read from sessionstore.js or sessionstore.bak.
+ * In general, sessionstore.js holds the current session, and
+ * sessionstore.bak holds a backup copy in case of interrupted writes.
+ *
+ * @param readBackup if true, the session is read from sessionstore.bak;
+ * otherwise, the session is read from sessionstore.js
+ *
+ * @return the session string
+ */
+ public String readSessionFile(boolean readBackup) {
+ return readSessionFile(readBackup ? SESSION_FILE_BACKUP : SESSION_FILE);
+ }
+
+ /**
+ * Get the string from last session's session file.
+ *
+ * If we are not restoring tabs automatically, sessionstore.old will contain
+ * the previous session.
+ *
+ * @return the session string
+ */
+ public String readPreviousSessionFile() {
+ return readSessionFile(SESSION_FILE_PREVIOUS);
+ }
+
+ private String readSessionFile(String fileName) {
+ File sessionFile = getFile(fileName);
+
+ try {
+ if (sessionFile != null && sessionFile.exists()) {
+ return readFile(sessionFile);
+ }
+ } catch (IOException ioe) {
+ Log.e(LOGTAG, "Unable to read session file", ioe);
+ }
+ return null;
+ }
+
+ /**
+ * Checks whether the session store file exists.
+ */
+ public boolean sessionFileExists() {
+ File sessionFile = getFile(SESSION_FILE);
+
+ return sessionFile != null && sessionFile.exists();
+ }
+
+ /**
+ * Ensures the parent director(y|ies) of the given filename exist by making them
+ * if they don't already exist..
+ *
+ * @param filename The path to the file whose parents should be made directories
+ * @return true if the parent directory exists, false otherwise
+ */
+ @WorkerThread
+ protected boolean ensureParentDirs(final String filename) {
+ final File file = new File(getDir(), filename);
+ final File parentFile = file.getParentFile();
+ return parentFile.mkdirs() || parentFile.isDirectory();
+ }
+
+ public void writeFile(final String filename, final String data) {
+ File file = new File(getDir(), filename);
+ BufferedWriter bufferedWriter = null;
+ try {
+ bufferedWriter = new BufferedWriter(new FileWriter(file, false));
+ bufferedWriter.write(data);
+ } catch (IOException e) {
+ Log.e(LOGTAG, "Unable to write to file", e);
+ } finally {
+ try {
+ if (bufferedWriter != null) {
+ bufferedWriter.close();
+ }
+ } catch (IOException e) {
+ Log.e(LOGTAG, "Error closing writer while writing to file", e);
+ }
+ }
+ }
+
+ @WorkerThread
+ public JSONObject readJSONObjectFromFile(final String filename) throws IOException {
+ final String fileContents;
+ try {
+ fileContents = readFile(filename);
+ } catch (final IOException e) {
+ // Don't log exception to avoid leaking profile path.
+ throw new IOException("Could not access given file to retrieve JSONObject");
+ }
+
+ try {
+ return new JSONObject(fileContents);
+ } catch (final JSONException e) {
+ // Don't log exception to avoid leaking profile path.
+ throw new IOException("Could not parse JSON to retrieve JSONObject");
+ }
+ }
+
+ public JSONArray readJSONArrayFromFile(final String filename) {
+ String fileContent;
+ try {
+ fileContent = readFile(filename);
+ } catch (IOException expected) {
+ return new JSONArray();
+ }
+
+ JSONArray jsonArray;
+ try {
+ jsonArray = new JSONArray(fileContent);
+ } catch (JSONException e) {
+ jsonArray = new JSONArray();
+ }
+ return jsonArray;
+ }
+
+ public String readFile(String filename) throws IOException {
+ File dir = getDir();
+ if (dir == null) {
+ throw new IOException("No profile directory found");
+ }
+ File target = new File(dir, filename);
+ return readFile(target);
+ }
+
+ private String readFile(File target) throws IOException {
+ FileReader fr = new FileReader(target);
+ try {
+ StringBuilder sb = new StringBuilder();
+ char[] buf = new char[8192];
+ int read = fr.read(buf);
+ while (read >= 0) {
+ sb.append(buf, 0, read);
+ read = fr.read(buf);
+ }
+ return sb.toString();
+ } finally {
+ fr.close();
+ }
+ }
+
+ public boolean deleteFileFromProfileDir(String fileName) throws IllegalArgumentException {
+ if (TextUtils.isEmpty(fileName)) {
+ throw new IllegalArgumentException("Filename cannot be empty.");
+ }
+ File file = new File(getDir(), fileName);
+ return file.delete();
+ }
+
+ private boolean remove() {
+ try {
+ synchronized (this) {
+ if (mProfileDir != null && mProfileDir.exists()) {
+ FileUtils.delete(mProfileDir);
+ }
+
+ if (isCustomProfile()) {
+ // Custom profiles don't have profile.ini sections that we need to remove.
+ return true;
+ }
+
+ try {
+ // If findProfileDir() succeeds, it means the profile was created
+ // through forceCreate(), so we set mProfileDir to null to enable
+ // forceCreate() to create the profile again.
+ findProfileDir();
+ mProfileDir = null;
+
+ } catch (final NoSuchProfileException e) {
+ // If findProfileDir() throws, it means the profile was not created
+ // through forceCreate(), and we have to preserve mProfileDir because
+ // it was given to us. In that case, there's nothing left to do here.
+ return true;
+ }
+ }
+
+ final INIParser parser = GeckoProfileDirectories.getProfilesINI(mMozillaDir);
+ final Hashtable<String, INISection> sections = parser.getSections();
+ for (Enumeration<INISection> e = sections.elements(); e.hasMoreElements();) {
+ final INISection section = e.nextElement();
+ String name = section.getStringProperty("Name");
+
+ if (name == null || !name.equals(mName)) {
+ continue;
+ }
+
+ if (section.getName().startsWith("Profile")) {
+ // ok, we have stupid Profile#-named things. Rename backwards.
+ try {
+ int sectionNumber = Integer.parseInt(section.getName().substring("Profile".length()));
+ String curSection = "Profile" + sectionNumber;
+ String nextSection = "Profile" + (sectionNumber + 1);
+
+ sections.remove(curSection);
+
+ while (sections.containsKey(nextSection)) {
+ parser.renameSection(nextSection, curSection);
+ sectionNumber++;
+
+ curSection = nextSection;
+ nextSection = "Profile" + (sectionNumber + 1);
+ }
+ } catch (NumberFormatException nex) {
+ // uhm, malformed Profile thing; we can't do much.
+ Log.e(LOGTAG, "Malformed section name in profiles.ini: " + section.getName());
+ return false;
+ }
+ } else {
+ // this really shouldn't be the case, but handle it anyway
+ parser.removeSection(mName);
+ }
+
+ break;
+ }
+
+ parser.write();
+ return true;
+ } catch (IOException ex) {
+ Log.w(LOGTAG, "Failed to remove profile.", ex);
+ return false;
+ }
+ }
+
+ /**
+ * @return the default profile name for this application, or
+ * {@link GeckoProfile#DEFAULT_PROFILE} if none could be found.
+ *
+ * @throws NoMozillaDirectoryException
+ * if the Mozilla directory did not exist and could not be
+ * created.
+ */
+ public static String getDefaultProfileName(final Context context) throws NoMozillaDirectoryException {
+ // Have we read the default profile from the INI already?
+ // Changing the default profile requires a restart, so we don't
+ // need to worry about runtime changes.
+ if (sDefaultProfileName != null) {
+ return sDefaultProfileName;
+ }
+
+ final String profileName = GeckoProfileDirectories.findDefaultProfileName(context);
+ if (profileName == null) {
+ // Note that we don't persist this back to profiles.ini.
+ sDefaultProfileName = DEFAULT_PROFILE;
+ return DEFAULT_PROFILE;
+ }
+
+ sDefaultProfileName = profileName;
+ return sDefaultProfileName;
+ }
+
+ private File findProfileDir() throws NoSuchProfileException {
+ if (isCustomProfile()) {
+ return mProfileDir;
+ }
+ return GeckoProfileDirectories.findProfileDir(mMozillaDir, mName);
+ }
+
+ @WorkerThread
+ private File createProfileDir() throws IOException {
+ if (isCustomProfile()) {
+ // Custom profiles must already exist.
+ return mProfileDir;
+ }
+
+ INIParser parser = GeckoProfileDirectories.getProfilesINI(mMozillaDir);
+
+ // Salt the name of our requested profile
+ String saltedName;
+ File profileDir;
+ do {
+ saltedName = GeckoProfileDirectories.saltProfileName(mName);
+ profileDir = new File(mMozillaDir, saltedName);
+ } while (profileDir.exists());
+
+ // Attempt to create the salted profile dir
+ if (!profileDir.mkdirs()) {
+ throw new IOException("Unable to create profile.");
+ }
+ Log.d(LOGTAG, "Created new profile dir.");
+
+ // Now update profiles.ini
+ // If this is the first time its created, we also add a General section
+ // look for the first profile number that isn't taken yet
+ int profileNum = 0;
+ boolean isDefaultSet = false;
+ INISection profileSection;
+ while ((profileSection = parser.getSection("Profile" + profileNum)) != null) {
+ profileNum++;
+ if (profileSection.getProperty("Default") != null) {
+ isDefaultSet = true;
+ }
+ }
+
+ profileSection = new INISection("Profile" + profileNum);
+ profileSection.setProperty("Name", mName);
+ profileSection.setProperty("IsRelative", 1);
+ profileSection.setProperty("Path", saltedName);
+
+ if (parser.getSection("General") == null) {
+ INISection generalSection = new INISection("General");
+ generalSection.setProperty("StartWithLastProfile", 1);
+ parser.addSection(generalSection);
+ }
+
+ if (!isDefaultSet) {
+ // only set as default if this is the first profile we're creating
+ profileSection.setProperty("Default", 1);
+ }
+
+ parser.addSection(profileSection);
+ parser.write();
+
+ enqueueInitialization(profileDir);
+
+ // Write out profile creation time, mirroring the logic in nsToolkitProfileService.
+ try {
+ FileOutputStream stream = new FileOutputStream(profileDir.getAbsolutePath() + File.separator + TIMES_PATH);
+ OutputStreamWriter writer = new OutputStreamWriter(stream, Charset.forName("UTF-8"));
+ try {
+ writer.append("{\"created\": " + System.currentTimeMillis() + "}\n");
+ } finally {
+ writer.close();
+ }
+ } catch (Exception e) {
+ // Best-effort.
+ Log.w(LOGTAG, "Couldn't write " + TIMES_PATH, e);
+ }
+
+ // Create the client ID file before Gecko starts (we assume this method
+ // is called before Gecko starts). If we let Gecko start, the JS telemetry
+ // code may try to write to the file at the same time Java does.
+ persistClientId(generateNewClientId());
+
+ return profileDir;
+ }
+
+ /**
+ * This method is called once, immediately before creation of the profile
+ * directory completes.
+ *
+ * It queues up work to be done in the background to prepare the profile,
+ * such as adding default bookmarks.
+ *
+ * This is public for use *from tests only*!
+ */
+ @RobocopTarget
+ public void enqueueInitialization(final File profileDir) {
+ Log.i(LOGTAG, "Enqueuing profile init.");
+
+ final Bundle message = new Bundle(2);
+ message.putCharSequence("name", getName());
+ message.putCharSequence("path", profileDir.getAbsolutePath());
+ EventDispatcher.getInstance().dispatch("Profile:Create", message);
+ }
+}