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 --- a/mobile/android/geckoview/src/main/java/org/mozilla/gecko/GeckoProfile.java +++ b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/GeckoProfile.java @@ -1,981 +1,984 @@ /* -*- 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.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.GeckoBundle; 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"; // 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 remove this entirely 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 sProfileCache = new ConcurrentHashMap( /* capacity */ 4, /* load factor */ 0.75f, /* concurrency */ 2); private static String sDefaultProfileName; private static String sIntentArgs; private final String mName; private final File mMozillaDir; 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 void setIntentArgs(final String intentArgs) { sIntentArgs = intentArgs; } 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; } return GeckoProfile.initFromArgs(context, sIntentArgs); } else if (profileName == null) { // If only profile dir was passed in, use custom (anonymous) profile. profileName = CUSTOM_PROFILE; } // 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."); } 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"); } 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 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 - creating a new one: " + e.getLocalizedMessage()); } String 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 sections = parser.getSections(); + if (sections == null) { + return false; + } for (Enumeration 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 GeckoBundle message = new GeckoBundle(2); message.putString("name", getName()); message.putString("path", profileDir.getAbsolutePath()); EventDispatcher.getInstance().dispatch("Profile:Create", message); } } diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/gecko/media/GeckoHLSDemuxerWrapper.java b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/media/GeckoHLSDemuxerWrapper.java --- a/mobile/android/geckoview/src/main/java/org/mozilla/gecko/media/GeckoHLSDemuxerWrapper.java +++ b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/media/GeckoHLSDemuxerWrapper.java @@ -1,166 +1,168 @@ /* 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.media; import android.util.Log; import java.util.concurrent.ConcurrentLinkedQueue; import org.mozilla.geckoview.BuildConfig; import org.mozilla.gecko.annotation.WrapForJNI; import org.mozilla.gecko.mozglue.JNIObject; public final class GeckoHLSDemuxerWrapper { private static final String LOGTAG = "GeckoHLSDemuxerWrapper"; private static final boolean DEBUG = !BuildConfig.MOZILLA_OFFICIAL; // NOTE : These TRACK definitions should be synced with Gecko. public enum TrackType { UNDEFINED(0), AUDIO(1), VIDEO(2), TEXT(3); private int mType; private TrackType(int type) { mType = type; } public int value() { return mType; } } private BaseHlsPlayer mPlayer = null; public static class Callbacks extends JNIObject implements BaseHlsPlayer.DemuxerCallbacks { @WrapForJNI(calledFrom = "gecko") Callbacks() {} @Override @WrapForJNI public native void onInitialized(boolean hasAudio, boolean hasVideo); @Override @WrapForJNI public native void onError(int errorCode); @Override // JNIObject protected void disposeNative() { throw new UnsupportedOperationException(); } } // Callbacks private static void assertTrue(boolean condition) { if (DEBUG && !condition) { throw new AssertionError("Expected condition to be true"); } } private BaseHlsPlayer.TrackType getPlayerTrackType(int trackType) { if (trackType == TrackType.AUDIO.value()) { return BaseHlsPlayer.TrackType.AUDIO; } else if (trackType == TrackType.VIDEO.value()) { return BaseHlsPlayer.TrackType.VIDEO; } else if (trackType == TrackType.TEXT.value()) { return BaseHlsPlayer.TrackType.TEXT; } return BaseHlsPlayer.TrackType.UNDEFINED; } @WrapForJNI public long getBuffered() { assertTrue(mPlayer != null); return mPlayer.getBufferedPosition(); } @WrapForJNI(calledFrom = "gecko") public static GeckoHLSDemuxerWrapper create(int id, BaseHlsPlayer.DemuxerCallbacks callback) { return new GeckoHLSDemuxerWrapper(id, callback); } @WrapForJNI public int getNumberOfTracks(int trackType) { assertTrue(mPlayer != null); int tracks = mPlayer.getNumberOfTracks(getPlayerTrackType(trackType)); if (DEBUG) Log.d(LOGTAG, "[GetNumberOfTracks] type : " + trackType + ", num = " + tracks); return tracks; } @WrapForJNI public GeckoAudioInfo getAudioInfo(int index) { assertTrue(mPlayer != null); if (DEBUG) Log.d(LOGTAG, "[getAudioInfo] formatIndex : " + index); GeckoAudioInfo aInfo = mPlayer.getAudioInfo(index); return aInfo; } @WrapForJNI public GeckoVideoInfo getVideoInfo(int index) { assertTrue(mPlayer != null); if (DEBUG) Log.d(LOGTAG, "[getVideoInfo] formatIndex : " + index); GeckoVideoInfo vInfo = mPlayer.getVideoInfo(index); return vInfo; } @WrapForJNI public boolean seek(long seekTime) { // seekTime : microseconds. assertTrue(mPlayer != null); if (DEBUG) Log.d(LOGTAG, "seek : " + seekTime + " (Us)"); return mPlayer.seek(seekTime); } GeckoHLSDemuxerWrapper(int id, BaseHlsPlayer.DemuxerCallbacks callback) { if (DEBUG) Log.d(LOGTAG, "Constructing GeckoHLSDemuxerWrapper ..."); assertTrue(callback != null); try { mPlayer = GeckoPlayerFactory.getPlayer(id); - mPlayer.addDemuxerWrapperCallbackListener(callback); + if (mPlayer != null) { + mPlayer.addDemuxerWrapperCallbackListener(callback); + } } catch (Exception e) { Log.e(LOGTAG, "Constructing GeckoHLSDemuxerWrapper ... error", e); callback.onError(BaseHlsPlayer.DemuxerError.UNKNOWN.code()); } } @WrapForJNI private GeckoHLSSample[] getSamples(int mediaType, int number) { assertTrue(mPlayer != null); ConcurrentLinkedQueue samples = null; // getA/VSamples will always return a non-null instance. samples = mPlayer.getSamples(getPlayerTrackType(mediaType), number); assertTrue(samples.size() <= number); return samples.toArray(new GeckoHLSSample[samples.size()]); } @WrapForJNI private long getNextKeyFrameTime() { assertTrue(mPlayer != null); return mPlayer.getNextKeyFrameTime(); } @WrapForJNI private boolean isLiveStream() { assertTrue(mPlayer != null); return mPlayer.isLiveStream(); } @WrapForJNI // Called when native object is destroyed. private void destroy() { if (DEBUG) Log.d(LOGTAG, "destroy!! Native object is destroyed."); if (mPlayer != null) { release(); } } private void release() { assertTrue(mPlayer != null); if (DEBUG) Log.d(LOGTAG, "release BaseHlsPlayer..."); GeckoPlayerFactory.removePlayer(mPlayer); mPlayer.release(); mPlayer = null; } } diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/gecko/process/GeckoProcessManager.java b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/process/GeckoProcessManager.java --- a/mobile/android/geckoview/src/main/java/org/mozilla/gecko/process/GeckoProcessManager.java +++ b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/process/GeckoProcessManager.java @@ -1,253 +1,256 @@ /* 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.process; import org.mozilla.gecko.GeckoAppShell; import org.mozilla.gecko.GeckoThread; import org.mozilla.gecko.IGeckoEditableParent; import org.mozilla.gecko.annotation.WrapForJNI; import org.mozilla.gecko.util.ThreadUtils; import android.content.ComponentName; import android.content.Context; import android.content.Intent; import android.content.ServiceConnection; import android.os.Bundle; import android.os.IBinder; import android.os.ParcelFileDescriptor; import android.os.Process; import android.os.RemoteException; import android.os.SystemClock; import android.support.v4.util.SimpleArrayMap; import android.util.Log; import java.io.IOException; public final class GeckoProcessManager extends IProcessManager.Stub { private static final String LOGTAG = "GeckoProcessManager"; private static final GeckoProcessManager INSTANCE = new GeckoProcessManager(); public static GeckoProcessManager getInstance() { return INSTANCE; } @WrapForJNI(stubName = "GetEditableParent") private static native IGeckoEditableParent nativeGetEditableParent(long contentId, long tabId); @Override // IProcessManager public IGeckoEditableParent getEditableParent(final long contentId, final long tabId) { return nativeGetEditableParent(contentId, tabId); } private static final class ChildConnection implements ServiceConnection, IBinder.DeathRecipient { private static final int WAIT_TIMEOUT = 5000; // 5 seconds private final String mType; private boolean mWaiting; private IChildProcess mChild; private int mPid; public ChildConnection(String type) { mType = type; } public synchronized int getPid() { if ((mPid == 0) && (mChild != null)) { try { mPid = mChild.getPid(); } catch (final RemoteException e) { Log.e(LOGTAG, "Cannot get pid for " + mType, e); } } return mPid; } public synchronized IChildProcess bind() { if (mChild != null) { return mChild; } final Context context = GeckoAppShell.getApplicationContext(); final Intent intent = new Intent(); intent.setClassName(context, GeckoServiceChildProcess.class.getName() + '$' + mType); if (context.bindService(intent, this, Context.BIND_AUTO_CREATE)) { waitForChildLocked(); if (mChild != null) { return mChild; } } Log.e(LOGTAG, "Cannot connect to process " + mType); context.unbindService(this); return null; } public synchronized void unbind() { if (mChild != null) { final Context context = GeckoAppShell.getApplicationContext(); context.unbindService(this); } final int pid = getPid(); if (pid != 0) { Process.killProcess(pid); waitForChildLocked(); } } private void waitForChildLocked() { ThreadUtils.assertNotOnUiThread(); mWaiting = true; final long startTime = SystemClock.uptimeMillis(); while (mWaiting && SystemClock.uptimeMillis() - startTime < WAIT_TIMEOUT) { try { wait(WAIT_TIMEOUT); } catch (final InterruptedException e) { } } mWaiting = false; } @Override public synchronized void onServiceConnected(ComponentName name, IBinder service) { try { service.linkToDeath(this, 0); } catch (final RemoteException e) { Log.e(LOGTAG, "Cannot link to death for " + mType, e); } mChild = IChildProcess.Stub.asInterface(service); mWaiting = false; notifyAll(); } @Override public synchronized void onServiceDisconnected(ComponentName name) { mChild = null; mPid = 0; mWaiting = false; notifyAll(); } @Override public synchronized void binderDied() { Log.i(LOGTAG, "Binder died for " + mType); if (mChild != null) { mChild = null; mPid = 0; GeckoAppShell.getApplicationContext().unbindService(this); } } } private final SimpleArrayMap mConnections; private GeckoProcessManager() { mConnections = new SimpleArrayMap(); } private ChildConnection getConnection(final String type) { synchronized (mConnections) { ChildConnection connection = mConnections.get(type); if (connection == null) { connection = new ChildConnection(type); mConnections.put(type, connection); } return connection; } } public void preload(final String... types) { for (final String type : types) { final ChildConnection connection = getConnection(type); if (connection.bind() != null) { connection.getPid(); } } } public void crashChild() { try { - mConnections.get("tab").bind().crash(); + IChildProcess childProcess = mConnections.get("tab").bind(); + if (childProcess != null) { + childProcess.crash(); + } } catch (RemoteException e) { } } @WrapForJNI private static int start(final String type, final String[] args, final int prefsFd, final int prefMapFd, final int ipcFd, final int crashFd, final int crashAnnotationFd) { return INSTANCE.start(type, args, prefsFd, prefMapFd, ipcFd, crashFd, crashAnnotationFd, /* retry */ false); } private int filterFlagsForChild(int flags) { return flags & (GeckoThread.FLAG_ENABLE_JAVA_CRASHREPORTER | GeckoThread.FLAG_ENABLE_NATIVE_CRASHREPORTER); } private int start(final String type, final String[] args, final int prefsFd, final int prefMapFd, final int ipcFd, final int crashFd, final int crashAnnotationFd, final boolean retry) { final ChildConnection connection = getConnection(type); final IChildProcess child = connection.bind(); if (child == null) { return 0; } final Bundle extras = GeckoThread.getActiveExtras(); final ParcelFileDescriptor prefsPfd = (prefsFd >= 0) ? ParcelFileDescriptor.adoptFd(prefsFd) : null; final ParcelFileDescriptor prefMapPfd = (prefMapFd >= 0) ? ParcelFileDescriptor.adoptFd(prefMapFd) : null; final ParcelFileDescriptor ipcPfd = ParcelFileDescriptor.adoptFd(ipcFd); final ParcelFileDescriptor crashPfd = (crashFd >= 0) ? ParcelFileDescriptor.adoptFd(crashFd) : null; final ParcelFileDescriptor crashAnnotationPfd = (crashAnnotationFd >= 0) ? ParcelFileDescriptor.adoptFd(crashAnnotationFd) : null; final int flags = filterFlagsForChild(GeckoThread.getActiveFlags()); boolean started = false; try { started = child.start(this, args, extras, flags, prefsPfd, prefMapPfd, ipcPfd, crashPfd, crashAnnotationPfd); } catch (final RemoteException e) { } if (crashAnnotationPfd != null) { crashAnnotationPfd.detachFd(); } if (crashPfd != null) { crashPfd.detachFd(); } ipcPfd.detachFd(); if (prefMapPfd != null) { prefMapPfd.detachFd(); } if (prefsPfd != null) { prefsPfd.detachFd(); } if (!started) { if (retry) { Log.e(LOGTAG, "Cannot restart child " + type); return 0; } Log.w(LOGTAG, "Attempting to kill running child " + type); connection.unbind(); return start(type, args, prefsFd, prefMapFd, ipcFd, crashFd, crashAnnotationFd, /* retry */ true); } return connection.getPid(); } } // GeckoProcessManager diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/GeckoInputConnection.java b/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/GeckoInputConnection.java --- a/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/GeckoInputConnection.java +++ b/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/GeckoInputConnection.java @@ -1,682 +1,685 @@ /* -*- 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.geckoview; import android.annotation.TargetApi; import android.content.Context; import android.content.res.Configuration; import android.graphics.Matrix; import android.graphics.RectF; import android.media.AudioManager; import android.os.Build; import android.os.Handler; import android.os.Looper; import android.support.annotation.NonNull; import android.text.Editable; import android.text.Selection; import android.text.SpannableString; import android.util.DisplayMetrics; import android.util.Log; import android.view.KeyEvent; import android.view.View; import android.view.inputmethod.BaseInputConnection; import android.view.inputmethod.CursorAnchorInfo; import android.view.inputmethod.EditorInfo; import android.view.inputmethod.ExtractedText; import android.view.inputmethod.ExtractedTextRequest; import android.view.inputmethod.InputConnection; import org.mozilla.gecko.Clipboard; import org.mozilla.gecko.InputMethods; import org.mozilla.gecko.util.ThreadUtils; import java.lang.reflect.InvocationHandler; import java.lang.reflect.Method; import java.lang.reflect.Proxy; /* package */ final class GeckoInputConnection extends BaseInputConnection implements SessionTextInput.InputConnectionClient, SessionTextInput.EditableListener { private static final boolean DEBUG = false; protected static final String LOGTAG = "GeckoInputConnection"; private static final String CUSTOM_HANDLER_TEST_METHOD = "testInputConnection"; private static final String CUSTOM_HANDLER_TEST_CLASS = "org.mozilla.gecko.tests.components.GeckoViewComponent$TextInput"; private static final int INLINE_IME_MIN_DISPLAY_SIZE = 480; private static Handler sBackgroundHandler; // Managed only by notifyIMEContext; see comments in notifyIMEContext private int mIMEState; private String mIMEActionHint = ""; private int mLastSelectionStart; private int mLastSelectionEnd; private String mCurrentInputMethod = ""; private final GeckoSession mSession; private final View mView; private final SessionTextInput.EditableClient mEditableClient; protected int mBatchEditCount; private ExtractedTextRequest mUpdateRequest; private final InputConnection mKeyInputConnection; private CursorAnchorInfo.Builder mCursorAnchorInfoBuilder; public static SessionTextInput.InputConnectionClient create( final GeckoSession session, final View targetView, final SessionTextInput.EditableClient editable) { SessionTextInput.InputConnectionClient ic = new GeckoInputConnection(session, targetView, editable); if (DEBUG) { ic = wrapForDebug(ic); } return ic; } private static SessionTextInput.InputConnectionClient wrapForDebug(final SessionTextInput.InputConnectionClient ic) { final InvocationHandler handler = new InvocationHandler() { private final StringBuilder mCallLevel = new StringBuilder(); @Override public Object invoke(final Object proxy, final Method method, final Object[] args) throws Throwable { final StringBuilder log = new StringBuilder(mCallLevel); log.append("> ").append(method.getName()).append("("); if (args != null) { for (int i = 0; i < args.length; i++) { final Object arg = args[i]; // translate argument values to constant names if ("notifyIME".equals(method.getName()) && i == 0) { log.append(GeckoEditable.getConstantName( SessionTextInput.EditableListener.class, "NOTIFY_IME_", arg)); } else if ("notifyIMEContext".equals(method.getName()) && i == 0) { log.append(GeckoEditable.getConstantName( SessionTextInput.EditableListener.class, "IME_STATE_", arg)); } else { GeckoEditable.debugAppend(log, arg); } log.append(", "); } if (args.length > 0) { log.setLength(log.length() - 2); } } log.append(")"); Log.d(LOGTAG, log.toString()); mCallLevel.append(' '); Object ret = method.invoke(ic, args); if (ret == ic) { ret = proxy; } mCallLevel.setLength(Math.max(0, mCallLevel.length() - 1)); log.setLength(mCallLevel.length()); log.append("< ").append(method.getName()); if (!method.getReturnType().equals(Void.TYPE)) { GeckoEditable.debugAppend(log.append(": "), ret); } Log.d(LOGTAG, log.toString()); return ret; } }; return (SessionTextInput.InputConnectionClient) Proxy.newProxyInstance( GeckoInputConnection.class.getClassLoader(), new Class[] { InputConnection.class, SessionTextInput.InputConnectionClient.class, SessionTextInput.EditableListener.class }, handler); } protected GeckoInputConnection(final GeckoSession session, final View targetView, final SessionTextInput.EditableClient editable) { super(targetView, true); mSession = session; mView = targetView; mEditableClient = editable; mIMEState = IME_STATE_DISABLED; // InputConnection that sends keys for plugins, which don't have full editors mKeyInputConnection = new BaseInputConnection(targetView, false); } @Override public synchronized boolean beginBatchEdit() { mBatchEditCount++; if (mBatchEditCount == 1) { mEditableClient.setBatchMode(true); } return true; } @Override public synchronized boolean endBatchEdit() { if (mBatchEditCount <= 0) { Log.w(LOGTAG, "endBatchEdit() called, but mBatchEditCount <= 0?!"); return true; } mBatchEditCount--; if (mBatchEditCount != 0) { return true; } // setBatchMode will call onTextChange and/or onSelectionChange for us. mEditableClient.setBatchMode(false); return true; } @Override public Editable getEditable() { return mEditableClient.getEditable(); } @Override public boolean performContextMenuAction(int id) { final View view = getView(); final Editable editable = getEditable(); if (view == null || editable == null) { return false; } int selStart = Selection.getSelectionStart(editable); int selEnd = Selection.getSelectionEnd(editable); switch (id) { case android.R.id.selectAll: setSelection(0, editable.length()); break; case android.R.id.cut: // If selection is empty, we'll select everything if (selStart == selEnd) { // Fill the clipboard Clipboard.setText(view.getContext(), editable); editable.clear(); } else { Clipboard.setText(view.getContext(), editable.subSequence(Math.min(selStart, selEnd), Math.max(selStart, selEnd))); editable.delete(selStart, selEnd); } break; case android.R.id.paste: - commitText(Clipboard.getText(view.getContext()), 1); + String text = Clipboard.getText(view.getContext()); + if (text != null) { + commitText(text, 1); + } break; case android.R.id.copy: // Copy the current selection or the empty string if nothing is selected. String copiedText = selStart == selEnd ? "" : editable.toString().substring( Math.min(selStart, selEnd), Math.max(selStart, selEnd)); Clipboard.setText(view.getContext(), copiedText); break; } return true; } @Override public ExtractedText getExtractedText(ExtractedTextRequest req, int flags) { if (req == null) return null; if ((flags & GET_EXTRACTED_TEXT_MONITOR) != 0) mUpdateRequest = req; Editable editable = getEditable(); if (editable == null) { return null; } int selStart = Selection.getSelectionStart(editable); int selEnd = Selection.getSelectionEnd(editable); ExtractedText extract = new ExtractedText(); extract.flags = 0; extract.partialStartOffset = -1; extract.partialEndOffset = -1; extract.selectionStart = selStart; extract.selectionEnd = selEnd; extract.startOffset = 0; if ((req.flags & GET_TEXT_WITH_STYLES) != 0) { extract.text = new SpannableString(editable); } else { extract.text = editable.toString(); } return extract; } @Override // SessionTextInput.InputConnectionClient public View getView() { return mView; } @NonNull /* package */ GeckoSession.TextInputDelegate getInputDelegate() { return mSession.getTextInput().getDelegate(); } @Override // SessionTextInput.EditableListener public void onTextChange() { final Editable editable = getEditable(); if (mUpdateRequest == null || editable == null) { return; } final ExtractedTextRequest request = mUpdateRequest; final ExtractedText extractedText = new ExtractedText(); extractedText.flags = 0; // Update the entire Editable range extractedText.partialStartOffset = -1; extractedText.partialEndOffset = -1; extractedText.selectionStart = Selection.getSelectionStart(editable); extractedText.selectionEnd = Selection.getSelectionEnd(editable); extractedText.startOffset = 0; if ((request.flags & GET_TEXT_WITH_STYLES) != 0) { extractedText.text = new SpannableString(editable); } else { extractedText.text = editable.toString(); } getView().post(new Runnable() { @Override public void run() { getInputDelegate().updateExtractedText(mSession, request, extractedText); } }); } @Override // SessionTextInput.EditableListener public void onSelectionChange() { final Editable editable = getEditable(); if (editable != null) { mLastSelectionStart = Selection.getSelectionStart(editable); mLastSelectionEnd = Selection.getSelectionEnd(editable); notifySelectionChange(mLastSelectionStart, mLastSelectionEnd); } } private void notifySelectionChange(final int start, final int end) { final Editable editable = getEditable(); if (editable == null) { return; } final int compositionStart = getComposingSpanStart(editable); final int compositionEnd = getComposingSpanEnd(editable); getView().post(new Runnable() { @Override public void run() { getInputDelegate().updateSelection(mSession, start, end, compositionStart, compositionEnd); } }); } @TargetApi(21) @Override // SessionTextInput.EditableListener public void updateCompositionRects(final RectF[] rects) { if (!(Build.VERSION.SDK_INT >= 21)) { return; } final View view = getView(); if (view == null) { return; } final Editable content = getEditable(); if (content == null) { return; } final int composingStart = getComposingSpanStart(content); final int composingEnd = getComposingSpanEnd(content); if (composingStart < 0 || composingEnd < 0) { if (DEBUG) { Log.d(LOGTAG, "No composition for updates"); } return; } final CharSequence composition = content.subSequence(composingStart, composingEnd); view.post(new Runnable() { @Override public void run() { updateCompositionRectsOnUi(view, rects, composition); } }); } @TargetApi(21) /* package */ void updateCompositionRectsOnUi(final View view, final RectF[] rects, final CharSequence composition) { if (mCursorAnchorInfoBuilder == null) { mCursorAnchorInfoBuilder = new CursorAnchorInfo.Builder(); } mCursorAnchorInfoBuilder.reset(); final Matrix matrix = new Matrix(); mSession.getClientToScreenMatrix(matrix); mCursorAnchorInfoBuilder.setMatrix(matrix); for (int i = 0; i < rects.length; i++) { mCursorAnchorInfoBuilder.addCharacterBounds( i, rects[i].left, rects[i].top, rects[i].right, rects[i].bottom, CursorAnchorInfo.FLAG_HAS_VISIBLE_REGION); } mCursorAnchorInfoBuilder.setComposingText(0, composition); final CursorAnchorInfo info = mCursorAnchorInfoBuilder.build(); getView().post(new Runnable() { @Override public void run() { getInputDelegate().updateCursorAnchorInfo(mSession, info); } }); } @Override public boolean requestCursorUpdates(int cursorUpdateMode) { if ((cursorUpdateMode & InputConnection.CURSOR_UPDATE_IMMEDIATE) != 0) { mEditableClient.requestCursorUpdates( SessionTextInput.EditableClient.ONE_SHOT); } if ((cursorUpdateMode & InputConnection.CURSOR_UPDATE_MONITOR) != 0) { mEditableClient.requestCursorUpdates( SessionTextInput.EditableClient.START_MONITOR); } else { mEditableClient.requestCursorUpdates( SessionTextInput.EditableClient.END_MONITOR); } return true; } @Override // SessionTextInput.EditableListener public void onDefaultKeyEvent(final KeyEvent event) { ThreadUtils.postToUiThread(new Runnable() { @Override public void run() { GeckoInputConnection.this.performDefaultKeyAction(event); } }); } private static synchronized Handler getBackgroundHandler() { if (sBackgroundHandler != null) { return sBackgroundHandler; } // Don't use GeckoBackgroundThread because Gecko thread may block waiting on // GeckoBackgroundThread. If we were to use GeckoBackgroundThread, due to IME, // GeckoBackgroundThread may end up also block waiting on Gecko thread and a // deadlock occurs Thread backgroundThread = new Thread(new Runnable() { @Override public void run() { Looper.prepare(); synchronized (GeckoInputConnection.class) { sBackgroundHandler = new Handler(); GeckoInputConnection.class.notify(); } Looper.loop(); // We should never be exiting the thread loop. throw new IllegalThreadStateException("unreachable code"); } }, LOGTAG); backgroundThread.setDaemon(true); backgroundThread.start(); while (sBackgroundHandler == null) { try { // wait for new thread to set sBackgroundHandler GeckoInputConnection.class.wait(); } catch (InterruptedException e) { } } return sBackgroundHandler; } private synchronized boolean canReturnCustomHandler() { if (mIMEState == IME_STATE_DISABLED) { return false; } for (StackTraceElement frame : Thread.currentThread().getStackTrace()) { // We only return our custom Handler to InputMethodManager's InputConnection // proxy. For all other purposes, we return the regular Handler. // InputMethodManager retrieves the Handler for its InputConnection proxy // inside its method startInputInner(), so we check for that here. This is // valid from Android 2.2 to at least Android 4.2. If this situation ever // changes, we gracefully fall back to using the regular Handler. if ("startInputInner".equals(frame.getMethodName()) && "android.view.inputmethod.InputMethodManager".equals(frame.getClassName())) { // Only return our own Handler to InputMethodManager and only prior to 24. return Build.VERSION.SDK_INT < 24; } if (CUSTOM_HANDLER_TEST_METHOD.equals(frame.getMethodName()) && CUSTOM_HANDLER_TEST_CLASS.equals(frame.getClassName())) { // InputConnection tests should also run on the custom handler return true; } } return false; } private boolean isPhysicalKeyboardPresent() { final View v = getView(); if (v == null) { return false; } final Configuration config = v.getContext().getResources().getConfiguration(); return config.keyboard != Configuration.KEYBOARD_NOKEYS; } @Override // InputConnection public Handler getHandler() { final Handler handler; if (isPhysicalKeyboardPresent()) { handler = ThreadUtils.getUiHandler(); } else { handler = getBackgroundHandler(); } return mEditableClient.setInputConnectionHandler(handler); } @Override // SessionTextInput.InputConnectionClient public Handler getHandler(Handler defHandler) { if (!canReturnCustomHandler()) { return defHandler; } return getHandler(); } @Override // InputConnection public void closeConnection() { // Not supported at the moment. super.closeConnection(); } @Override // SessionTextInput.InputConnectionClient public synchronized InputConnection onCreateInputConnection(EditorInfo outAttrs) { if (mIMEState == IME_STATE_DISABLED) { return null; } Context context = getView().getContext(); DisplayMetrics metrics = context.getResources().getDisplayMetrics(); if (Math.min(metrics.widthPixels, metrics.heightPixels) > INLINE_IME_MIN_DISPLAY_SIZE) { // prevent showing full-screen keyboard only when the screen is tall enough // to show some reasonable amount of the page (see bug 752709) outAttrs.imeOptions |= EditorInfo.IME_FLAG_NO_EXTRACT_UI | EditorInfo.IME_FLAG_NO_FULLSCREEN; } if (DEBUG) { Log.d(LOGTAG, "mapped IME states to: inputType = " + Integer.toHexString(outAttrs.inputType) + ", imeOptions = " + Integer.toHexString(outAttrs.imeOptions)); } String prevInputMethod = mCurrentInputMethod; mCurrentInputMethod = InputMethods.getCurrentInputMethod(context); if (DEBUG) { Log.d(LOGTAG, "IME: CurrentInputMethod=" + mCurrentInputMethod); } outAttrs.initialSelStart = mLastSelectionStart; outAttrs.initialSelEnd = mLastSelectionEnd; return this; } private boolean replaceComposingSpanWithSelection() { final Editable content = getEditable(); if (content == null) { return false; } int a = getComposingSpanStart(content), b = getComposingSpanEnd(content); if (a != -1 && b != -1) { if (DEBUG) { Log.d(LOGTAG, "removing composition at " + a + "-" + b); } removeComposingSpans(content); Selection.setSelection(content, a, b); } return true; } @Override public boolean commitText(CharSequence text, int newCursorPosition) { if (InputMethods.shouldCommitCharAsKey(mCurrentInputMethod) && text.length() == 1 && newCursorPosition > 0) { if (DEBUG) { Log.d(LOGTAG, "committing \"" + text + "\" as key"); } // mKeyInputConnection is a BaseInputConnection that commits text as keys; // but we first need to replace any composing span with a selection, // so that the new key events will generate characters to replace // text from the old composing span return replaceComposingSpanWithSelection() && mKeyInputConnection.commitText(text, newCursorPosition); } return super.commitText(text, newCursorPosition); } @Override public boolean setSelection(int start, int end) { if (start < 0 || end < 0) { // Some keyboards (e.g. Samsung) can call setSelection with // negative offsets. In that case we ignore the call, similar to how // BaseInputConnection.setSelection ignores offsets that go past the length. return true; } return super.setSelection(start, end); } @Override public boolean sendKeyEvent(@NonNull KeyEvent event) { event = translateKey(event.getKeyCode(), event); mEditableClient.sendKeyEvent(getView(), event.getAction(), event); return false; // seems to always return false } private KeyEvent translateKey(final int keyCode, final @NonNull KeyEvent event) { switch (keyCode) { case KeyEvent.KEYCODE_ENTER: if ((event.getFlags() & KeyEvent.FLAG_EDITOR_ACTION) != 0 && mIMEActionHint.equalsIgnoreCase("next")) { return new KeyEvent(event.getAction(), KeyEvent.KEYCODE_TAB); } break; } return event; } // Called by OnDefaultKeyEvent handler, up from Gecko /* package */ void performDefaultKeyAction(KeyEvent event) { switch (event.getKeyCode()) { case KeyEvent.KEYCODE_MUTE: case KeyEvent.KEYCODE_HEADSETHOOK: case KeyEvent.KEYCODE_MEDIA_PLAY: case KeyEvent.KEYCODE_MEDIA_PAUSE: case KeyEvent.KEYCODE_MEDIA_PLAY_PAUSE: case KeyEvent.KEYCODE_MEDIA_STOP: case KeyEvent.KEYCODE_MEDIA_NEXT: case KeyEvent.KEYCODE_MEDIA_PREVIOUS: case KeyEvent.KEYCODE_MEDIA_REWIND: case KeyEvent.KEYCODE_MEDIA_RECORD: case KeyEvent.KEYCODE_MEDIA_FAST_FORWARD: case KeyEvent.KEYCODE_MEDIA_CLOSE: case KeyEvent.KEYCODE_MEDIA_EJECT: case KeyEvent.KEYCODE_MEDIA_AUDIO_TRACK: // Forward media keypresses to the registered handler so headset controls work // Does the same thing as Chromium // https://chromium.googlesource.com/chromium/src/+/49.0.2623.67/chrome/android/java/src/org/chromium/chrome/browser/tab/TabWebContentsDelegateAndroid.java#445 // These are all the keys dispatchMediaKeyEvent supports. if (Build.VERSION.SDK_INT >= 19) { // dispatchMediaKeyEvent is only available on Android 4.4+ Context viewContext = getView().getContext(); AudioManager am = (AudioManager)viewContext.getSystemService(Context.AUDIO_SERVICE); am.dispatchMediaKeyEvent(event); } break; } } @Override // SessionTextInput.EditableListener public void notifyIME(final int type) { switch (type) { case NOTIFY_IME_OF_FOCUS: // Showing/hiding vkb is done in notifyIMEContext if (mBatchEditCount != 0) { Log.w(LOGTAG, "resetting with mBatchEditCount = " + mBatchEditCount); mBatchEditCount = 0; } break; case NOTIFY_IME_OF_BLUR: break; default: if (DEBUG) { throw new IllegalArgumentException("Unexpected NOTIFY_IME=" + type); } break; } } @Override // SessionTextInput.EditableListener public synchronized void notifyIMEContext(final int state, final String typeHint, final String modeHint, final String actionHint, final int flags) { // mIMEState and the mIME*Hint fields should only be changed by notifyIMEContext, // and not reset anywhere else. Usually, notifyIMEContext is called right after a // focus or blur, so resetting mIMEState during the focus or blur seems harmless. // However, this behavior is not guaranteed. Gecko may call notifyIMEContext // independent of focus change; that is, a focus change may not be accompanied by // a notifyIMEContext call. So if we reset mIMEState inside focus, there may not // be another notifyIMEContext call to set mIMEState to a proper value (bug 829318) /* When IME is 'disabled', IME processing is disabled. In addition, the IME UI is hidden */ mIMEState = state; mIMEActionHint = (actionHint == null) ? "" : actionHint; // These fields are reset here and will be updated when restartInput is called below mUpdateRequest = null; mCurrentInputMethod = ""; } }